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

@ -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

@ -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

@ -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());
} }
@ -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([
'chapters' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']); $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
}, 'pages' => function ($query) { },
'pages' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']); $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,29 +429,18 @@ 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'),
@ -510,7 +449,7 @@ class PermissionService
'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);
if (!empty($revision->markdown)) {
$content->setNewMarkdown($revision->markdown);
} else {
$content->setNewHTML($revision->html); $content->setNewHTML($revision->html);
}
$page->updated_by = user()->id; $page->updated_by = user()->id;
$page->refreshSlug(); $page->refreshSlug();
$page->save(); $page->save();

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;
} }
$query->where('updated_by', '=', $input);
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);
}
} }
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

@ -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

@ -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

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

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

@ -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

@ -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);
} }

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

@ -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,18 +10,18 @@
convertWarningsToExceptions="true" convertWarningsToExceptions="true"
processIsolation="false" processIsolation="false"
stopOnFailure="false"> stopOnFailure="false">
<coverage>
<include>
<directory suffix=".php">app/</directory>
</include>
</coverage>
<testsuites> <testsuites>
<testsuite name="Application Test Suite"> <testsuite name="Application Test Suite">
<directory>./tests/</directory> <directory>./tests/</directory>
</testsuite> </testsuite>
</testsuites> </testsuites>
<filter>
<whitelist>
<directory suffix=".php">app/</directory>
</whitelist>
</filter>
<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"/>
@ -29,13 +31,14 @@
<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_START_TLS" value="false"/>
<server name="LDAP_VERSION" value="3"/> <server name="LDAP_VERSION" value="3"/>
<server name="SESSION_SECURE_COOKIE" value="null"/> <server name="SESSION_SECURE_COOKIE" value="null"/>
<server name="STORAGE_TYPE" value="local"/> <server name="STORAGE_TYPE" value="local"/>
@ -56,6 +59,6 @@
<server name="LOG_FAILED_LOGIN_MESSAGE" value=""/> <server name="LOG_FAILED_LOGIN_MESSAGE" value=""/>
<server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/> <server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/>
<server name="WKHTMLTOPDF" value="false"/> <server name="WKHTMLTOPDF" value="false"/>
<ini name="memory_limit" value="1024M"/> <server name="APP_DEFAULT_DARK_MODE" value="false"/>
</php> </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',
]; ];

View File

@ -37,6 +37,11 @@ return [
'app_homepage' => 'الصفحة الرئيسية للتطبيق', 'app_homepage' => 'الصفحة الرئيسية للتطبيق',
'app_homepage_desc' => 'الرجاء اختيار صفحة لتصبح الصفحة الرئيسية بدل من الافتراضية. سيتم تجاهل جميع الأذونات الخاصة بالصفحة المختارة.', 'app_homepage_desc' => 'الرجاء اختيار صفحة لتصبح الصفحة الرئيسية بدل من الافتراضية. سيتم تجاهل جميع الأذونات الخاصة بالصفحة المختارة.',
'app_homepage_select' => 'اختر صفحة', 'app_homepage_select' => 'اختر صفحة',
'app_footer_links' => 'Footer Links',
'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
'app_footer_links_label' => 'Link Label',
'app_footer_links_url' => 'Link URL',
'app_footer_links_add' => 'Add Footer Link',
'app_disable_comments' => 'تعطيل التعليقات', 'app_disable_comments' => 'تعطيل التعليقات',
'app_disable_comments_toggle' => 'تعطيل التعليقات', 'app_disable_comments_toggle' => 'تعطيل التعليقات',
'app_disable_comments_desc' => 'تعطيل التعليقات على جميع الصفحات داخل التطبيق. التعليقات الموجودة من الأصل لن تكون ظاهرة.', 'app_disable_comments_desc' => 'تعطيل التعليقات على جميع الصفحات داخل التطبيق. التعليقات الموجودة من الأصل لن تكون ظاهرة.',
@ -226,6 +231,8 @@ return [
'en' => 'English', 'en' => 'English',
'ar' => 'العربية', 'ar' => 'العربية',
'bg' => 'Bǎlgarski', 'bg' => 'Bǎlgarski',
'bs' => 'Bosanski',
'ca' => 'Català',
'cs' => 'Česky', 'cs' => 'Česky',
'da' => 'Dansk', 'da' => 'Dansk',
'de' => 'Deutsch (Sie)', 'de' => 'Deutsch (Sie)',
@ -235,12 +242,15 @@ return [
'fr' => 'Français', 'fr' => 'Français',
'he' => 'עברית', 'he' => 'עברית',
'hu' => 'Magyar', 'hu' => 'Magyar',
'id' => 'Bahasa Indonesia',
'it' => 'Italian', 'it' => 'Italian',
'ja' => '日本語', 'ja' => '日本語',
'ko' => '한국어', 'ko' => '한국어',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands', 'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)', 'nb' => 'Norsk (Bokmål)',
'pl' => 'Polski', 'pl' => 'Polski',
'pt' => 'Português',
'pt_BR' => 'Português do Brasil', 'pt_BR' => 'Português do Brasil',
'ru' => 'Русский', 'ru' => 'Русский',
'sk' => 'Slovensky', 'sk' => 'Slovensky',

View File

@ -78,7 +78,6 @@ return [
'string' => 'يجب أن يكون :attribute على الأقل :min حرف / حروف.', 'string' => 'يجب أن يكون :attribute على الأقل :min حرف / حروف.',
'array' => 'يجب أن يحتوي :attribute على :min عنصر / عناصر كحد أدنى.', 'array' => 'يجب أن يحتوي :attribute على :min عنصر / عناصر كحد أدنى.',
], ],
'no_double_extension' => 'يجب أن يكون للسمة: امتداد ملف واحد فقط.',
'not_in' => ':attribute المحدد غير صالح.', 'not_in' => ':attribute المحدد غير صالح.',
'not_regex' => 'صيغة السمة: غير صالحة.', 'not_regex' => 'صيغة السمة: غير صالحة.',
'numeric' => 'يجب أن يكون :attribute رقم.', 'numeric' => 'يجب أن يكون :attribute رقم.',

View File

@ -77,4 +77,9 @@ return [
// Email Content // Email Content
'email_action_help' => 'Ако имате проблеми с бутона ":actionText" по-горе, копирайте и поставете URL адреса по-долу в уеб браузъра си:', 'email_action_help' => 'Ако имате проблеми с бутона ":actionText" по-горе, копирайте и поставете URL адреса по-долу в уеб браузъра си:',
'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',
]; ];

