Merge branch 'master' into docker-tests

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

View File

@ -195,6 +195,7 @@ LDAP_DN=false
LDAP_PASS=false LDAP_PASS=false
LDAP_USER_FILTER=false LDAP_USER_FILTER=false
LDAP_VERSION=false LDAP_VERSION=false
LDAP_START_TLS=false
LDAP_TLS_INSECURE=false LDAP_TLS_INSECURE=false
LDAP_ID_ATTRIBUTE=uid LDAP_ID_ATTRIBUTE=uid
LDAP_EMAIL_ATTRIBUTE=mail LDAP_EMAIL_ATTRIBUTE=mail
@ -245,10 +246,15 @@ AVATAR_URL=
DRAWIO=true DRAWIO=true
# Default item listing view # Default item listing view
# Used for public visitors and user's without a preference # Used for public visitors and user's without a preference.
# Can be 'list' or 'grid' # Can be 'list' or 'grid'.
APP_VIEWS_BOOKS=list APP_VIEWS_BOOKS=list
APP_VIEWS_BOOKSHELVES=grid APP_VIEWS_BOOKSHELVES=grid
APP_VIEWS_BOOKSHELF=grid
# Use dark mode by default
# Will be overriden by any user/session preference.
APP_DEFAULT_DARK_MODE=false
# Page revision limit # Page revision limit
# Number of page revisions to keep in the system before deleting old revisions. # Number of page revisions to keep in the system before deleting old revisions.

View File

@ -5,6 +5,7 @@ on:
branches: branches:
- master - master
- release - release
- gh_actions_update
pull_request: pull_request:
branches: branches:
- '*' - '*'
@ -13,13 +14,19 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-20.04
strategy: strategy:
matrix: matrix:
php: [7.2, 7.4] php: ['7.3', '7.4', '8.0']
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory - name: Get Composer Cache Directory
id: composer-cache id: composer-cache
run: | run: |
@ -38,7 +45,7 @@ jobs:
- name: Setup Database - name: Setup Database
run: | run: |
mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;' mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED BY 'bookstack-test';" mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';"
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';" mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
mysql -uroot -proot -e 'FLUSH PRIVILEGES;' mysql -uroot -proot -e 'FLUSH PRIVILEGES;'

View File

@ -5,6 +5,7 @@ on:
branches: branches:
- master - master
- release - release
- gh_actions_update
pull_request: pull_request:
branches: branches:
- '*' - '*'
@ -13,13 +14,19 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-20.04
strategy: strategy:
matrix: matrix:
php: [7.2, 7.4] php: ['7.3', '7.4', '8.0']
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory - name: Get Composer Cache Directory
id: composer-cache id: composer-cache
run: | run: |
@ -38,7 +45,7 @@ jobs:
- name: Create database & user - name: Create database & user
run: | run: |
mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;' mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED BY 'bookstack-test';" mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';"
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';" mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
mysql -uroot -proot -e 'FLUSH PRIVILEGES;' mysql -uroot -proot -e 'FLUSH PRIVILEGES;'

View File

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

View File

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

View File

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

View File

@ -26,7 +26,9 @@ class TagRepo
*/ */
public function getNameSuggestions(?string $searchTerm): Collection 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) { if ($searchTerm) {
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc'); $query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
@ -45,7 +47,9 @@ class TagRepo
*/ */
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection 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) { if ($searchTerm) {
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc'); $query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');

View File

@ -65,7 +65,7 @@ class ViewService
{ {
$skipCount = $count * $page; $skipCount = $count * $page;
$query = $this->permissionService $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')) ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type') ->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc'); ->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; $this->user = null;
} }
} }

View File

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

View File

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

View File

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

View File

@ -31,6 +31,14 @@ class Ldap
return ldap_set_option($ldapConnection, $option, $value); 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. * Set the version number for the given ldap connection.
* @param $ldapConnection * @param $ldapConnection

View File

@ -85,9 +85,9 @@ class LdapService extends ExternalAuthService
$userCn = $this->getUserResponseProperty($user, 'cn', null); $userCn = $this->getUserResponseProperty($user, 'cn', null);
$formatted = [ $formatted = [
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']), 'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn), 'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'], 'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null), 'email' => $this->getUserResponseProperty($user, $emailAttr, null),
]; ];
@ -187,8 +187,8 @@ class LdapService extends ExternalAuthService
throw new LdapException(trans('errors.ldap_extension_not_installed')); 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 // Disable certificate verification.
// the LDAP_OPT_X_TLS_REQUIRE_CERT option. It can only be set globally and not per handle. // This option works globally and must be set before a connection is created.
if ($this->config['tls_insecure']) { if ($this->config['tls_insecure']) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER); $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']); $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; $this->ldapConnection = $ldapConnection;
return $this->ldapConnection; return $this->ldapConnection;
} }

View File

@ -6,6 +6,8 @@ use BookStack\Auth\User;
use BookStack\Auth\UserRepo; use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Exception; use Exception;
class RegistrationService class RegistrationService
@ -71,6 +73,7 @@ class RegistrationService
} }
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser); Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(), $newUser);
// Start email confirmation flow if required // Start email confirmation flow if required
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) { if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
@ -83,7 +86,6 @@ class RegistrationService
$message = trans('auth.email_confirm_send_error'); $message = trans('auth.email_confirm_send_error');
throw new UserRegistrationException($message, '/register/confirm'); throw new UserRegistrationException($message, '/register/confirm');
} }
} }
return $newUser; return $newUser;
@ -109,5 +111,4 @@ class RegistrationService
throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), $redirect); 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\SamlException;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Exception; use Exception;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use OneLogin\Saml2\Auth; use OneLogin\Saml2\Auth;
@ -375,6 +377,7 @@ class Saml2Service extends ExternalAuthService
auth()->login($user); auth()->login($user);
Activity::add(ActivityType::AUTH_LOGIN, "saml2; {$user->logDescriptor()}"); Activity::add(ActivityType::AUTH_LOGIN, "saml2; {$user->logDescriptor()}");
Theme::dispatch(ThemeEvents::AUTH_LOGIN, 'saml2', $user);
return $user; return $user;
} }
} }

View File