View File

@ -37,6 +37,11 @@ return [
'app_homepage' => 'Application Homepage', 'app_homepage' => 'Application Homepage',
'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', 'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
'app_homepage_select' => 'Select a page', 'app_homepage_select' => 'Select a page',
'app_footer_links' => 'Footer Links',
'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
'app_footer_links_label' => 'Link Label',
'app_footer_links_url' => 'Link URL',
'app_footer_links_add' => 'Add Footer Link',
'app_disable_comments' => 'Disable Comments', 'app_disable_comments' => 'Disable Comments',
'app_disable_comments_toggle' => 'Disable comments', 'app_disable_comments_toggle' => 'Disable comments',
'app_disable_comments_desc' => 'Disables comments across all pages in the application. <br> Existing comments are not shown.', 'app_disable_comments_desc' => 'Disables comments across all pages in the application. <br> Existing comments are not shown.',
@ -226,6 +231,8 @@ return [
'en' => 'English', 'en' => 'English',
'ar' => 'العربية', 'ar' => 'العربية',
'bg' => 'Bǎlgarski', 'bg' => 'Bǎlgarski',
'bs' => 'Bosanski',
'ca' => 'Català',
'cs' => 'Česky', 'cs' => 'Česky',
'da' => 'Dansk', 'da' => 'Dansk',
'de' => 'Deutsch (Sie)', 'de' => 'Deutsch (Sie)',
@ -235,12 +242,15 @@ return [
'fr' => 'Français', 'fr' => 'Français',
'he' => 'עברית', 'he' => 'עברית',
'hu' => 'Magyar', 'hu' => 'Magyar',
'id' => 'Bahasa Indonesia',
'it' => 'Italian', 'it' => 'Italian',
'ja' => '日本語', 'ja' => '日本語',
'ko' => '한국어', 'ko' => '한국어',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands', 'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)', 'nb' => 'Norsk (Bokmål)',
'pl' => 'Polski', 'pl' => 'Polski',
'pt' => 'Português',
'pt_BR' => 'Português do Brasil', 'pt_BR' => 'Português do Brasil',
'ru' => 'Русский', 'ru' => 'Русский',
'sk' => 'Slovensky', 'sk' => 'Slovensky',

View File

@ -78,7 +78,6 @@ return [
'string' => 'The :attribute must be at least :min characters.', 'string' => 'The :attribute must be at least :min characters.',
'array' => 'The :attribute must have at least :min items.', 'array' => 'The :attribute must have at least :min items.',
], ],
'no_double_extension' => 'The :attribute must only have a single file extension.',
'not_in' => 'The selected :attribute is invalid.', 'not_in' => 'The selected :attribute is invalid.',
'not_regex' => 'The :attribute format is invalid.', 'not_regex' => 'The :attribute format is invalid.',
'numeric' => 'The :attribute must be a number.', 'numeric' => 'The :attribute must be a number.',

View File

@ -0,0 +1,49 @@
<?php
/**
* Activity text strings.
* Is used for all the text within activity logs & notifications.
*/
return [
// Pages
'page_create' => 'je kreirao/la stranicu',
'page_create_notification' => 'Stranica Uspješno Kreirana',
'page_update' => 'je ažurirao/la stranicu',
'page_update_notification' => 'Stranica Uspješno Ažurirana',
'page_delete' => 'je izbrisao/la stranicu',
'page_delete_notification' => 'Stranica Uspješno Izbrisana',
'page_restore' => 'je vratio/la stranicu',
'page_restore_notification' => 'Stranica Uspješno Vraćena',
'page_move' => 'je premjestio/la stranicu',
// Chapters
'chapter_create' => 'je kreirao/la poglavlje',
'chapter_create_notification' => 'Poglavlje Uspješno Kreirano',
'chapter_update' => 'je ažurirao/la poglavlje',
'chapter_update_notification' => 'Poglavlje Uspješno Ažurirano',
'chapter_delete' => 'je izbrisao/la poglavlje',
'chapter_delete_notification' => 'Poglavlje Uspješno Izbrisano',
'chapter_move' => 'je premjestio/la poglavlje',
// Books
'book_create' => 'je kreirao/la knjigu',
'book_create_notification' => 'Knjiga Uspješno Kreirana',
'book_update' => 'je ažurirao/la knjigu',
'book_update_notification' => 'Knjiga Uspješno Ažurirana',
'book_delete' => 'je izbrisao/la knjigu',
'book_delete_notification' => 'Knjiga Uspješno Izbrisana',
'book_sort' => 'je sortirao/la knjigu',
'book_sort_notification' => 'Knjiga Uspješno Ponovno Sortirana',
// Bookshelves
'bookshelf_create' => 'je kreirao/la Policu za knjige',
'bookshelf_create_notification' => 'Polica za knjige Uspješno Kreirana',
'bookshelf_update' => 'je ažurirao/la policu za knjige',
'bookshelf_update_notification' => 'Polica za knjige Uspješno Ažurirana',
'bookshelf_delete' => 'je izbrisao/la policu za knjige',
'bookshelf_delete_notification' => 'Polica za knjige Uspješno Izbrisana',
// Other
'commented_on' => 'je komentarisao/la na',
'permissions_update' => 'je ažurirao/la dozvole',
];

View File

@ -0,0 +1,77 @@
<?php
/**
* Authentication Language Lines
* The following language lines are used during authentication for various
* messages that we need to display to the user.
*/
return [
'failed' => 'Ovi pristupni podaci se ne slažu sa našom evidencijom.',
'throttle' => 'Preveliki broj pokušaja prijave. Molimo vas da pokušate ponovo za :seconds sekundi.',
// Login & Register
'sign_up' => 'Registruj se',
'log_in' => 'Prijavi se',
'log_in_with' => 'Prijavi se sa :socialDriver',
'sign_up_with' => 'Registruj se sa :socialDriver',
'logout' => 'Odjavi se',
'name' => 'Ime',
'username' => 'Korisničko ime',
'email' => 'E-mail',
'password' => 'Lozinka',
'password_confirm' => 'Potvrdi lozinku',
'password_hint' => 'Mora imati više od 7 karaktera',
'forgot_password' => 'Zaboravljena lozinka?',
'remember_me' => 'Zapamti me',
'ldap_email_hint' => 'Unesite e-mail koji će se koristiti za ovaj račun.',
'create_account' => 'Napravi račun',
'already_have_account' => 'Već imate račun?',
'dont_have_account' => 'Nemate korisnički račun?',
'social_login' => 'Prijava preko društvene mreže',
'social_registration' => 'Registracija pomoću društvene mreže',
'social_registration_text' => 'Registruj i prijavi se koristeći drugi servis.',
'register_thanks' => 'Hvala na registraciji!',
'register_confirm' => 'Provjerite vašu e-mail adresu i pritisnite dugme za potvrdu da bi dobili pristup :appName.',
'registrations_disabled' => 'Registracije su trenutno onemogućene',
'registration_email_domain_invalid' => 'Ta e-mail domena nema pristup ovoj aplikaciji',
'register_success' => 'Hvala na registraciji! Sada ste registrovani i prijavljeni.',
// Password Reset
'reset_password' => 'Resetuj Lozinku',
'reset_password_send_instructions' => 'Unesite vašu e-mail adresu ispod i na nju ćemo vam poslati e-mail sa linkom za promjenu lozinke.',
'reset_password_send_button' => 'Pošalji link za promjenu',
'reset_password_sent' => 'Link za promjenu lozinke će biti poslan na :email ako ta adresa postoji u sistemu.',
'reset_password_success' => 'Vaša lozinka je uspješno promijenjena.',
'email_reset_subject' => 'Resetujte vašu lozinku od :appName',
'email_reset_text' => 'Primate ovaj e-mail jer smo dobili zahtjev za promjenu lozinke za vaš račun.',
'email_reset_not_requested' => 'Ako niste zahtijevali promjenu lozinke ne trebate ništa više uraditi.',
// Email Confirmation
'email_confirm_subject' => 'Potvrdite vaš e-mail na :appName',
'email_confirm_greeting' => 'Hvala na pristupanju :appName!',
'email_confirm_text' => 'Potvrdite vašu e-mail adresu pritiskom na dugme ispod:',
'email_confirm_action' => 'Potvrdi e-mail',
'email_confirm_send_error' => 'Potvrda e-maila je obavezna ali sistem nije mogao poslati e-mail. Kontaktirajte administratora da biste bili sigurni da je e-mail postavljen ispravno.',
'email_confirm_success' => 'Vaš e-mail je potvrđen!',
'email_confirm_resent' => 'E-mail za potvrdu je ponovno poslan. Provjerite vaš e-mail.',
'email_not_confirmed' => 'E-mail adresa nije potvrđena',
'email_not_confirmed_text' => 'Vaša e-mail adresa nije još potvrđena.',
'email_not_confirmed_click_link' => 'Kliknite na link u e-mailu koji vam je poslan nakon što ste se registrovali.',
'email_not_confirmed_resend' => 'Ako ne možete naći e-mail možete ponovno poslati e-mail za potvrdu tako što ćete ispuniti formu ispod.',
'email_not_confirmed_resend_button' => 'Ponovno pošalji e-mail za potvrdu',
// User Invite
'user_invite_email_subject' => 'Pozvani ste da se pridružite :appName!',
'user_invite_email_greeting' => 'Račun je napravljen za vas na :appName.',
'user_invite_email_text' => 'Pritisnite dugme ispod da niste postavili lozinku vašeg računa i tako dobili pristup:',
'user_invite_email_action' => 'Postavi lozinku računa',
'user_invite_page_welcome' => 'Dobrodošli na :appName!',
'user_invite_page_text' => 'Da biste završili vaš račun i dobili pristup morate postaviti lozinku koju ćete koristiti da se prijavite na :appName tokom budućih posjeta.',
'user_invite_page_confirm_button' => 'Potvrdi lozinku',
'user_invite_success' => 'Lozinka postavljena, sada imate pristup :sppName!'
];

View File

@ -0,0 +1,85 @@
<?php
/**
* Common elements found throughout many areas of BookStack.
*/
return [
// Buttons
'cancel' => 'Otkaži',
'confirm' => 'Potvrdi',
'back' => 'Nazad',
'save' => 'Spremi',
'continue' => 'Nastavi',
'select' => 'Odaberi',
'toggle_all' => 'Prebaci sve',
'more' => 'Više',
// Form Labels
'name' => 'Ime',
'description' => 'Opis',
'role' => 'Uloga',
'cover_image' => 'Naslovna slika',
'cover_image_description' => 'Ova slika treba biti približno 440x250px.',
// Actions
'actions' => 'Akcije',
'view' => 'Prikaz',
'view_all' => 'Prikaži sve',
'create' => 'Kreiraj',
'update' => 'Ažuriraj',
'edit' => 'Uredi',
'sort' => 'Sortiraj',
'move' => 'Pomjeri',
'copy' => 'Kopiraj',
'reply' => 'Odgovori',
'delete' => 'Izbriši',
'delete_confirm' => 'Potvrdi brisanje',
'search' => 'Traži',
'search_clear' => 'Očisti pretragu',
'reset' => 'Resetuj',
'remove' => 'Ukloni',
'add' => 'Dodaj',
'fullscreen' => 'Prikaz preko čitavog ekrana',
// Sort Options
'sort_options' => 'Opcije sortiranja',
'sort_direction_toggle' => 'Prebacivanje smjera sortiranja',
'sort_ascending' => 'Sortiraj uzlazno',
'sort_descending' => 'Sortiraj silazno',
'sort_name' => 'Ime',
'sort_created_at' => 'Datum kreiranja',
'sort_updated_at' => 'Datum ažuriranja',
// Misc
'deleted_user' => 'Obrisani korisnik',
'no_activity' => 'Nema aktivnosti za prikazivanje',
'no_items' => 'Nema dostupnih stavki',
'back_to_top' => 'Povratak na vrh',
'toggle_details' => 'Vidi detalje',
'toggle_thumbnails' => 'Vidi prikaze slika',
'details' => 'Detalji',
'grid_view' => 'Prikaz rešetke',
'list_view' => 'Prikaz liste',
'default' => 'Početne postavke',
'breadcrumb' => 'Navigacijske stavke',
// Header
'profile_menu' => 'Meni profila',
'view_profile' => 'Pogledaj profil',
'edit_profile' => 'Izmjeni profil',
'dark_mode' => 'Tamni način rada',
'light_mode' => 'Svijetli način rada',
// Layout tabs
'tab_info' => 'Informacije',
'tab_content' => 'Sadržaj',
// Email Content
'email_action_help' => 'Ukoliko imate poteškoća sa pritiskom na ":actionText" dugme, kopirajte i zaljepite URL koji se nalazi ispod u vaš web pretraživač:',
'email_rights' => 'Sva prava pridržana',
// Footer Link Options
// Not directly used but available for convenience to users.
'privacy_policy' => 'Pravila o privatnosti',
'terms_of_service' => 'Uslovi korištenja',
];

View File

@ -0,0 +1,34 @@
<?php
/**
* Text used in custom JavaScript driven components.
*/
return [
// Image Manager
'image_select' => 'Biraj sliku',
'image_all' => 'Sve',
'image_all_title' => 'Pogledaj sve slike',
'image_book_title' => 'Pogledaj slike prenesene u ovu knjigu',
'image_page_title' => 'Pogledaj slike prenesene na ovu stranicu',
'image_search_hint' => 'Traži po nazivu slike',
'image_uploaded' => 'Preneseno :uploadedDate',
'image_load_more' => 'Učitaj još',
'image_image_name' => 'Naziv slike',
'image_delete_used' => 'Ova slika se koristi na stranicama prikazanim ispod.',
'image_delete_confirm_text' => 'Jeste li sigurni da želite obrisati ovu sliku?',
'image_select_image' => 'Odaberi sliku',
'image_dropzone' => 'Ostavi slike ili pritisnite ovdje da ih prenesete',
'images_deleted' => 'Slike su izbrisane',
'image_preview' => 'Pregled Slike',
'image_upload_success' => 'Slika uspješno učitana',
'image_update_success' => 'Detalji slike uspješno ažurirani',
'image_delete_success' => 'Slika uspješno izbrisana',
'image_upload_remove' => 'Ukloni',
// Code Editor
'code_editor' => 'Uredi Kod',
'code_language' => 'Jezik koda',
'code_content' => 'Sadržaj Koda',
'code_session_history' => 'Historija Sesije',
'code_save' => 'Snimi Kod',
];

View File

@ -0,0 +1,319 @@
<?php
/**
* Text used for 'Entities' (Document Structure Elements) such as
* Books, Shelves, Chapters & Pages
*/
return [
// Shared
'recently_created' => 'Nedavno napravljen',
'recently_created_pages' => 'Nedavno napravljene stranice',
'recently_updated_pages' => 'Nedavno ažurirane stranice',
'recently_created_chapters' => 'Nedavno napravljena poglavlja',
'recently_created_books' => 'Nedavno napravljene knjige',
'recently_created_shelves' => 'Nedavno napravljene police',
'recently_update' => 'Nedavno ažurirana',
'recently_viewed' => 'Nedavno pogledana',
'recent_activity' => 'Nedavna aktivnost',
'create_now' => 'Napravi jednu sada',
'revisions' => 'Promjene',
'meta_revision' => 'Promjena #:revisionCount',
'meta_created' => 'Napravljena :timeLength',
'meta_created_name' => 'Napravljena :timeLength od :user',
'meta_updated' => 'Ažurirana :timeLength',
'meta_updated_name' => 'Ažurirana :timeLength od :user',
'meta_owned_name' => 'Vlasnik je :user',
'entity_select' => 'Odaberi entitet',
'images' => 'Slike',
'my_recent_drafts' => 'Moje nedavne skice',
'my_recently_viewed' => 'Moji nedavni pregledi',
'no_pages_viewed' => 'Niste pogledali nijednu stranicu',
'no_pages_recently_created' => 'Nijedna stranica nije napravljena nedavno',
'no_pages_recently_updated' => 'Niijedna stranica nije ažurirana nedavno',
'export' => 'Izvezi',
'export_html' => 'Sadržani web fajl',
'export_pdf' => 'PDF fajl',
'export_text' => 'Plain Text fajl',
// Permissions and restrictions
'permissions' => 'Dozvole',
'permissions_intro' => 'Jednom omogućene, ove dozvole imaju prednost nad dozvolama uloge.',
'permissions_enable' => 'Omogući prilagođena dopuštenja',
'permissions_save' => 'Snimi dozvole',
'permissions_owner' => 'Vlasnik',
// Search
'search_results' => 'Rezultati pretrage',
'search_total_results_found' => ':count rezultata je nađeno|:count ukupno rezultata je nađeno',
'search_clear' => 'Očisti pretragu',
'search_no_pages' => 'Nijedna stranica nije nađena',
'search_for_term' => 'Traži :term',
'search_more' => 'Više rezultata',
'search_advanced' => 'Napredna pretraga',
'search_terms' => 'Pojmovi za pretragu',
'search_content_type' => 'Vrsta sadržaja',
'search_exact_matches' => 'Tačna podudaranja',
'search_tags' => 'Pretraga oznaka',
'search_options' => 'Opcije',
'search_viewed_by_me' => 'Ja sam pogledao/la',
'search_not_viewed_by_me' => 'Nisam pogledao/la',
'search_permissions_set' => 'Dozvole',
'search_created_by_me' => 'Ja sam napravio/la',
'search_updated_by_me' => 'Ja sam ažurirao/la',
'search_date_options' => 'Opcije datuma',
'search_updated_before' => 'Ažurirano prije',
'search_updated_after' => 'Ažurirano nakon',
'search_created_before' => 'Kreirano prije',
'search_created_after' => 'Kreirano nakon',
'search_set_date' => 'Postavi datum',
'search_update' => 'Ažuriraj pretragu',
// Shelves
'shelf' => 'Polica',
'shelves' => 'Police',
'x_shelves' => ':count Polica|:count Police',
'shelves_long' => 'Police za knjige',
'shelves_empty' => 'Niti jedna polica nije kreirana',
'shelves_create' => 'Kreiraj novu policu',
'shelves_popular' => 'Popularne police',
'shelves_new' => 'Nove police',
'shelves_new_action' => 'Nova polica',
'shelves_popular_empty' => 'Najpopularnije police će se pojaviti ovdje.',
'shelves_new_empty' => 'Najnovije police će se pojaviti ovdje.',
'shelves_save' => 'Spremi policu',
'shelves_books' => 'Knjige na ovoj polici',
'shelves_add_books' => 'Dodaj knjige na ovu policu',
'shelves_drag_books' => 'Prenesi knjige ovdje da bi ih dodao/la na ovu policu',
'shelves_empty_contents' => 'Ova polica nema knjiga koje su postavljene na nju',
'shelves_edit_and_assign' => 'Uredi policu da bi dodao/la knjige',
'shelves_edit_named' => 'Uredi :name police za knjige',
'shelves_edit' => 'Uredi policu za knjige',
'shelves_delete' => 'Izbriši policu za knjige',
'shelves_delete_named' => 'Izbriši policu za knjige :name',
'shelves_delete_explain' => "This will delete the bookshelf with the name ':name'. Contained books will not be deleted.",
'shelves_delete_confirmation' => 'Are you sure you want to delete this bookshelf?',
'shelves_permissions' => 'Bookshelf Permissions',
'shelves_permissions_updated' => 'Bookshelf Permissions Updated',
'shelves_permissions_active' => 'Bookshelf Permissions Active',
'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
'shelves_copy_permissions' => 'Copy Permissions',
'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
'shelves_copy_permission_success' => 'Bookshelf permissions copied to :count books',
// Books
'book' => 'Book',
'books' => 'Books',
'x_books' => ':count Book|:count Books',
'books_empty' => 'No books have been created',
'books_popular' => 'Popular Books',
'books_recent' => 'Recent Books',
'books_new' => 'New Books',
'books_new_action' => 'New Book',
'books_popular_empty' => 'The most popular books will appear here.',
'books_new_empty' => 'The most recently created books will appear here.',
'books_create' => 'Create New Book',
'books_delete' => 'Delete Book',
'books_delete_named' => 'Delete Book :bookName',
'books_delete_explain' => 'Ovo će izbrisati knjigu naziva \':bookName\'. Sve stranice i poglavlja će biti uklonjene.',
'books_delete_confirmation' => 'Jeste li sigurni da želite izbrisati ovu knjigu?',
'books_edit' => 'Uredi knjigu',
'books_edit_named' => 'Uredi knjigu :bookName',
'books_form_book_name' => 'Naziv knjige',
'books_save' => 'Spremi knjigu',
'books_permissions' => 'Dozvole knjige',
'books_permissions_updated' => 'Dozvole knjige su ažurirane',
'books_empty_contents' => 'Za ovu knjigu nisu napravljene ni stranice ni poglavlja.',
'books_empty_create_page' => 'Napravi novu stranicu',
'books_empty_sort_current_book' => 'Sortiraj trenutnu knjigu',
'books_empty_add_chapter' => 'Dodaj poglavlje',
'books_permissions_active' => 'Dozvole za knjigu su aktivne',
'books_search_this' => 'Pretraži ovu knjigu',
'books_navigation' => 'Navigacija knjige',
'books_sort' => 'Sortiraj sadržaj knjige',
'books_sort_named' => 'Sortiraj knjigu :bookName',
'books_sort_name' => 'Sortiraj po imenu',
'books_sort_created' => 'Sortiraj po datumu kreiranja',
'books_sort_updated' => 'Sortiraj po datumu ažuriranja',
'books_sort_chapters_first' => 'Poglavlja prva',
'books_sort_chapters_last' => 'Poglavlja zadnja',
'books_sort_show_other' => 'Prikaži druge knjige',
'books_sort_save' => 'Spremi trenutni poredak',
// Chapters
'chapter' => 'Poglavlje',
'chapters' => 'Poglavlja',
'x_chapters' => ':count Poglavlje|:count Poglavlja',
'chapters_popular' => 'Popularna poglavlja',
'chapters_new' => 'Novo poglavlje',
'chapters_create' => 'Napravi novo poglavlje',
'chapters_delete' => 'Izbriši poglavlje',
'chapters_delete_named' => 'Izbriši poglavlje :chapterName',
'chapters_delete_explain' => 'Ovo će izbrisati poglavlje naziva \':chapterName\'. Sve stranice koje postoje u ovom poglavlju će također biti izbrisane.',
'chapters_delete_confirm' => 'Jeste li sigurni da želite izbrisati ovo poglavlje?',
'chapters_edit' => 'Uredi poglavlje',
'chapters_edit_named' => 'Uredi poglavlje :chapterName',
'chapters_save' => 'Spremi poglavlje',
'chapters_move' => 'Premjesti poglavlje',
'chapters_move_named' => 'Premjesti poglavlje :chapterName',
'chapter_move_success' => 'Poglavlje premješteno u :bookName',
'chapters_permissions' => 'Dozvole poglavlja',
'chapters_empty' => 'U ovom poglavlju trenutno nema stranica.',
'chapters_permissions_active' => 'Dozvole za poglavlje su aktivne',
'chapters_permissions_success' => 'Dozvole za poglavlje su ažurirane',
'chapters_search_this' => 'Pretražuj ovo poglavlje',
// Pages
'page' => 'Stranica',
'pages' => 'Stranice',
'x_pages' => ':count Stranica|:count Stranice',
'pages_popular' => 'Popularne stranice',
'pages_new' => 'Nova stranica',
'pages_attachments' => 'Attachments',
'pages_navigation' => 'Page Navigation',
'pages_delete' => 'Delete Page',
'pages_delete_named' => 'Delete Page :pageName',
'pages_delete_draft_named' => 'Delete Draft Page :pageName',
'pages_delete_draft' => 'Delete Draft Page',
'pages_delete_success' => 'Page deleted',
'pages_delete_draft_success' => 'Draft page deleted',
'pages_delete_confirm' => 'Are you sure you want to delete this page?',
'pages_delete_draft_confirm' => 'Are you sure you want to delete this draft page?',
'pages_editing_named' => 'Editing Page :pageName',
'pages_edit_draft_options' => 'Draft Options',
'pages_edit_save_draft' => 'Save Draft',
'pages_edit_draft' => 'Edit Page Draft',
'pages_editing_draft' => 'Editing Draft',
'pages_editing_page' => 'Editing Page',
'pages_edit_draft_save_at' => 'Draft saved at ',
'pages_edit_delete_draft' => 'Delete Draft',
'pages_edit_discard_draft' => 'Discard Draft',
'pages_edit_set_changelog' => 'Set Changelog',
'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
'pages_edit_enter_changelog' => 'Enter Changelog',
'pages_save' => 'Save Page',
'pages_title' => 'Page Title',
'pages_name' => 'Page Name',
'pages_md_editor' => 'Editor',
'pages_md_preview' => 'Preview',
'pages_md_insert_image' => 'Insert Image',
'pages_md_insert_link' => 'Insert Entity Link',
'pages_md_insert_drawing' => 'Insert Drawing',
'pages_not_in_chapter' => 'Page is not in a chapter',
'pages_move' => 'Move Page',
'pages_move_success' => 'Page moved to ":parentName"',
'pages_copy' => 'Copy Page',
'pages_copy_desination' => 'Copy Destination',
'pages_copy_success' => 'Page successfully copied',
'pages_permissions' => 'Page Permissions',
'pages_permissions_success' => 'Page permissions updated',
'pages_revision' => 'Revision',
'pages_revisions' => 'Page Revisions',
'pages_revisions_named' => 'Page Revisions for :pageName',
'pages_revision_named' => 'Page Revision for :pageName',
'pages_revision_restored_from' => 'Restored from #:id; :summary',
'pages_revisions_created_by' => 'Created By',
'pages_revisions_date' => 'Revision Date',
'pages_revisions_number' => '#',
'pages_revisions_numbered' => 'Revision #:id',
'pages_revisions_numbered_changes' => 'Revision #:id Changes',
'pages_revisions_changelog' => 'Changelog',
'pages_revisions_changes' => 'Changes',
'pages_revisions_current' => 'Trenutna verzija',
'pages_revisions_preview' => 'Pregled',
'pages_revisions_restore' => 'Vrati',
'pages_revisions_none' => 'Ova stranica nema promjena',
'pages_copy_link' => 'Iskopiraj link',
'pages_edit_content_link' => 'Uredi sadržaj',
'pages_permissions_active' => 'Dozvole za stranicu su aktivne',
'pages_initial_revision' => 'Prvo izdavanje',
'pages_initial_name' => 'Nova stranica',
'pages_editing_draft_notification' => 'Trenutno uređujete skicu koja je posljednji put snimljena :timeDiff.',
'pages_draft_edited_notification' => 'Ova stranica je ažurirana nakon tog vremena. Preporučujemo da odbacite ovu skicu.',
'pages_draft_edit_active' => [
'start_a' => ':count korisnika je počelo sa uređivanjem ove stranice',
'start_b' => ':userName je počeo/la sa uređivanjem ove stranice',
'time_a' => 'od kada je stranica posljednji put ažurirana',
'time_b' => 'u posljednjih :minCount minuta',
'message' => ':start :time. Pazite da jedni drugima ne prepišete promjene!',
],
'pages_draft_discarded' => 'Skica je odbačena, uređivač je ažuriran sa trenutnim sadržajem stranice',
'pages_specific' => 'Specifična stranica',
'pages_is_template' => 'Predložak stranice',
// Editor Sidebar
'page_tags' => 'Oznake stranice',
'chapter_tags' => 'Oznake poglavlja',
'book_tags' => 'Oznake knjige',
'shelf_tags' => 'Oznake police',
'tag' => 'Oznaka',
'tags' => 'Oznake',
'tag_name' => 'Naziv oznake',
'tag_value' => 'Vrijednost oznake (nije obavezno)',
'tags_explain' => "Dodaj nekoliko oznaka da bi sadržaj bio bolje kategorisan. \n Možeš dodati vrijednost oznaci za dublju organizaciju.",
'tags_add' => 'Dodaj još jednu oznaku',
'tags_remove' => 'Ukloni ovu oznaku',
'attachments' => 'Prilozi',
'attachments_explain' => 'Učitajte fajlove ili priložite poveznice da bi ih prikazali na stranici. Oni su onda vidljivi u navigaciji sa strane.',
'attachments_explain_instant_save' => 'Sve promjene se snimaju odmah.',
'attachments_items' => 'Priložene stavke',
'attachments_upload' => 'Učitaj fajl',
'attachments_link' => 'Zakači link',
'attachments_set_link' => 'Postavi link',
'attachments_delete' => 'Jeste li sigurni da želite obrisati ovaj prilog?',
'attachments_dropzone' => 'Spustite fajlove ili pritisnite ovdje da priložite fajl',
'attachments_no_files' => 'Niti jedan fajl nije prenesen',
'attachments_explain_link' => 'Možete zakačiti link ako ne želite učitati fajl. To može biti link druge stranice ili link za fajl u oblaku.',
'attachments_link_name' => 'Naziv linka',
'attachment_link' => 'Link poveznice',
'attachments_link_url' => 'Link do fajla',
'attachments_link_url_hint' => 'Url stranice ili fajla',
'attach' => 'Zakači',
'attachments_insert_link' => 'Dodaj priloženi link na stranicu',
'attachments_edit_file' => 'Uredi fajl',
'attachments_edit_file_name' => 'Naziv fajla',
'attachments_edit_drop_upload' => 'Spusti fajlove ili pritisni ovdje da učitaš i prepišeš',
'attachments_order_updated' => 'Attachment order updated',
'attachments_updated_success' => 'Attachment details updated',
'attachments_deleted' => 'Attachment deleted',
'attachments_file_uploaded' => 'File successfully uploaded',
'attachments_file_updated' => 'File successfully updated',
'attachments_link_attached' => 'Link successfully attached to page',
'templates' => 'Templates',
'templates_set_as_template' => 'Page is a template',
'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.',
'templates_replace_content' => 'Replace page content',
'templates_append_content' => 'Append to page content',
'templates_prepend_content' => 'Prepend to page content',
// Profile View
'profile_user_for_x' => 'User for :time',
'profile_created_content' => 'Created Content',
'profile_not_created_pages' => ':userName has not created any pages',
'profile_not_created_chapters' => ':userName has not created any chapters',
'profile_not_created_books' => ':userName has not created any books',
'profile_not_created_shelves' => ':userName has not created any shelves',
// Comments
'comment' => 'Comment',
'comments' => 'Comments',
'comment_add' => 'Add Comment',
'comment_placeholder' => 'Leave a comment here',
'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
'comment_save' => 'Save Comment',
'comment_saving' => 'Saving comment...',
'comment_deleting' => 'Deleting comment...',
'comment_new' => 'New Comment',
'comment_created' => 'commented :createDiff',
'comment_updated' => 'Updated :updateDiff by :username',
'comment_deleted_success' => 'Comment deleted',
'comment_created_success' => 'Comment added',
'comment_updated_success' => 'Comment updated',
'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
'comment_in_reply_to' => 'In reply to :commentId',
// Revision
'revision_delete_confirm' => 'Are you sure you want to delete this revision?',
'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
'revision_delete_success' => 'Revision deleted',
'revision_cannot_delete_latest' => 'Cannot delete the latest revision.'
];

View File

@ -0,0 +1,102 @@
<?php
/**
* Text shown in error messaging.
*/
return [
// Permissions
'permission' => 'Nemate ovlaštenje da pristupite ovoj stranici.',
'permissionJson' => 'Nemate ovlaštenje da izvršite tu akciju.',
// Auth
'error_user_exists_different_creds' => 'Korisnik sa e-mailom :email već postoji ali sa različitim podacima.',
'email_already_confirmed' => 'E-mail je već potvrđen, pokušajte se prijaviti.',
'email_confirmation_invalid' => 'Ovaj token za potvrdu nije ispravan ili je već iskorišten, molimo vas pokušajte se registrovati ponovno.',
'email_confirmation_expired' => 'Ovaj token za potvrdu je istekao, novi e-mail za potvrdu je poslan.',
'email_confirmation_awaiting' => 'E-mail adresa za račun koji se koristi mora biti potvrđena',
'ldap_fail_anonymous' => 'LDAP pristup nije uspio koristeći anonimno povezivanje',
'ldap_fail_authed' => 'LDAP pristup nije uspio koristeći date detalje lozinke i dn',
'ldap_extension_not_installed' => 'LDAP PHP ekstenzija nije instalirana',
'ldap_cannot_connect' => 'Nije se moguće povezati sa ldap serverom, incijalna konekcija nije uspjela',
'saml_already_logged_in' => 'Već prijavljeni',
'saml_user_not_registered' => 'Korisnik :user nije registrovan i automatska registracija je onemogućena',
'saml_no_email_address' => 'E-mail adresa za ovog korisnika nije nađena u podacima dobijenim od eksternog autentifikacijskog sistema',
'saml_invalid_response_id' => 'Proces, koji je pokrenula ova aplikacija, nije prepoznao zahtjev od eksternog sistema za autentifikaciju. Navigacija nazad nakon prijave može uzrokovati ovaj problem.',
'saml_fail_authed' => 'Prijava koristeći :system nije uspjela, sistem nije obezbijedio uspješnu autorizaciju',
'social_no_action_defined' => 'Nema definisane akcije',
'social_login_bad_response' => "Došlo je do greške prilikom prijave preko :socialAccount :\n:error",
'social_account_in_use' => 'Ovaj :socialAccount račun se već koristi, pokušajte se prijaviti putem :socialAccount opcije.',
'social_account_email_in_use' => 'E-mail :email se već koristi. Ako već imate račun možete povezati vaš :socialAccount račun u postavkama profila.',
'social_account_existing' => 'Ovaj :socialAccount je već povezan sa vašim profilom.',
'social_account_already_used_existing' => 'Drugi korisnik već koristi ovaj :socialAccount.',
'social_account_not_used' => 'Ovaj :socialAccount nije povezan ni sa jednim korisnikom. Povežite ga u postavkama profila. ',
'social_account_register_instructions' => 'Ako još uvijek nemate račun, možete se registrovati koristeći :socialAccount opciju.',
'social_driver_not_found' => 'Driver društvene mreže nije pronađen',
'social_driver_not_configured' => 'Vaše :socialAccount postavke nisu konfigurisane ispravno.',
'invite_token_expired' => 'Pozivni link je istekao. Možete umjesto toga pokušati da resetujete lozinku.',
// System
'path_not_writable' => 'Na putanju fajla :filePath se ne može učitati. Potvrdite da je omogućeno pisanje na server.',
'cannot_get_image_from_url' => 'Nije moguće dobiti sliku sa :url',
'cannot_create_thumbs' => 'Server ne može kreirati sličice. Provjerite da imate instaliranu GD PHP ekstenziju.',
'server_upload_limit' => 'Server ne dopušta učitavanja ove veličine. Pokušajte sa manjom veličinom fajla.',
'uploaded' => 'Server ne dopušta učitavanja ove veličine. Pokušajte sa manjom veličinom fajla.',
'image_upload_error' => 'Desila se greška prilikom učitavanja slike',
'image_upload_type_error' => 'Vrsta slike koja se učitava je neispravna',
'file_upload_timeout' => 'Vrijeme učitavanja fajla je isteklo.',
// Attachments
'attachment_not_found' => 'Prilog nije pronađen',
// Pages
'page_draft_autosave_fail' => 'Snimanje skice nije uspjelo. Provjerite da ste povezani na internet prije snimanja ove stranice',
'page_custom_home_deletion' => 'Stranicu nije moguće izbrisati dok se koristi kao početna stranica',
// Entities
'entity_not_found' => 'Entitet nije pronađen',
'bookshelf_not_found' => 'Polica za knjige nije pronađena',
'book_not_found' => 'Knjiga nije pronađena',
'page_not_found' => 'Stranica nije pronađena',
'chapter_not_found' => 'Poglavlje nije pronađeno',
'selected_book_not_found' => 'Odabrana knjiga nije pronađena',
'selected_book_chapter_not_found' => 'Odabrana knjiga ili poglavlje nije pronađeno',
'guests_cannot_save_drafts' => 'Gosti ne mogu snimati skice',
// Users
'users_cannot_delete_only_admin' => 'Ne možete izbrisati jedinog administratora',
'users_cannot_delete_guest' => 'Ne možete izbrisati gost korisnika',
// Roles
'role_cannot_be_edited' => 'Ova uloga ne može biti mijenjana',
'role_system_cannot_be_deleted' => 'Ova uloga je sistemska uloga i ne može biti izbrisana',
'role_registration_default_cannot_delete' => 'Ova uloga ne može biti izbrisana dok je postavljena kao osnovna registracijska uloga',
'role_cannot_remove_only_admin' => 'Ovaj korisnik je jedini korisnik sa ulogom administratora. Postavite ulogu administratora drugom korisniku prije nego je uklonite ovdje.',
// Comments
'comment_list' => 'Desila se greška prilikom dobavljanja komentara.',
'cannot_add_comment_to_draft' => 'Ne možete dodati komentare na skicu.',
'comment_add' => 'Desila se greška prilikom dodavanja / ažuriranja komentara.',
'comment_delete' => 'Desila se greška prilikom brisanja komentara.',
'empty_comment' => 'Nemoguće dodati prazan komentar.',
// Error pages
'404_page_not_found' => 'Stranica nije pronađena',
'sorry_page_not_found' => 'Stranica koju ste tražili nije pronađena.',
'sorry_page_not_found_permission_warning' => 'Ako ste očekivali da ova stranica postoji, možda nemate privilegije da joj pristupite.',
'return_home' => 'Nazad na početnu stranu',
'error_occurred' => 'Desila se greška',
'app_down' => ':appName trenutno nije u funkciji',
'back_soon' => 'Biti će uskoro u funkciji.',
// API errors
'api_no_authorization_found' => 'Na zahtjevu nije pronađen token za autorizaciju',
'api_bad_authorization_format' => 'Token za autorizaciju je pronađen u zahtjevu ali je format neispravan',
'api_user_token_not_found' => 'Nije pronađen odgovarajući API token za pruženi token autorizacije',
'api_incorrect_token_secret' => 'Tajni ključ naveden za dati korišteni API token nije tačan',
'api_user_no_api_permission' => 'Vlasnik korištenog API tokena nema dozvolu za upućivanje API poziva',
'api_user_token_expired' => 'Autorizacijski token je istekao',
// Settings & Maintenance
'maintenance_test_email_failure' => 'Došlo je do greške prilikom slanja testnog e-maila:',
];

View File

@ -0,0 +1,12 @@
<?php
/**
* Pagination Language Lines
* The following language lines are used by the paginator library to build
* the simple pagination links.
*/
return [
'previous' => '&laquo; Prethodna',
'next' => 'Sljedeća &raquo;',
];

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