@ -2,21 +2,23 @@
use BookStack\Actions\ActivityType; use BookStack\Actions\ActivityType;
use BookStack\Auth\SocialAccount; use BookStack\Auth\SocialAccount;
use BookStack\Auth\UserRepo; use BookStack\Auth\User;
use BookStack\Exceptions\SocialDriverNotConfigured; use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed; use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite; use Laravel\Socialite\Contracts\Factory as Socialite;
use Laravel\Socialite\Contracts\Provider; use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Contracts\User as SocialUser; use Laravel\Socialite\Contracts\User as SocialUser;
use SocialiteProviders\Manager\SocialiteWasCalled;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
class SocialAuthService class SocialAuthService
{ {
protected $userRepo;
protected $socialite; protected $socialite;
protected $socialAccount; protected $socialAccount;
@ -25,14 +27,11 @@ class SocialAuthService
/** /**
* SocialAuthService constructor. * SocialAuthService constructor.
*/ */
public function __construct(UserRepo $userRepo, Socialite $socialite, SocialAccount $socialAccount) public function __construct(Socialite $socialite)
{ {
$this->userRepo = $userRepo;
$this->socialite = $socialite; $this->socialite = $socialite;
$this->socialAccount = $socialAccount;
} }
/** /**
* Start the social login path. * Start the social login path.
* @throws SocialDriverNotConfigured * @throws SocialDriverNotConfigured
@ -60,11 +59,11 @@ class SocialAuthService
public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser
{ {
// Check social account has not already been used // Check social account has not already been used
if ($this->socialAccount->where('driver_id', '=', $socialUser->getId())->exists()) { if (SocialAccount::query()->where('driver_id', '=', $socialUser->getId())->exists()) {
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount'=>$socialDriver]), '/login'); 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(); $email = $socialUser->getEmail();
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login'); throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login');
} }
@ -91,7 +90,7 @@ class SocialAuthService
$socialId = $socialUser->getId(); $socialId = $socialUser->getId();
// Get any attached social accounts or users // 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(); $isLoggedIn = auth()->check();
$currentUser = user(); $currentUser = user();
$titleCaseDriver = Str::title($socialDriver); $titleCaseDriver = Str::title($socialDriver);
@ -101,14 +100,15 @@ class SocialAuthService
if (!$isLoggedIn && $socialAccount !== null) { if (!$isLoggedIn && $socialAccount !== null) {
auth()->login($socialAccount->user); auth()->login($socialAccount->user);
Activity::add(ActivityType::AUTH_LOGIN, $socialAccount); Activity::add(ActivityType::AUTH_LOGIN, $socialAccount);
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $socialDriver, $socialAccount->user);
return redirect()->intended('/'); return redirect()->intended('/');
} }
// When a user is logged in but the social account does not exist, // 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. // Create the social account and attach it to the user & redirect to the profile page.
if ($isLoggedIn && $socialAccount === null) { if ($isLoggedIn && $socialAccount === null) {
$this->fillSocialAccount($socialDriver, $socialUser); $account = $this->newSocialAccount($socialDriver, $socialUser);
$currentUser->socialAccounts()->save($this->socialAccount); $currentUser->socialAccounts()->save($account);
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver])); session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl()); return redirect($currentUser->getEditUrl());
} }
@ -130,7 +130,7 @@ class SocialAuthService
if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') { if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') {
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]); $message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]);
} }
throw new SocialSignInAccountNotUsed($message, '/login'); throw new SocialSignInAccountNotUsed($message, '/login');
} }
@ -207,21 +207,19 @@ class SocialAuthService
/** /**
* Fill and return a SocialAccount from the given driver name and SocialUser. * 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([ return new SocialAccount([
'driver' => $socialDriver, 'driver' => $socialDriver,
'driver_id' => $socialUser->getId(), 'driver_id' => $socialUser->getId(),
'avatar' => $socialUser->getAvatar() 'avatar' => $socialUser->getAvatar()
]); ]);
return $this->socialAccount;
} }
/** /**
* Detach a social account from a user. * 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(); user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
} }
@ -242,4 +240,20 @@ class SocialAuthService
return $driver; 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; <?php namespace BookStack\Auth\Permissions;
use BookStack\Auth\Permissions;
use BookStack\Auth\Role; use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book; 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\Models\Entity;
use BookStack\Entities\EntityProvider; use BookStack\Entities\Models\Page;
use BookStack\Model; use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater; use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner; use BookStack\Traits\HasOwner;
use Illuminate\Database\Connection; use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Support\Collection; use Throwable;
class PermissionService class PermissionService
{ {
/**
* @var ?array
*/
protected $userRoles = null;
protected $currentAction; /**
protected $isAdminUser; * @var ?User
protected $userRoles = false; */
protected $currentUserModel = false; protected $currentUserModel = null;
/** /**
* @var Connection * @var Connection
@ -27,47 +35,20 @@ class PermissionService
protected $db; protected $db;
/** /**
* @var JointPermission * @var array
*/ */
protected $jointPermission;
/**
* @var Role
*/
protected $role;
/**
* @var EntityPermission
*/
protected $entityPermission;
/**
* @var EntityProvider
*/
protected $entityProvider;
protected $entityCache; protected $entityCache;
/** /**
* PermissionService constructor. * PermissionService constructor.
*/ */
public function __construct( public function __construct(Connection $db)
JointPermission $jointPermission, {
Permissions\EntityPermission $entityPermission,
Role $role,
Connection $db,
EntityProvider $entityProvider
) {
$this->db = $db; $this->db = $db;
$this->jointPermission = $jointPermission;
$this->entityPermission = $entityPermission;
$this->role = $role;
$this->entityProvider = $entityProvider;
} }
/** /**
* Set the database connection * Set the database connection
* @param Connection $connection
*/ */
public function setConnection(Connection $connection) public function setConnection(Connection $connection)
{ {
@ -76,81 +57,63 @@ class PermissionService
/** /**
* Prepare the local entity cache and ensure it's empty * 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 = []; $this->entityCache = [];
foreach ($entities as $entity) { foreach ($entities as $entity) {
$type = $entity->getType(); $class = get_class($entity);
if (!isset($this->entityCache[$type])) { if (!isset($this->entityCache[$class])) {
$this->entityCache[$type] = collect(); $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 * 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)) { if (isset($this->entityCache[Book::class]) && $this->entityCache[Book::class]->has($bookId)) {
return $this->entityCache['book']->get($bookId); return $this->entityCache[Book::class]->get($bookId);
} }
$book = $this->entityProvider->book->find($bookId); return Book::query()->withTrashed()->find($bookId);
if ($book === null) {
$book = false;
}
return $book;
} }
/** /**
* Get a chapter via ID, Checks local cache * 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)) { if (isset($this->entityCache[Chapter::class]) && $this->entityCache[Chapter::class]->has($chapterId)) {
return $this->entityCache['chapter']->get($chapterId); return $this->entityCache[Chapter::class]->get($chapterId);
} }
$chapter = $this->entityProvider->chapter->find($chapterId); return Chapter::query()
if ($chapter === null) { ->withTrashed()
$chapter = false; ->find($chapterId);
}
return $chapter;
} }
/** /**
* Get the roles for the current user; * Get the roles for the current logged in user.
* @return array|bool
*/ */
protected function getRoles() protected function getCurrentUserRoles(): array
{ {
if ($this->userRoles !== false) { if (!is_null($this->userRoles)) {
return $this->userRoles; return $this->userRoles;
} }
$roles = [];
if (auth()->guest()) { if (auth()->guest()) {
$roles[] = $this->role->getSystemRole('public')->id; $this->userRoles = [Role::getSystemRole('public')->id];
return $roles; } else {
$this->userRoles = $this->currentUser()->roles->pluck('id')->values()->all();
} }
return $this->userRoles;
foreach ($this->currentUser()->roles as $role) {
$roles[] = $role->id;
}
return $roles;
} }
/** /**
@ -158,59 +121,57 @@ class PermissionService
*/ */
public function buildJointPermissions() public function buildJointPermissions()
{ {
$this->jointPermission->truncate(); JointPermission::query()->truncate();
$this->readyEntityCache(); $this->readyEntityCache();
// Get all roles (Should be the most limited dimension) // 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 // 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); $this->buildJointPermissionsForBooks($books, $roles);
}); });
// Chunk through all bookshelves // Chunk through all bookshelves
$this->entityProvider->bookshelf->newQuery()->withTrashed()->select(['id', 'restricted', 'owned_by']) Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
->chunk(50, function ($shelves) use ($roles) { ->chunk(50, function (EloquentCollection $shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles); $this->buildJointPermissionsForShelves($shelves, $roles);
}); });
} }
/** /**
* Get a query for fetching a book with it's children. * 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() return Book::query()->withTrashed()
->select(['id', 'restricted', 'owned_by'])->with(['chapters' => function ($query) { ->select(['id', 'restricted', 'owned_by'])->with([
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']); 'chapters' => function ($query) {
}, 'pages' => function ($query) { $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']); },
}]); 'pages' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
}
]);
} }
/** /**
* @param Collection $shelves * Build joint permissions for the given shelf and role combinations.
* @param array $roles * @throws Throwable
* @param bool $deleteOld
* @throws \Throwable
*/ */
protected function buildJointPermissionsForShelves($shelves, $roles, $deleteOld = false) protected function buildJointPermissionsForShelves(EloquentCollection $shelves, array $roles, bool $deleteOld = false)
{ {
if ($deleteOld) { if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($shelves->all()); $this->deleteManyJointPermissionsForEntities($shelves->all());
} }
$this->createManyJointPermissions($shelves, $roles); $this->createManyJointPermissions($shelves->all(), $roles);
} }
/** /**
* Build joint permissions for an array of books * Build joint permissions for the given book and role combinations.
* @param Collection $books * @throws Throwable
* @param array $roles
* @param bool $deleteOld
*/ */
protected function buildJointPermissionsForBooks($books, $roles, $deleteOld = false) protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
{ {
$entities = clone $books; $entities = clone $books;
@ -227,55 +188,53 @@ class PermissionService
if ($deleteOld) { if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($entities->all()); $this->deleteManyJointPermissionsForEntities($entities->all());
} }
$this->createManyJointPermissions($entities, $roles); $this->createManyJointPermissions($entities->all(), $roles);
} }
/** /**
* Rebuild the entity jointPermissions for a particular entity. * Rebuild the entity jointPermissions for a particular entity.
* @param \BookStack\Entities\Models\Entity $entity * @throws Throwable
* @throws \Throwable
*/ */
public function buildJointPermissionsForEntity(Entity $entity) public function buildJointPermissionsForEntity(Entity $entity)
{ {
$entities = [$entity]; $entities = [$entity];
if ($entity->isA('book')) { if ($entity instanceof Book) {
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get(); $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; return;
} }
/** @var BookChild $entity */
if ($entity->book) { if ($entity->book) {
$entities[] = $entity->book; $entities[] = $entity->book;
} }
if ($entity->isA('page') && $entity->chapter_id) { if ($entity instanceof Page && $entity->chapter_id) {
$entities[] = $entity->chapter; $entities[] = $entity->chapter;
} }
if ($entity->isA('chapter')) { if ($entity instanceof Chapter) {
foreach ($entity->pages as $page) { foreach ($entity->pages as $page) {
$entities[] = $page; $entities[] = $page;
} }
} }
$this->buildJointPermissionsForEntities(collect($entities)); $this->buildJointPermissionsForEntities($entities);
} }
/** /**
* Rebuild the entity jointPermissions for a collection of 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(); $roles = Role::query()->get()->values()->all();
$this->deleteManyJointPermissionsForEntities($entities->all()); $this->deleteManyJointPermissionsForEntities($entities);
$this->createManyJointPermissions($entities, $roles); $this->createManyJointPermissions($entities, $roles);
} }
/** /**
* Build the entity jointPermissions for a particular role. * Build the entity jointPermissions for a particular role.
* @param Role $role
*/ */
public function buildJointPermissionForRole(Role $role) public function buildJointPermissionForRole(Role $role)
{ {
@ -288,7 +247,7 @@ class PermissionService
}); });
// Chunk through all bookshelves // 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) { ->chunk(50, function ($shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles); $this->buildJointPermissionsForShelves($shelves, $roles);
}); });
@ -296,7 +255,6 @@ class PermissionService
/** /**
* Delete the entity jointPermissions attached to a particular role. * Delete the entity jointPermissions attached to a particular role.
* @param Role $role
*/ */
public function deleteJointPermissionsForRole(Role $role) public function deleteJointPermissionsForRole(Role $role)
{ {
@ -312,13 +270,13 @@ class PermissionService
$roleIds = array_map(function ($role) { $roleIds = array_map(function ($role) {
return $role->id; return $role->id;
}, $roles); }, $roles);
$this->jointPermission->newQuery()->whereIn('role_id', $roleIds)->delete(); JointPermission::query()->whereIn('role_id', $roleIds)->delete();
} }
/** /**
* Delete the entity jointPermissions for a particular entity. * Delete the entity jointPermissions for a particular entity.
* @param Entity $entity * @param Entity $entity
* @throws \Throwable * @throws Throwable
*/ */
public function deleteJointPermissionsForEntity(Entity $entity) public function deleteJointPermissionsForEntity(Entity $entity)
{ {
@ -327,10 +285,10 @@ class PermissionService
/** /**
* Delete all of the entity jointPermissions for a list of entities. * Delete all of the entity jointPermissions for a list of entities.
* @param \BookStack\Entities\Models\Entity[] $entities * @param Entity[] $entities
* @throws \Throwable * @throws Throwable
*/ */
protected function deleteManyJointPermissionsForEntities($entities) protected function deleteManyJointPermissionsForEntities(array $entities)
{ {
if (count($entities) === 0) { if (count($entities) === 0) {
return; return;
@ -352,19 +310,19 @@ class PermissionService
} }
/** /**
* Create & Save entity jointPermissions for many entities and jointPermissions. * Create & Save entity jointPermissions for many entities and roles.
* @param Collection $entities * @param Entity[] $entities
* @param array $roles * @param Role[] $roles
* @throws \Throwable * @throws Throwable
*/ */
protected function createManyJointPermissions($entities, $roles) protected function createManyJointPermissions(array $entities, array $roles)
{ {
$this->readyEntityCache($entities); $this->readyEntityCache($entities);
$jointPermissions = []; $jointPermissions = [];
// Fetch Entity Permissions and create a mapping of entity restricted statuses // Fetch Entity Permissions and create a mapping of entity restricted statuses
$entityRestrictedMap = []; $entityRestrictedMap = [];
$permissionFetch = $this->entityPermission->newQuery(); $permissionFetch = EntityPermission::query();
foreach ($entities as $entity) { foreach ($entities as $entity) {
$entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted')); $entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted'));
$permissionFetch->orWhere(function ($query) use ($entity) { $permissionFetch->orWhere(function ($query) use ($entity) {
@ -408,16 +366,14 @@ class PermissionService
/** /**
* Get the actions related to an entity. * 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']; $baseActions = ['view', 'update', 'delete'];
if ($entity->isA('chapter') || $entity->isA('book')) { if ($entity instanceof Chapter || $entity instanceof Book) {
$baseActions[] = 'page-create'; $baseActions[] = 'page-create';
} }
if ($entity->isA('book')) { if ($entity instanceof Book) {
$baseActions[] = 'chapter-create'; $baseActions[] = 'chapter-create';
} }
return $baseActions; return $baseActions;
@ -426,14 +382,8 @@ class PermissionService
/** /**
* Create entity permission data for an entity and role * Create entity permission data for an entity and role
* for a particular action. * 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; $permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
$roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']); $roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']);
@ -450,7 +400,7 @@ class PermissionService
return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess); 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); return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn);
} }
@ -460,7 +410,7 @@ class PermissionService
$hasPermissiveAccessToParents = !$book->restricted; $hasPermissiveAccessToParents = !$book->restricted;
// For pages with a chapter, Check if explicit permissions are set on the Chapter // 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); $chapter = $this->getChapter($entity->chapter_id);
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted; $hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
if ($chapter->restricted) { if ($chapter->restricted) {
@ -479,38 +429,27 @@ class PermissionService
/** /**
* Check for an active restriction in an entity map. * 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; $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. * Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion. * 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 [ return [
'role_id' => $role->getRawAttribute('id'), 'role_id' => $role->getRawAttribute('id'),
'entity_id' => $entity->getRawAttribute('id'), 'entity_id' => $entity->getRawAttribute('id'),
'entity_type' => $entity->getMorphClass(), 'entity_type' => $entity->getMorphClass(),
'action' => $action, 'action' => $action,
'has_permission' => $permissionAll, 'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn, '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); $baseQuery = $ownable->newQuery()->where('id', '=', $ownable->id);
$action = end($explodedPermission); $action = end($explodedPermission);
$this->currentAction = $action; $user = $this->currentUser();
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment']; $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
// Handle non entity specific jointPermissions // Handle non entity specific jointPermissions
if (in_array($explodedPermission[0], $nonJointPermissions)) { if (in_array($explodedPermission[0], $nonJointPermissions)) {
$allPermission = $this->currentUser() && $this->currentUser()->can($permission . '-all'); $allPermission = $user && $user->can($permission . '-all');
$ownPermission = $this->currentUser() && $this->currentUser()->can($permission . '-own'); $ownPermission = $user && $user->can($permission . '-own');
$this->currentAction = 'view';
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by'; $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)); return ($allPermission || ($isOwner && $ownPermission));
} }
// Handle abnormal create jointPermissions // Handle abnormal create jointPermissions
if ($action === 'create') { if ($action === 'create') {
$this->currentAction = $permission; $action = $permission;
} }
$q = $this->entityRestrictionQuery($baseQuery)->count() > 0; $hasAccess = $this->entityRestrictionQuery($baseQuery, $action)->count() > 0;
$this->clean(); $this->clean();
return $q; return $hasAccess;
} }
/** /**
* Checks if a user has the given permission for any items in the system. * 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. * 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(); $userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray();
$userId = $this->currentUser()->id; $userId = $this->currentUser()->id;
$permissionQuery = $this->db->table('joint_permissions') $permissionQuery = JointPermission::query()
->where('action', '=', $permission) ->where('action', '=', $permission)
->whereIn('role_id', $userRoleIds) ->whereIn('role_id', $userRoleIds)
->where(function ($query) use ($userId) { ->where(function (Builder $query) use ($userId) {
$query->where('has_permission', '=', 1) $this->addJointHasPermissionCheck($query, $userId);
->orWhere(function ($query2) use ($userId) {
$query2->where('has_permission_own', '=', 1)
->where('owned_by', '=', $userId);
});
}); });
if (!is_null($entityClass)) { if (!is_null($entityClass)) {
$entityInstance = app()->make($entityClass); $entityInstance = app($entityClass);
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass()); $permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
} }
@ -581,46 +512,22 @@ class PermissionService
return $hasPermission; 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 * The general query filter to remove all entities
* that the current user does not have access to. * 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) { $q = $query->where(function ($parentQuery) use ($action) {
$parentQuery->whereHas('jointPermissions', function ($permissionQuery) { $parentQuery->whereHas('jointPermissions', function ($permissionQuery) use ($action) {
$permissionQuery->whereIn('role_id', $this->getRoles()) $permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
->where('action', '=', $this->currentAction) ->where('action', '=', $action)
->where(function ($query) { ->where(function (Builder $query) {
$query->where('has_permission', '=', true) $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
}); });
}); });
}); });
$this->clean(); $this->clean();
return $q; return $q;
} }
@ -634,14 +541,10 @@ class PermissionService
$this->clean(); $this->clean();
return $query->where(function (Builder $parentQuery) use ($ability) { return $query->where(function (Builder $parentQuery) use ($ability) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) 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('action', '=', $ability)
->where(function (Builder $query) { ->where(function (Builder $query) {
$query->where('has_permission', '=', true) $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
->orWhere(function (Builder $query) {
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
}); });
}); });
}); });
@ -651,7 +554,7 @@ class PermissionService
* Extend the given page query to ensure draft items are not visible * Extend the given page query to ensure draft items are not visible
* unless created by the given user. * unless created by the given user.
*/ */
public function enforceDraftVisiblityOnQuery(Builder $query): Builder public function enforceDraftVisibilityOnQuery(Builder $query): Builder
{ {
return $query->where(function (Builder $query) { return $query->where(function (Builder $query) {
$query->where('draft', '=', false) $query->where('draft', '=', false)
@ -663,109 +566,89 @@ class PermissionService
} }
/** /**
* Add restrictions for a generic entity * Add restrictions for a generic entity.
* @param string $entityType
* @param Builder|\BookStack\Entities\Models\Entity $query
* @param string $action
* @return Builder
*/ */
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. // Prevent drafts being visible to others.
$query = $query->where(function ($query) { $this->enforceDraftVisibilityOnQuery($query);
$query->where('draft', '=', false)
->orWhere(function ($query) {
$query->where('draft', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
});
} }
$this->currentAction = $action; return $this->entityRestrictionQuery($query, $action);
return $this->entityRestrictionQuery($query);
} }
/** /**
* Filter items that have entities set as a polymorphic relation. * 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]; $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$q = $query->where(function ($query) use ($tableDetails) { $q = $query->where(function ($query) use ($tableDetails, $action) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails) { $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
$permissionQuery->select('id')->from('joint_permissions') $permissionQuery->select('id')->from('joint_permissions')
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn']) ->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->where('action', '=', $this->currentAction) ->where('action', '=', $action)
->whereIn('role_id', $this->getRoles()) ->whereIn('role_id', $this->getCurrentUserRoles())
->where(function ($query) { ->where(function (QueryBuilder $query) {
$query->where('has_permission', '=', true)->orWhere(function ($query) { $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
}); });
}); });
}); });
$this->clean(); $this->clean();
return $q; return $q;
} }
/** /**
* Add conditions to a query to filter the selection to related entities * Add conditions to a query to filter the selection to related entities
* where permissions are granted. * where view permissions are granted.
* @param $entityType
* @param $query
* @param $tableName
* @param $entityIdColumn
* @return mixed
*/ */
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]; $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
$morphClass = app($entityClass)->getMorphClass();
$pageMorphClass = $this->entityProvider->get($entityType)->getMorphClass(); $q = $query->where(function ($query) use ($tableDetails, $morphClass) {
$query->where(function ($query) use (&$tableDetails, $morphClass) {
$q = $query->where(function ($query) use ($tableDetails, $pageMorphClass) { $query->whereExists(function ($permissionQuery) use (&$tableDetails, $morphClass) {
$query->where(function ($query) use (&$tableDetails, $pageMorphClass) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails, $pageMorphClass) {
$permissionQuery->select('id')->from('joint_permissions') $permissionQuery->select('id')->from('joint_permissions')
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where('entity_type', '=', $pageMorphClass) ->where('entity_type', '=', $morphClass)
->where('action', '=', $this->currentAction) ->where('action', '=', 'view')
->whereIn('role_id', $this->getRoles()) ->whereIn('role_id', $this->getCurrentUserRoles())
->where(function ($query) { ->where(function (QueryBuilder $query) {
$query->where('has_permission', '=', true)->orWhere(function ($query) { $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
}); });
}); });
})->orWhere($tableDetails['entityIdColumn'], '=', 0); })->orWhere($tableDetails['entityIdColumn'], '=', 0);
}); });
$this->clean(); $this->clean();
return $q; return $q;
} }
/** /**
* Get the current user * Add the query for checking the given user id has permission
* @return \BookStack\Auth\User * 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(); $this->currentUserModel = user();
} }
@ -775,10 +658,9 @@ class PermissionService
/** /**
* Clean the cached user elements. * Clean the cached user elements.
*/ */
private function clean() private function clean(): void
{ {
$this->currentUserModel = false; $this->currentUserModel = null;
$this->userRoles = false; $this->userRoles = null;
$this->isAdminUser = null;
} }
} }

View File

@ -1,7 +1,9 @@
<?php namespace BookStack\Auth; <?php namespace BookStack\Auth;
use BookStack\Api\ApiToken; use BookStack\Api\ApiToken;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Interfaces\Loggable; use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable;
use BookStack\Model; use BookStack\Model;
use BookStack\Notifications\ResetPassword; use BookStack\Notifications\ResetPassword;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
@ -22,6 +24,7 @@ use Illuminate\Support\Collection;
* Class User * Class User
* @property string $id * @property string $id
* @property string $name * @property string $name
* @property string $slug
* @property string $email * @property string $email
* @property string $password * @property string $password
* @property Carbon $created_at * @property Carbon $created_at
@ -30,8 +33,9 @@ use Illuminate\Support\Collection;
* @property int $image_id * @property int $image_id
* @property string $external_auth_id * @property string $external_auth_id
* @property string $system_name * @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; use Authenticatable, CanResetPassword, Notifiable;
@ -72,23 +76,21 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/** /**
* Returns the default public user. * Returns the default public user.
* @return User
*/ */
public static function getDefault() public static function getDefault(): User
{ {
if (!is_null(static::$defaultUser)) { if (!is_null(static::$defaultUser)) {
return 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; return static::$defaultUser;
} }
/** /**
* Check if the user is the default public user. * Check if the user is the default public user.
* @return bool
*/ */
public function isDefault() public function isDefault(): bool
{ {
return $this->system_name === 'public'; return $this->system_name === 'public';
} }
@ -115,12 +117,10 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/** /**
* Check if the user has a role. * 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. * Get the social account associated with this user.
* @return HasMany
*/ */
public function socialAccounts() public function socialAccounts(): HasMany
{ {
return $this->hasMany(SocialAccount::class); return $this->hasMany(SocialAccount::class);
} }
@ -207,11 +206,9 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
} }
/** /**
* Returns the user's avatar, * Returns a URL to the user's avatar
* @param int $size
* @return string
*/ */
public function getAvatar($size = 50) public function getAvatar(int $size = 50): string
{ {
$default = url('/user_avatar.png'); $default = url('/user_avatar.png');
$imageId = $this->image_id; $imageId = $this->image_id;
@ -229,9 +226,8 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/** /**
* Get the avatar for the user. * Get the avatar for the user.
* @return BelongsTo
*/ */
public function avatar() public function avatar(): BelongsTo
{ {
return $this->belongsTo(Image::class, 'image_id'); return $this->belongsTo(Image::class, 'image_id');
} }
@ -271,15 +267,13 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/ */
public function getProfileUrl(): string public function getProfileUrl(): string
{ {
return url('/user/' . $this->id); return url('/user/' . $this->slug);
} }
/** /**
* Get a shortened version of the user's name. * 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) { if (mb_strlen($this->name) <= $chars) {
return $this->name; return $this->name;
@ -310,4 +304,13 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
{ {
return "({$this->id}) {$this->name}"; 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); 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. * Get all the users with their permissions.
*/ */
@ -159,7 +167,13 @@ class UserRepo
'email_confirmed' => $emailConfirmed, 'email_confirmed' => $emailConfirmed,
'external_auth_id' => $data['external_auth_id'] ?? '', '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. // private configuration variables so should remain disabled in public.
'debug' => env('APP_DEBUG', false), '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. // The number of revisions to keep in the database.
// Once this limit is reached older revisions will be deleted. // Once this limit is reached older revisions will be deleted.
// If set to false then a limit will not be enforced. // If set to false then a limit will not be enforced.
@ -63,7 +56,7 @@ return [
'locale' => env('APP_LANG', 'en'), 'locale' => env('APP_LANG', 'en'),
// Locales available // 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 // Application Fallback Locale
'fallback_locale' => 'en', 'fallback_locale' => 'en',
@ -122,6 +115,7 @@ return [
BookStack\Providers\TranslationServiceProvider::class, BookStack\Providers\TranslationServiceProvider::class,
// BookStack custom service providers // BookStack custom service providers
BookStack\Providers\ThemeServiceProvider::class,
BookStack\Providers\AuthServiceProvider::class, BookStack\Providers\AuthServiceProvider::class,
BookStack\Providers\AppServiceProvider::class, BookStack\Providers\AppServiceProvider::class,
BookStack\Providers\BroadcastServiceProvider::class, BookStack\Providers\BroadcastServiceProvider::class,
@ -190,10 +184,10 @@ return [
// Custom BookStack // Custom BookStack
'Activity' => BookStack\Facades\Activity::class, 'Activity' => BookStack\Facades\Activity::class,
'Setting' => BookStack\Facades\Setting::class,
'Views' => BookStack\Facades\Views::class, 'Views' => BookStack\Facades\Views::class,
'Images' => BookStack\Facades\Images::class, 'Images' => BookStack\Facades\Images::class,
'Permissions' => BookStack\Facades\Permissions::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'), 'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false), 'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
'tls_insecure' => env('LDAP_TLS_INSECURE', 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 // The session cookie path determines the path for which the cookie will
// be regarded as available. Typically, this will be the root path of // be regarded as available. Typically, this will be the root path of
// your application but you are free to change this when necessary. // your application but you are free to change this when necessary.
'path' => '/', 'path' => '/' . (explode('/', env('APP_URL', ''), 4)[3] ?? ''),
// Session Cookie Domain // Session Cookie Domain
// Here you may change the domain of the cookie used to identify a session // 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, 'app-custom-head' => false,
'registration-enabled' => 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\Console\Command;
use Illuminate\Database\Connection; use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
class UpdateUrl extends Command class UpdateUrl extends Command
{ {
@ -60,22 +61,50 @@ class UpdateUrl extends Command
"attachments" => ["path"], "attachments" => ["path"],
"pages" => ["html", "text", "markdown"], "pages" => ["html", "text", "markdown"],
"images" => ["url"], "images" => ["url"],
"settings" => ["value"],
"comments" => ["html", "text"], "comments" => ["html", "text"],
]; ];
foreach ($columnsToUpdateByTable as $table => $columns) { foreach ($columnsToUpdateByTable as $table => $columns) {
foreach ($columns as $column) { foreach ($columns as $column) {
$changeCount = $this->db->table($table)->update([ $changeCount = $this->replaceValueInTable($table, $column, $oldUrl, $newUrl);
$column => $this->db->raw("REPLACE({$column}, '{$oldUrl}', '{$newUrl}')")
]);
$this->info("Updated {$changeCount} rows in {$table}->{$column}"); $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("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; 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. * Warn the user of the dangers of this operation.
* Returns a boolean indicating if they've accepted the warnings. * Returns a boolean indicating if they've accepted the warnings.

View File

@ -1,8 +1,5 @@
<?php namespace BookStack\Entities\Models; <?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\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -49,7 +46,7 @@ abstract class BookChild extends Entity
// Update all child pages if a chapter // Update all child pages if a chapter
if ($this instanceof Chapter) { if ($this instanceof Chapter) {
foreach ($this->pages as $page) { foreach ($this->pages()->withTrashed()->get() as $page) {
$page->changeBook($newBookId); $page->changeBook($newBookId);
} }
} }

View File

@ -9,6 +9,7 @@ use BookStack\Auth\Permissions\JointPermission;
use BookStack\Entities\Tools\SearchIndex; use BookStack\Entities\Tools\SearchIndex;
use BookStack\Entities\Tools\SlugGenerator; use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Facades\Permissions; use BookStack\Facades\Permissions;
use BookStack\Interfaces\Sluggable;
use BookStack\Model; use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater; use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner; use BookStack\Traits\HasOwner;
@ -37,7 +38,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static Builder withLastView() * @method static Builder withLastView()
* @method static Builder withViewCount() * @method static Builder withViewCount()
*/ */
abstract class Entity extends Model abstract class Entity extends Model implements Sluggable
{ {
use SoftDeletes; use SoftDeletes;
use HasCreatorAndUpdater; 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 public function refreshSlug(): string
{ {
$this->slug = (new SlugGenerator)->generate($this); $this->slug = app(SlugGenerator::class)->generate($this);
return $this->slug; return $this->slug;
} }
} }

View File

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

View File

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

View File

@ -13,4 +13,4 @@ class CustomStrikeThroughExtension implements ExtensionInterface
$environment->addDelimiterProcessor(new StrikethroughDelimiterProcessor()); $environment->addDelimiterProcessor(new StrikethroughDelimiterProcessor());
$environment->addInlineRenderer(Strikethrough::class, new CustomStrikethroughRenderer()); $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())); 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\Models\Page;
use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension; use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use DOMDocument; use DOMDocument;
use DOMNodeList; use DOMNodeList;
use DOMXPath; use DOMXPath;
@ -53,6 +55,7 @@ class PageContent
$environment->addExtension(new TableExtension()); $environment->addExtension(new TableExtension());
$environment->addExtension(new TaskListExtension()); $environment->addExtension(new TaskListExtension());
$environment->addExtension(new CustomStrikeThroughExtension()); $environment->addExtension(new CustomStrikeThroughExtension());
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
$converter = new CommonMarkConverter([], $environment); $converter = new CommonMarkConverter([], $environment);
return $converter->convertToHtml($markdown); return $converter->convertToHtml($markdown);
} }

View File

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

View File

@ -1,6 +1,7 @@
<?php namespace BookStack\Entities\Tools; <?php namespace BookStack\Entities\Tools;
use BookStack\Auth\Permissions\PermissionService; use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\EntityProvider; use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use Illuminate\Database\Connection; 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) protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input)
{ {
if (!is_numeric($input) && $input !== 'me') { $userSlug = $input === 'me' ? user()->slug : trim($input);
return; $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) protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, $input)
{ {
if (!is_numeric($input) && $input !== 'me') { $userSlug = $input === 'me' ? user()->slug : trim($input);
return; $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) protected function filterInName(EloquentBuilder $query, Entity $model, $input)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,7 +30,7 @@ class BookController extends Controller
*/ */
public function index() 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'); $sort = setting()->getForCurrentUser('books_sort', 'name');
$order = setting()->getForCurrentUser('books_sort_order', 'asc'); $order = setting()->getForCurrentUser('books_sort_order', 'asc');

View File

@ -32,7 +32,7 @@ class BookshelfController extends Controller
*/ */
public function index() 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'); $sort = setting()->getForCurrentUser('bookshelves_sort', 'name');
$order = setting()->getForCurrentUser('bookshelves_sort_order', 'asc'); $order = setting()->getForCurrentUser('bookshelves_sort_order', 'asc');
$sortOptions = [ $sortOptions = [
@ -103,7 +103,7 @@ class BookshelfController extends Controller
Views::add($shelf); Views::add($shelf);
$this->entityContextManager->setShelfContext($shelf->id); $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()); $this->setPageTitle($shelf->getShortName());
return view('shelves.show', [ return view('shelves.show', [

View File

@ -159,6 +159,6 @@ abstract class Controller extends BaseController
*/ */
protected function getImageValidationRules(): string 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. // Add required list ordering & sorting for books & shelves views.
if ($homepageOption === 'bookshelves' || $homepageOption === 'books') { if ($homepageOption === 'bookshelves' || $homepageOption === 'books') {
$key = $homepageOption; $key = $homepageOption;
$view = setting()->getForCurrentUser($key . '_view_type', config('app.views.' . $key)); $view = setting()->getForCurrentUser($key . '_view_type');
$sort = setting()->getForCurrentUser($key . '_sort', 'name'); $sort = setting()->getForCurrentUser($key . '_sort', 'name');
$order = setting()->getForCurrentUser($key . '_sort_order', 'asc'); $order = setting()->getForCurrentUser($key . '_sort_order', 'asc');

View File

@ -1,9 +1,6 @@
<?php namespace BookStack\Http\Controllers; <?php namespace BookStack\Http\Controllers;
use BookStack\Actions\ViewService; 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\SearchRunner;
use BookStack\Entities\Tools\ShelfContext; use BookStack\Entities\Tools\ShelfContext;
use BookStack\Entities\Tools\SearchOptions; 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(); $token = ApiToken::query()->where('user_id', '=', $user->id)->where('id', '=', $tokenId)->firstOrFail();
return [$user, $token]; return [$user, $token];
} }
} }

View File

@ -5,10 +5,13 @@ use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\Access\UserInviteService; use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Auth\UserRepo; use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\UserUpdateException; use BookStack\Exceptions\UserUpdateException;
use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class UserController extends Controller class UserController extends Controller
{ {
@ -61,7 +64,7 @@ class UserController extends Controller
/** /**
* Store a newly created user in storage. * Store a newly created user in storage.
* @throws UserUpdateException * @throws UserUpdateException
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request) public function store(Request $request)
{ {
@ -90,6 +93,7 @@ class UserController extends Controller
$user->external_auth_id = $request->get('external_auth_id'); $user->external_auth_id = $request->get('external_auth_id');
} }
$user->refreshSlug();
$user->save(); $user->save();
if ($sendInvite) { if ($sendInvite) {
@ -132,8 +136,8 @@ class UserController extends Controller
/** /**
* Update the specified user in storage. * Update the specified user in storage.
* @throws UserUpdateException * @throws UserUpdateException
* @throws \BookStack\Exceptions\ImageUploadException * @throws ImageUploadException
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function update(Request $request, int $id) public function update(Request $request, int $id)
{ {
@ -157,6 +161,11 @@ class UserController extends Controller
$user->email = $request->get('email'); $user->email = $request->get('email');
} }
// Refresh the slug if the user's name has changed
if ($user->isDirty('name')) {
$user->refreshSlug();
}
// Role updates // Role updates
if (userCan('users-manage') && $request->filled('roles')) { if (userCan('users-manage') && $request->filled('roles')) {
$roles = $request->get('roles'); $roles = $request->get('roles');
@ -216,7 +225,7 @@ class UserController extends Controller
/** /**
* Remove the specified user from storage. * Remove the specified user from storage.
* @throws \Exception * @throws Exception
*/ */
public function destroy(Request $request, int $id) public function destroy(Request $request, int $id)
{ {
@ -243,25 +252,6 @@ class UserController extends Controller
return redirect('/settings/users'); 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. * 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\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class,
\BookStack\Http\Middleware\VerifyCsrfToken::class, \BookStack\Http\Middleware\VerifyCsrfToken::class,
\BookStack\Http\Middleware\RunThemeActions::class,
\BookStack\Http\Middleware\Localization::class, \BookStack\Http\Middleware\Localization::class,
], ],
'api' => [ 'api' => [

View File

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

View File

@ -18,6 +18,8 @@ class Localization
protected $localeMap = [ protected $localeMap = [
'ar' => 'ar', 'ar' => 'ar',
'bg' => 'bg_BG', 'bg' => 'bg_BG',
'bs' => 'bs_BA',
'ca' => 'ca',
'da' => 'da_DK', 'da' => 'da_DK',
'de' => 'de_DE', 'de' => 'de_DE',
'de_informal' => 'de_DE', 'de_informal' => 'de_DE',
@ -26,13 +28,15 @@ class Localization
'es_AR' => 'es_AR', 'es_AR' => 'es_AR',
'fr' => 'fr_FR', 'fr' => 'fr_FR',
'he' => 'he_IL', 'he' => 'he_IL',
'id' => 'id_ID',
'it' => 'it_IT', 'it' => 'it_IT',
'ja' => 'ja', 'ja' => 'ja',
'ko' => 'ko_KR', 'ko' => 'ko_KR',
'lv' => 'lv_LV',
'nl' => 'nl_NL', 'nl' => 'nl_NL',
'nb' => 'nb_NO', 'nb' => 'nb_NO',
'pl' => 'pl_PL', 'pl' => 'pl_PL',
'pt' => 'pl_PT', 'pt' => 'pt_PT',
'pt_BR' => 'pt_BR', 'pt_BR' => 'pt_BR',
'ru' => 'ru', 'ru' => 'ru',
'sk' => 'sk_SK', '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'); return (int) config('api.requests_per_minute');
} }
}
}

View File

@ -8,4 +8,4 @@ interface Loggable
* Get the string descriptor for this item. * Get the string descriptor for this item.
*/ */
public function logDescriptor(): string; 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; <?php namespace BookStack\Providers;
use Blade; use Blade;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\BreadcrumbsViewComposer; use BookStack\Entities\BreadcrumbsViewComposer;
@ -12,6 +13,7 @@ use Illuminate\Contracts\Cache\Repository;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
use Schema; use Schema;
use URL; use URL;
@ -62,5 +64,9 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(SettingService::class, function ($app) { $this->app->singleton(SettingService::class, function ($app) {
return new SettingService($app->make(Setting::class), $app->make(Repository::class)); 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\ActivityService;
use BookStack\Actions\ViewService; use BookStack\Actions\ViewService;
use BookStack\Auth\Permissions\PermissionService; use BookStack\Auth\Permissions\PermissionService;
use BookStack\Settings\SettingService; use BookStack\Theming\ThemeService;
use BookStack\Uploads\ImageService; use BookStack\Uploads\ImageService;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -36,10 +36,6 @@ class CustomFacadeProvider extends ServiceProvider
return $this->app->make(ViewService::class); return $this->app->make(ViewService::class);
}); });
$this->app->singleton('setting', function () {
return $this->app->make(SettingService::class);
});
$this->app->singleton('images', function () { $this->app->singleton('images', function () {
return $this->app->make(ImageService::class); return $this->app->make(ImageService::class);
}); });
@ -47,5 +43,9 @@ class CustomFacadeProvider extends ServiceProvider
$this->app->singleton('permissions', function () { $this->app->singleton('permissions', function () {
return $this->app->make(PermissionService::class); 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); 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) { Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) {
$cleanLinkName = strtolower(trim($value)); $cleanLinkName = strtolower(trim($value));
$isJs = strpos($cleanLinkName, 'javascript:') === 0; $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']); return new FileLoader($app['files'], $app['path.lang']);
}); });
} }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -79,9 +79,9 @@ function userCanOnAny(string $permission, string $entityClass = null): bool
/** /**
* Helper to access system settings. * 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); $settingService = resolve(SettingService::class);

View File

@ -5,25 +5,25 @@
"license": "MIT", "license": "MIT",
"type": "project", "type": "project",
"require": { "require": {
"php": "^7.2.5", "php": "^7.3|^8.0",
"ext-curl": "*", "ext-curl": "*",
"ext-dom": "*", "ext-dom": "*",
"ext-gd": "*", "ext-gd": "*",
"ext-json": "*", "ext-json": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-xml": "*", "ext-xml": "*",
"barryvdh/laravel-dompdf": "^0.8.7", "barryvdh/laravel-dompdf": "^0.9.0",
"barryvdh/laravel-snappy": "^0.4.8", "barryvdh/laravel-snappy": "^0.4.8",
"doctrine/dbal": "^2.9", "doctrine/dbal": "^2.12.1",
"facade/ignition": "^1.16.4", "facade/ignition": "^1.16.4",
"fideloper/proxy": "^4.4.1", "fideloper/proxy": "^4.4.1",
"intervention/image": "^2.5.1", "intervention/image": "^2.5.1",
"laravel/framework": "^6.20.12", "laravel/framework": "^6.20.16",
"laravel/socialite": "^5.1", "laravel/socialite": "^5.1",
"league/commonmark": "^1.5", "league/commonmark": "^1.5",
"league/flysystem-aws-s3-v3": "^1.0.29", "league/flysystem-aws-s3-v3": "^1.0.29",
"nunomaduro/collision": "^3.1", "nunomaduro/collision": "^3.1",
"onelogin/php-saml": "^3.3", "onelogin/php-saml": "^4.0",
"predis/predis": "^1.1.6", "predis/predis": "^1.1.6",
"socialiteproviders/discord": "^4.1", "socialiteproviders/discord": "^4.1",
"socialiteproviders/gitlab": "^4.1", "socialiteproviders/gitlab": "^4.1",
@ -36,10 +36,10 @@
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "^3.5.1", "barryvdh/laravel-debugbar": "^3.5.1",
"barryvdh/laravel-ide-helper": "^2.8.2", "barryvdh/laravel-ide-helper": "^2.8.2",
"fakerphp/faker": "^1.9.1", "fakerphp/faker": "^1.13.0",
"laravel/browser-kit-testing": "^5.2", "laravel/browser-kit-testing": "^5.2",
"mockery/mockery": "^1.3.3", "mockery/mockery": "^1.3.3",
"phpunit/phpunit": "^8.0", "phpunit/phpunit": "^9.5.3",
"squizlabs/php_codesniffer": "^3.5.8" "squizlabs/php_codesniffer": "^3.5.8"
}, },
"autoload": { "autoload": {
@ -87,7 +87,7 @@
"preferred-install": "dist", "preferred-install": "dist",
"sort-packages": true, "sort-packages": true,
"platform": { "platform": {
"php": "7.2.5" "php": "7.3.0"
} }
}, },
"extra": { "extra": {

1583
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,9 +12,11 @@
*/ */
$factory->define(\BookStack\Auth\User::class, function ($faker) { $factory->define(\BookStack\Auth\User::class, function ($faker) {
$name = $faker->name;
return [ return [
'name' => $faker->name, 'name' => $name,
'email' => $faker->email, 'email' => $faker->email,
'slug' => \Illuminate\Support\Str::slug($name . '-' . \Illuminate\Support\Str::random(5)),
'password' => Str::random(10), 'password' => Str::random(10),
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),
'email_confirmed' => 1 'email_confirmed' => 1

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class AddUserSlug extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('slug', 250);
});
$slugMap = [];
DB::table('users')->cursor()->each(function ($user) use (&$slugMap) {
$userSlug = Str::slug($user->name);
while (isset($slugMap[$userSlug])) {
$userSlug = Str::slug($user->name . Str::random(4));
}
$slugMap[$userSlug] = true;
DB::table('users')
->where('id', $user->id)
->update(['slug' => $userSlug]);
});
Schema::table('users', function (Blueprint $table) {
$table->unique('slug');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('slug');
});
}
}

View File

@ -1,12 +1,12 @@
FROM php:7.3-apache FROM php:7.4-apache
ENV APACHE_DOCUMENT_ROOT /app/public ENV APACHE_DOCUMENT_ROOT /app/public
WORKDIR /app WORKDIR /app
RUN apt-get update -y \ RUN apt-get update -y \
&& apt-get install -y git zip unzip libtidy-dev libpng-dev libldap2-dev libxml++2.6-dev wait-for-it \ && apt-get install -y git zip unzip libpng-dev libldap2-dev wait-for-it \
&& docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu \ && docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu \
&& docker-php-ext-install pdo pdo_mysql tidy dom xml mbstring gd ldap \ && docker-php-ext-install pdo_mysql gd ldap \
&& a2enmod rewrite \ && a2enmod rewrite \
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \ && sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
&& sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf \ && sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf \

View File

@ -0,0 +1,98 @@
# Logical Theme System
BookStack allows logical customization via the theme system which enables you to add, or extend, functionality within the PHP side of the system without needing to alter the core application files.
WARNING: This system is currently in alpha so may incur changes. Once we've gathered some feedback on usage we'll look to removing this warning. This system will be considered semi-stable in the future. The `Theme::` system will be kept maintained but specific customizations or deeper app/framework usage using this system will not be supported nor considered in any way stable. Customizations using this system should be checked after updates.
## Getting Started
This makes use of the theme system. Create a folder for your theme within your BookStack `themes` directory. As an example we'll use `my_theme`, so we'd create a `themes/my_theme` folder.
You'll need to tell BookStack to use your theme via the `APP_THEME` option in your `.env` file. For example: `APP_THEME=my_theme`.
Within your theme folder create a `functions.php` file. BookStack will look for this and run it during app boot-up. Within this file you can use the `Theme` facade API, described below, to hook into certain app events.
## `Theme` Facade API
Below details the public methods of the `Theme` facade that are considered stable:
### `Theme::listen`
This method listens to a system event and runs the given action when that event occurs. The arguments passed to the action depend on the event. Event names are exposed as static properties on the `\BookStack\Theming\ThemeEvents` class.
It is possible to listen to a single event using multiple actions. When dispatched, BookStack will loop over and run each action for that event.
If an action returns a non-null value then BookStack will stop cycling through actions at that point and make use of the non-null return value if possible (Depending on the event).
**Arguments**
- string $event
- callable $action
**Example**
```php
Theme::listen(
\BookStack\Theming\ThemeEvents::AUTH_LOGIN,
function($service, $user) {
\Log::info("Login by {$user->name} via {$service}");
}
);
```
### `Theme::addSocialDriver`
This method allows you to register a custom social authentication driver within the system. This is primarily intended to use with [Socialite Providers](https://socialiteproviders.com/).
**Arguments**
- string $driverName
- array $config
- string $socialiteHandler
**Example**
*See "Custom Socialite Service Example" below.*
## Available Events
All available events dispatched by BookStack are exposed as static properties on the `\BookStack\Theming\ThemeEvents` class, which can be found within the file `app/Theming/ThemeEvents.php` relative to your root BookStack folder. Alternatively, the events for the latest release can be [seen on GitHub here](https://github.com/BookStackApp/BookStack/blob/release/app/Theming/ThemeEvents.php).
The comments above each constant with the `ThemeEvents.php` file describe the dispatch conditions of the event, in addition to the arguments the action will receive. The comments may also describe any ways the return value of the action may be used.
## Example `functions.php` file
```php
<?php
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
// Logs custom message on user login
Theme::listen(ThemeEvents::AUTH_LOGIN, function($method, $user) {
Log::info("Login via {$method} for {$user->name}");
});
// Adds a `/info` public URL endpoint that emits php debug details
Theme::listen(ThemeEvents::APP_BOOT, function($app) {
\Route::get('info', function() {
phpinfo(); // Don't do this on a production instance!
});
});
```
## Custom Socialite Service Example
The below shows an example of adding a custom reddit socialite service to BookStack.
BookStack exposes a helper function for this via `Theme::addSocialDriver` which sets the required config and event listeners in the platform.
The require statements reference composer installed dependencies within the theme folder. They are required manually since they are not auto-loaded like other app files due to being outside the main BookStack dependency list.
```php
require "vendor/socialiteproviders/reddit/Provider.php";
require "vendor/socialiteproviders/reddit/RedditExtendSocialite.php";
Theme::listen(ThemeEvents::APP_BOOT, function($app) {
Theme::addSocialDriver('reddit', [
'client_id' => 'abc123',
'client_secret' => 'def456789',
'name' => 'Reddit',
], '\SocialiteProviders\Reddit\RedditExtendSocialite@handle');
});
```

View File

@ -0,0 +1,31 @@
# Visual Theme System
BookStack allows visual customization via the theme system which enables you to extensively customize views, translation text & icons.
This theme system itself is maintained and supported but usages of this system, including the files you are able to override, are not considered stable and may change upon any update. You should test any customizations made after updates.
## Getting Started
This makes use of the theme system. Create a folder for your theme within your BookStack `themes` directory. As an example we'll use `my_theme`, so we'd create a `themes/my_theme` folder.
You'll need to tell BookStack to use your theme via the `APP_THEME` option in your `.env` file. For example: `APP_THEME=my_theme`.
## Customizing View Files
Content placed in your `themes/<theme_name>/` folder will override the original view files found in the `resources/views` folder. These files are typically [Laravel Blade](https://laravel.com/docs/6.x/blade) files.
## Customizing Icons
SVG files placed in a `themes/<theme_name>/icons` folder will override any icons of the same name within `resources/icons`. You'd typically want to follow the format convention of the existing icons, where no XML deceleration is included and no width & height attributes are set, to ensure optimal compatibility.
## Customizing Text Content
Folders with PHP translation files placed in a `themes/<theme_name>/lang` folder will override translations defined within the `resources/lang` folder. Custom translations are merged with the original files so you only need to override the select translations you want to override, you don't need to copy the whole original file. Note that you'll need to include the language folder.
As an example, Say I wanted to change 'Search' to 'Find'; Within a `themes/<theme_name>/lang/en/common.php` file I'd set the following:
```php
<?php
return [
'search' => 'find',
];
```

View File

@ -1,9 +1,12 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<ruleset name="PHP_CodeSniffer"> <ruleset name="BookStack Standard">
<!-- Format described at: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Annotated-Ruleset -->
<description>The coding standard for BookStack.</description> <description>The coding standard for BookStack.</description>
<file>app</file> <config name="php_version" value="70205"/>
<file>./app</file>
<exclude-pattern>*/migrations/*</exclude-pattern> <exclude-pattern>*/migrations/*</exclude-pattern>
<exclude-pattern>*/tests/*</exclude-pattern> <exclude-pattern>*/tests/*</exclude-pattern>
<arg value="np"/> <arg value="np"/>
<arg name="colors"/>
<rule ref="PSR2"/> <rule ref="PSR2"/>
</ruleset> </ruleset>

View File

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
backupGlobals="false"
backupStaticAttributes="false" backupStaticAttributes="false"
bootstrap="vendor/autoload.php" bootstrap="vendor/autoload.php"
colors="true" colors="true"
@ -8,54 +10,55 @@
convertWarningsToExceptions="true" convertWarningsToExceptions="true"
processIsolation="false" processIsolation="false"
stopOnFailure="false"> stopOnFailure="false">
<testsuites> <coverage>
<testsuite name="Application Test Suite"> <include>
<directory>./tests/</directory> <directory suffix=".php">app/</directory>
</testsuite> </include>
</testsuites> </coverage>
<filter> <testsuites>
<whitelist> <testsuite name="Application Test Suite">
<directory suffix=".php">app/</directory> <directory>./tests/</directory>
</whitelist> </testsuite>
</filter> </testsuites>
<php> <php>
<env name="APP_ENV" value="testing" force="true"/> <server name="APP_ENV" value="testing"/>
<server name="APP_DEBUG" value="false"/> <server name="APP_DEBUG" value="false"/>
<server name="APP_LANG" value="en"/> <server name="APP_LANG" value="en"/>
<server name="APP_THEME" value="none"/> <server name="APP_THEME" value="none"/>
<server name="APP_AUTO_LANG_PUBLIC" value="true"/> <server name="APP_AUTO_LANG_PUBLIC" value="true"/>
<server name="APP_URL" value="http://bookstack.dev"/> <server name="APP_URL" value="http://bookstack.dev"/>
<server name="ALLOWED_IFRAME_HOSTS" value=""/> <server name="ALLOWED_IFRAME_HOSTS" value=""/>
<server name="CACHE_DRIVER" value="array"/> <server name="CACHE_DRIVER" value="array"/>
<server name="SESSION_DRIVER" value="array"/> <server name="SESSION_DRIVER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/> <server name="QUEUE_CONNECTION" value="sync"/>
<env name="DB_CONNECTION" value="mysql_testing" force="true"/> <server name="DB_CONNECTION" value="mysql_testing"/>
<server name="BCRYPT_ROUNDS" value="4"/> <server name="BCRYPT_ROUNDS" value="4"/>
<server name="MAIL_DRIVER" value="array"/> <server name="MAIL_DRIVER" value="array"/>
<server name="LOG_CHANNEL" value="single"/> <server name="LOG_CHANNEL" value="single"/>
<server name="AUTH_METHOD" value="standard"/> <server name="AUTH_METHOD" value="standard"/>
<server name="DISABLE_EXTERNAL_SERVICES" value="true"/> <server name="DISABLE_EXTERNAL_SERVICES" value="true"/>
<server name="AVATAR_URL" value=""/> <server name="AVATAR_URL" value=""/>
<server name="LDAP_VERSION" value="3"/> <server name="LDAP_START_TLS" value="false"/>
<server name="SESSION_SECURE_COOKIE" value="null"/> <server name="LDAP_VERSION" value="3"/>
<server name="STORAGE_TYPE" value="local"/> <server name="SESSION_SECURE_COOKIE" value="null"/>
<server name="STORAGE_ATTACHMENT_TYPE" value="local"/> <server name="STORAGE_TYPE" value="local"/>
<server name="STORAGE_IMAGE_TYPE" value="local"/> <server name="STORAGE_ATTACHMENT_TYPE" value="local"/>
<server name="GITHUB_APP_ID" value="aaaaaaaaaaaaaa"/> <server name="STORAGE_IMAGE_TYPE" value="local"/>
<server name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/> <server name="GITHUB_APP_ID" value="aaaaaaaaaaaaaa"/>
<server name="GITHUB_AUTO_REGISTER" value=""/> <server name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/>
<server name="GITHUB_AUTO_CONFIRM_EMAIL" value=""/> <server name="GITHUB_AUTO_REGISTER" value=""/>
<server name="GOOGLE_APP_ID" value="aaaaaaaaaaaaaa"/> <server name="GITHUB_AUTO_CONFIRM_EMAIL" value=""/>
<server name="GOOGLE_APP_SECRET" value="aaaaaaaaaaaaaa"/> <server name="GOOGLE_APP_ID" value="aaaaaaaaaaaaaa"/>
<server name="GOOGLE_AUTO_REGISTER" value=""/> <server name="GOOGLE_APP_SECRET" value="aaaaaaaaaaaaaa"/>
<server name="GOOGLE_AUTO_CONFIRM_EMAIL" value=""/> <server name="GOOGLE_AUTO_REGISTER" value=""/>
<server name="GOOGLE_SELECT_ACCOUNT" value=""/> <server name="GOOGLE_AUTO_CONFIRM_EMAIL" value=""/>
<server name="DEBUGBAR_ENABLED" value="false"/> <server name="GOOGLE_SELECT_ACCOUNT" value=""/>
<server name="SAML2_ENABLED" value="false"/> <server name="DEBUGBAR_ENABLED" value="false"/>
<server name="API_REQUESTS_PER_MIN" value="180"/> <server name="SAML2_ENABLED" value="false"/>
<server name="LOG_FAILED_LOGIN_MESSAGE" value=""/> <server name="API_REQUESTS_PER_MIN" value="180"/>
<server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/> <server name="LOG_FAILED_LOGIN_MESSAGE" value=""/>
<server name="WKHTMLTOPDF" value="false"/> <server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/>
<ini name="memory_limit" value="1024M"/> <server name="WKHTMLTOPDF" value="false"/>
</php> <server name="APP_DEFAULT_DARK_MODE" value="false"/>
</php>
</phpunit> </phpunit>

View File

@ -4,7 +4,8 @@
[![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/master/LICENSE) [![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/master/LICENSE)
[![Crowdin](https://badges.crowdin.net/bookstack/localized.svg)](https://crowdin.com/project/bookstack) [![Crowdin](https://badges.crowdin.net/bookstack/localized.svg)](https://crowdin.com/project/bookstack)
[![Build Status](https://github.com/BookStackApp/BookStack/workflows/phpunit/badge.svg)](https://github.com/BookStackApp/BookStack/actions) [![Build Status](https://github.com/BookStackApp/BookStack/workflows/phpunit/badge.svg)](https://github.com/BookStackApp/BookStack/actions)
[![Discord](https://img.shields.io/static/v1?label=Chat&message=Discord&color=738adb&logo=discord)](https://discord.gg/ztkBqR2) [![Discord](https://img.shields.io/static/v1?label=chat&message=discord&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
[![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/)
A platform for storing and organising information and documentation. Details for BookStack can be found on the official website at https://www.bookstackapp.com/. A platform for storing and organising information and documentation. Details for BookStack can be found on the official website at https://www.bookstackapp.com/.
@ -133,7 +134,7 @@ Feel free to create issues to request new features or to report bugs & problems.
Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit in to the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project. Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit in to the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project.
Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/assets`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly. Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/CODE_OF_CONDUCT.md). The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/CODE_OF_CONDUCT.md).
@ -181,3 +182,5 @@ These are the great open-source projects used to help build BookStack:
* [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html) * [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html)
* [diagrams.net](https://github.com/jgraph/drawio) * [diagrams.net](https://github.com/jgraph/drawio)
* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml) * [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
* [League/CommonMark](https://commonmark.thephpleague.com/)
* [League/Flysystem](https://flysystem.thephpleague.com)

View File

@ -1,22 +1,32 @@
import {onChildEvent} from "../services/dom";
/**
* Entity Selector
* @extends {Component}
*/
class EntitySelector { class EntitySelector {
constructor(elem) { setup() {
this.elem = elem; this.elem = this.$el;
this.entityTypes = this.$opts.entityTypes || 'page,book,chapter';
this.entityPermission = this.$opts.entityPermission || 'view';
this.input = this.$refs.input;
this.searchInput = this.$refs.search;
this.loading = this.$refs.loading;
this.resultsContainer = this.$refs.results;
this.addButton = this.$refs.add;
this.search = ''; this.search = '';
this.lastClick = 0; this.lastClick = 0;
this.selectedItemData = null; this.selectedItemData = null;
const entityTypes = elem.hasAttribute('entity-types') ? elem.getAttribute('entity-types') : 'page,book,chapter'; this.setupListeners();
const entityPermission = elem.hasAttribute('entity-permission') ? elem.getAttribute('entity-permission') : 'view'; this.showLoading();
this.searchUrl = window.baseUrl(`/ajax/search/entities?types=${encodeURIComponent(entityTypes)}&permission=${encodeURIComponent(entityPermission)}`); this.initialLoad();
}
this.input = elem.querySelector('[entity-selector-input]');
this.searchInput = elem.querySelector('[entity-selector-search]');
this.loading = elem.querySelector('[entity-selector-loading]');
this.resultsContainer = elem.querySelector('[entity-selector-results]');
this.addButton = elem.querySelector('[entity-selector-add-button]');
setupListeners() {
this.elem.addEventListener('click', this.onClick.bind(this)); this.elem.addEventListener('click', this.onClick.bind(this));
let lastSearch = 0; let lastSearch = 0;
@ -42,8 +52,39 @@ class EntitySelector {
}); });
} }
this.showLoading(); // Keyboard navigation
this.initialLoad(); onChildEvent(this.$el, '[data-entity-type]', 'keydown', (e, el) => {
if (e.ctrlKey && e.code === 'Enter') {
const form = this.$el.closest('form');
if (form) {
form.submit();
e.preventDefault();
return;
}
}
if (e.code === 'ArrowDown') {
this.focusAdjacent(true);
}
if (e.code === 'ArrowUp') {
this.focusAdjacent(false);
}
});
this.searchInput.addEventListener('keydown', e => {
if (e.code === 'ArrowDown') {
this.focusAdjacent(true);
}
})
}
focusAdjacent(forward = true) {
const items = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]'));
const selectedIndex = items.indexOf(document.activeElement);
const newItem = items[selectedIndex+ (forward ? 1 : -1)] || items[0];
if (newItem) {
newItem.focus();
}
} }
showLoading() { showLoading() {
@ -57,15 +98,19 @@ class EntitySelector {
} }
initialLoad() { initialLoad() {
window.$http.get(this.searchUrl).then(resp => { window.$http.get(this.searchUrl()).then(resp => {
this.resultsContainer.innerHTML = resp.data; this.resultsContainer.innerHTML = resp.data;
this.hideLoading(); this.hideLoading();
}) })
} }
searchUrl() {
return `/ajax/search/entities?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
}
searchEntities(searchTerm) { searchEntities(searchTerm) {
this.input.value = ''; this.input.value = '';
let url = `${this.searchUrl}&term=${encodeURIComponent(searchTerm)}`; const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`;
window.$http.get(url).then(resp => { window.$http.get(url).then(resp => {
this.resultsContainer.innerHTML = resp.data; this.resultsContainer.innerHTML = resp.data;
this.hideLoading(); this.hideLoading();
@ -73,8 +118,8 @@ class EntitySelector {
} }
isDoubleClick() { isDoubleClick() {
let now = Date.now(); const now = Date.now();
let answer = now - this.lastClick < 300; const answer = now - this.lastClick < 300;
this.lastClick = now; this.lastClick = now;
return answer; return answer;
} }
@ -123,8 +168,8 @@ class EntitySelector {
} }
unselectAll() { unselectAll() {
let selected = this.elem.querySelectorAll('.selected'); const selected = this.elem.querySelectorAll('.selected');
for (let selectedElem of selected) { for (const selectedElem of selected) {
selectedElem.classList.remove('selected', 'primary-background'); selectedElem.classList.remove('selected', 'primary-background');
} }
this.selectedItemData = null; this.selectedItemData = null;

View File

@ -22,7 +22,6 @@ class MarkdownEditor {
this.displayStylesLoaded = false; this.displayStylesLoaded = false;
this.input = this.elem.querySelector('textarea'); this.input = this.elem.querySelector('textarea');
this.htmlInput = this.elem.querySelector('input[name=html]');
this.cm = code.markdownEditor(this.input); this.cm = code.markdownEditor(this.input);
this.onMarkdownScroll = this.onMarkdownScroll.bind(this); this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
@ -125,7 +124,6 @@ class MarkdownEditor {
// Set body content // Set body content
this.displayDoc.body.className = 'page-content'; this.displayDoc.body.className = 'page-content';
this.displayDoc.body.innerHTML = html; this.displayDoc.body.innerHTML = html;
this.htmlInput.value = html;
// Copy styles from page head and set custom styles for editor // Copy styles from page head and set custom styles for editor
this.loadStylesIntoDisplay(); this.loadStylesIntoDisplay();

View File

@ -13,9 +13,11 @@ class UserSelect {
} }
selectUser(event, userEl) { selectUser(event, userEl) {
event.preventDefault();
const id = userEl.getAttribute('data-id'); const id = userEl.getAttribute('data-id');
this.input.value = id; this.input.value = id;
this.userInfoContainer.innerHTML = userEl.innerHTML; this.userInfoContainer.innerHTML = userEl.innerHTML;
this.input.dispatchEvent(new Event('change', {bubbles: true}));
this.hide(); this.hide();
} }

View File

@ -212,7 +212,7 @@ function codePlugin() {
showPopup(editor); showPopup(editor);
}); });
editor.on('SetContent', function () { function parseCodeMirrorInstances() {
// Recover broken codemirror instances // Recover broken codemirror instances
$('.CodeMirrorContainer').filter((index ,elem) => { $('.CodeMirrorContainer').filter((index ,elem) => {
@ -231,6 +231,17 @@ function codePlugin() {
Code.wysiwygView(elem); Code.wysiwygView(elem);
}); });
}); });
}
editor.on('init', function() {
// Parse code mirror instances on init, but delay a little so this runs after
// initial styles are fetched into the editor.
parseCodeMirrorInstances();
// Parsed code mirror blocks when content is set but wait before setting this handler
// to avoid any init 'SetContent' events.
setTimeout(() => {
editor.on('SetContent', parseCodeMirrorInstances);
}, 200);
}); });
}); });

View File

@ -238,9 +238,7 @@ function wysiwygView(elem) {
theme: getTheme(), theme: getTheme(),
readOnly: true readOnly: true
}); });
setTimeout(() => {
cm.refresh();
}, 300);
return {wrap: newWrap, editor: cm}; return {wrap: newWrap, editor: cm};
} }

View File

@ -77,4 +77,9 @@ return [
// Email Content // Email Content
'email_action_help' => 'إذا واجهتكم مشكلة بضغط زر ":actionText" فبإمكانكم نسخ الرابط أدناه ولصقه بالمتصفح:', 'email_action_help' => 'إذا واجهتكم مشكلة بضغط زر ":actionText" فبإمكانكم نسخ الرابط أدناه ولصقه بالمتصفح:',
'email_rights' => 'جميع الحقوق محفوظة', 'email_rights' => 'جميع الحقوق محفوظة',
// Footer Link Options
// Not directly used but available for convenience to users.
'privacy_policy' => 'Privacy Policy',
'terms_of_service' => 'Terms of Service',
]; ];

Some files were not shown because too many files have changed in this diff Show More