Compare commits

..

10 Commits

Author SHA1 Message Date
Dan Brown
93433fdb0f Added more complexity in an attempt to make ldap host failover fit 2022-11-25 17:45:06 +00:00
Dan Brown
77d4a28442 Refactored & split LDAP connection logic
Updated ldap connections to be carried via their own class.
Extracted connection logic to its own class.
Having trouble making this new format testable.
2022-10-18 00:21:11 +01:00
Dan Brown
661d8059ed Fixed incorrect dn format in ldap mocks 2022-10-17 18:15:12 +01:00
Dan Brown
3d8df952b7 Combined LDAP connect and bind for failover handling
Not totally happy with the complexity in approach, could maybe do with
refactoring some of this out, but getting a little tired spending time
on this.
2022-10-17 17:46:14 +01:00
Dan Brown
303dbf9b01 Ldap host failover: updated method names and split out method 2022-10-16 22:40:10 +01:00
Dan Brown
392eef8273 Added ldap host failover test, updated error handling
Error handling updated after testing, since the ldap 'starttls'
operation can throw an error, or maybe return false. The PHP docs are
quite under-documented in regards to this function.
2022-10-16 22:15:15 +01:00
Dan Brown
fc4380cbc7 Merge branch 'development' into Khazhinov/development 2022-10-16 21:37:10 +01:00
Vladislav Khazhinov
8658459151 Merge branch 'BookStackApp:development' into development 2022-10-04 10:36:54 +03:00
Khazhinov Vladislav
965258baf5 multiple ldap server update 2022-09-30 09:52:12 +03:00
Khazhinov Vladislav
4bacc45fb7 multiple ldap server 2022-09-21 15:24:57 +03:00
702 changed files with 7086 additions and 16521 deletions

View File

@@ -268,7 +268,6 @@ OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false
OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false
OIDC_EXTERNAL_ID_CLAIM=sub
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option
@@ -369,4 +368,4 @@ LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
# IP address '146.191.42.4' would result in '146.191.x.x' being logged.
# For the IPv6 address '2001:db8:85a3:8d3:1319:8a2e:370:7348' this would result as:
# '2001:db8:85a3:8d3:x:x:x:x'
IP_ADDRESS_PRECISION=4
IP_ADDRESS_PRECISION=4

View File

@@ -176,7 +176,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: ; French; Dutch; Turkish
REMOVED_USER :: ; Dutch; Turkish
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -280,31 +280,3 @@ DerLinkman (derlinkman) :: German; German Informal
TurnArabic :: Arabic
Martin Sebek (sebekmartin) :: Czech
Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
digilady :: Greek
Linus (LinusOP) :: Swedish
Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian
RandomUser0815 :: German Informal; German
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
구인회 (laskdjlaskdj12) :: Korean
LiZerui (CNLiZerui) :: Chinese Traditional
Fabrice Boyer (FabriceBoyer) :: French
mikael (bitcanon) :: Swedish
Matthias Mai (schnapsidee) :: German; German Informal
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
Jan Mitrof (jan.kachlik) :: Czech
edwardsmirnov :: Russian
Mr_OSS117 :: French
shotu :: French
Cesar_Lopez_Aguillon :: Spanish
bdewoop :: German
dina davoudi (dina.davoudi) :: Persian
Angelos Chouvardas (achouvardas) :: Greek
rndrss :: Portuguese, Brazilian
rirac294 :: Russian
David Furman (thefourCraft) :: Hebrew
Pafzedog :: French
Yllelder :: Spanish
Adrian Ocneanu (aocneanu) :: Romanian
Eduardo Castanho (EduardoCastanho) :: Portuguese
VIET NAM VPS (vietnamvps) :: Vietnamese
m4tthi4s :: French

View File

@@ -18,10 +18,10 @@ jobs:
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v2
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-8.1

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['7.4', '8.0', '8.1', '8.2']
php: ['7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1
@@ -21,10 +21,10 @@ jobs:
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v2
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['7.4', '8.0', '8.1', '8.2']
php: ['7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1
@@ -16,15 +16,15 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v2
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}

6
.gitignore vendored
View File

@@ -5,10 +5,10 @@ Homestead.yaml
.idea
npm-debug.log
yarn-error.log
/public/dist/*.map
/public/dist
/public/plugins
/public/css/*.map
/public/js/*.map
/public/css
/public/js
/public/bower
/public/build/
/storage/images

View File

@@ -2,12 +2,10 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Str;
@@ -42,12 +40,6 @@ class Activity extends Model
return $this->belongsTo(User::class);
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
}
/**
* Returns text from the language files, Looks up by using the activity key.
*/

View File

@@ -2,9 +2,7 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Favourite extends Model
@@ -18,10 +16,4 @@ class Favourite extends Model
{
return $this->morphTo();
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'favouritable_id')
->whereColumn('favourites.favouritable_type', '=', 'joint_permissions.entity_type');
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace BookStack\Actions\Queries;
use BookStack\Actions\Webhook;
use BookStack\Util\SimpleListOptions;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Get all the webhooks in the system in a paginated format.
*/
class WebhooksAllPaginatedAndSorted
{
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
{
$query = Webhook::query()->select(['*'])
->withCount(['trackedEvents'])
->orderBy($listOptions->getSort(), $listOptions->getOrder());
if ($listOptions->getSearch()) {
$term = '%' . $listOptions->getSearch() . '%';
$query->where(function ($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('endpoint', 'like', $term);
});
}
return $query->paginate($count);
}
}

View File

@@ -2,10 +2,8 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
@@ -29,12 +27,6 @@ class Tag extends Model
return $this->morphTo('entity');
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('tags.entity_type', '=', 'joint_permissions.entity_type');
}
/**
* Get a full URL to start a tag name search for this tag name.
*/

View File

@@ -4,7 +4,6 @@ namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Models\Entity;
use BookStack\Util\SimpleListOptions;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -21,14 +20,8 @@ class TagRepo
/**
* Start a query against all tags in the system.
*/
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
{
$searchTerm = $listOptions->getSearch();
$sort = $listOptions->getSort();
if ($sort === 'name' && $nameFilter) {
$sort = 'value';
}
$query = Tag::query()
->select([
'name',
@@ -39,7 +32,7 @@ class TagRepo
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
])
->orderBy($sort, $listOptions->getOrder());
->orderBy($nameFilter ? 'value' : 'name');
if ($nameFilter) {
$query->where('name', '=', $nameFilter);

View File

@@ -2,10 +2,8 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
@@ -30,12 +28,6 @@ class View extends Model
return $this->morphTo();
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'viewable_id')
->whereColumn('views.viewable_type', '=', 'joint_permissions.entity_type');
}
/**
* Increment the current user's view count for the given viewable model.
*/

View File

@@ -4,29 +4,21 @@ namespace BookStack\Api;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ListingResponseBuilder
{
protected Builder $query;
protected Request $request;
/**
* @var string[]
*/
protected array $fields;
protected $query;
protected $request;
protected $fields;
/**
* @var array<callable>
*/
protected array $resultModifiers = [];
protected $resultModifiers = [];
/**
* @var array<string, string>
*/
protected array $filterOperators = [
protected $filterOperators = [
'eq' => '=',
'ne' => '!=',
'gt' => '>',
@@ -70,9 +62,9 @@ class ListingResponseBuilder
/**
* Add a callback to modify each element of the results.
*
* @param (callable(Model): void) $modifier
* @param (callable(Model)) $modifier
*/
public function modifyResults(callable $modifier): void
public function modifyResults($modifier): void
{
$this->resultModifiers[] = $modifier;
}

View File

@@ -2,7 +2,7 @@
namespace BookStack\Auth\Access\Guards;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\Access\Ldap\LdapService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;

View File

@@ -1,136 +0,0 @@
<?php
namespace BookStack\Auth\Access;
/**
* Class Ldap
* An object-orientated thin abstraction wrapper for common PHP LDAP functions.
* Allows the standard LDAP functions to be mocked for testing.
*/
class Ldap
{
/**
* Connect to an LDAP server.
*
* @return resource
*/
public function connect(string $hostName, int $port)
{
return ldap_connect($hostName, $port);
}
/**
* Set the value of a LDAP option for the given connection.
*
* @param resource $ldapConnection
* @param mixed $value
*/
public function setOption($ldapConnection, int $option, $value): bool
{
return ldap_set_option($ldapConnection, $option, $value);
}
/**
* Start TLS on the given LDAP connection.
*/
public function startTls($ldapConnection): bool
{
return ldap_start_tls($ldapConnection);
}
/**
* Set the version number for the given ldap connection.
*
* @param resource $ldapConnection
*/
public function setVersion($ldapConnection, int $version): bool
{
return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
}
/**
* Search LDAP tree using the provided filter.
*
* @param resource $ldapConnection
* @param string $baseDn
* @param string $filter
* @param array|null $attributes
*
* @return resource
*/
public function search($ldapConnection, $baseDn, $filter, array $attributes = null)
{
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
}
/**
* Get entries from an ldap search result.
*
* @param resource $ldapConnection
* @param resource $ldapSearchResult
*
* @return array
*/
public function getEntries($ldapConnection, $ldapSearchResult)
{
return ldap_get_entries($ldapConnection, $ldapSearchResult);
}
/**
* Search and get entries immediately.
*
* @param resource $ldapConnection
* @param string $baseDn
* @param string $filter
* @param array|null $attributes
*
* @return resource
*/
public function searchAndGetEntries($ldapConnection, $baseDn, $filter, array $attributes = null)
{
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
return $this->getEntries($ldapConnection, $search);
}
/**
* Bind to LDAP directory.
*
* @param resource $ldapConnection
* @param string $bindRdn
* @param string $bindPassword
*
* @return bool
*/
public function bind($ldapConnection, $bindRdn = null, $bindPassword = null)
{
return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
}
/**
* Explode a LDAP dn string into an array of components.
*
* @param string $dn
* @param int $withAttrib
*
* @return array
*/
public function explodeDn(string $dn, int $withAttrib)
{
return ldap_explode_dn($dn, $withAttrib);
}
/**
* Escape a string for use in an LDAP filter.
*
* @param string $value
* @param string $ignore
* @param int $flags
*
* @return string
*/
public function escape(string $value, string $ignore = '', int $flags = 0)
{
return ldap_escape($value, $ignore, $flags);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace BookStack\Auth\Access\Ldap;
class LdapConfig
{
/**
* App provided config array.
* @var array
*/
protected array $config;
public function __construct(array $config)
{
$this->config = $config;
}
/**
* Get a value from the config.
*/
public function get(string $key)
{
return $this->config[$key] ?? null;
}
/**
* Parse the potentially multi-value LDAP server host string and return an array of host/port detail pairs.
* Multiple hosts are separated with a semicolon, for example: 'ldap.example.com:8069;ldaps://ldap.example.com'
*
* @return array<array{host: string, port: int}>
*/
public function getServers(): array
{
$serverStringList = explode(';', $this->get('server'));
return array_map(fn ($serverStr) => $this->parseSingleServerString($serverStr), $serverStringList);
}
/**
* Parse an LDAP server string and return the host and port for a connection.
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
*
* @return array{host: string, port: int}
*/
protected function parseSingleServerString(string $serverString): array
{
$serverNameParts = explode(':', trim($serverString));
// If we have a protocol just return the full string since PHP will ignore a separate port.
if ($serverNameParts[0] === 'ldaps' || $serverNameParts[0] === 'ldap') {
return ['host' => $serverString, 'port' => 389];
}
// Otherwise, extract the port out
$hostName = $serverNameParts[0];
$ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
return ['host' => $hostName, 'port' => $ldapPort];
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace BookStack\Auth\Access\Ldap;
use ErrorException;
/**
* An object-orientated wrapper for core ldap functions,
* holding an internal connection instance.
*/
class LdapConnection
{
/**
* The core ldap connection resource.
* @var resource
*/
protected $connection;
protected string $hostName;
protected int $port;
public function __construct(string $hostName, int $port)
{
$this->hostName = $hostName;
$this->port = $port;
$this->connection = $this->connect();
}
/**
* Start a connection to an LDAP server.
* Does not actually call out to the external server until an action is performed.
*
* @return resource
*/
protected function connect()
{
return ldap_connect($this->hostName, $this->port);
}
/**
* Set the value of a LDAP option for the current connection.
*
* @param mixed $value
*/
public function setOption(int $option, $value): bool
{
return ldap_set_option($this->connection, $option, $value);
}
/**
* Start TLS for this LDAP connection.
*/
public function startTls(): bool
{
return ldap_start_tls($this->connection);
}
/**
* Set the version number for this ldap connection.
*/
public function setVersion(int $version): bool
{
return $this->setOption(LDAP_OPT_PROTOCOL_VERSION, $version);
}
/**
* Search LDAP tree using the provided filter.
*
* @return resource
*/
public function search(string $baseDn, string $filter, array $attributes = null)
{
return ldap_search($this->connection, $baseDn, $filter, $attributes);
}
/**
* Get entries from an ldap search result.
*
* @param resource $ldapSearchResult
* @return array|false
*/
public function getEntries($ldapSearchResult)
{
return ldap_get_entries($this->connection, $ldapSearchResult);
}
/**
* Search and get entries immediately.
*
* @return array|false
*/
public function searchAndGetEntries(string $baseDn, string $filter, array $attributes = null)
{
$search = $this->search($baseDn, $filter, $attributes);
return $this->getEntries($search);
}
/**
* Bind to LDAP directory.
*
* @throws ErrorException
*/
public function bind(string $bindRdn = null, string $bindPassword = null): bool
{
return ldap_bind($this->connection, $bindRdn, $bindPassword);
}
/**
* Explode a LDAP dn string into an array of components.
*
* @return array|false
*/
public static function explodeDn(string $dn, int $withAttrib)
{
return ldap_explode_dn($dn, $withAttrib);
}
/**
* Escape a string for use in an LDAP filter.
*/
public static function escape(string $value, string $ignore = '', int $flags = 0): string
{
return ldap_escape($value, $ignore, $flags);
}
/**
* Set a non-connection-specific LDAP option.
* @param mixed $value
*/
public static function setGlobalOption(int $option, $value): bool
{
return ldap_set_option(null, $option, $value);
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace BookStack\Auth\Access\Ldap;
use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LdapFailedBindException;
use ErrorException;
use Illuminate\Support\Facades\Log;
class LdapConnectionManager
{
protected array $connectionCache = [];
/**
* Attempt to start and bind to a new LDAP connection as the configured LDAP system user.
*/
public function startSystemBind(LdapConfig $config): LdapConnection
{
// Incoming options are string|false
$dn = $config->get('dn');
$pass = $config->get('pass');
$isAnonymous = ($dn === false || $pass === false);
try {
return $this->startBind($dn ?: null, $pass ?: null, $config);
} catch (LdapFailedBindException $exception) {
$msg = ($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed'));
throw new LdapFailedBindException($msg);
}
}
/**
* Attempt to start and bind to a new LDAP connection.
* Will attempt against multiple defined fail-over hosts if set in the provided config.
*
* Throws a LdapFailedBindException error if the bind connected but failed.
* Otherwise, generic LdapException errors would be thrown.
*
* @throws LdapException
*/
public function startBind(?string $dn, ?string $password, LdapConfig $config): LdapConnection
{
// Check LDAP extension in installed
if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
throw new LdapException(trans('errors.ldap_extension_not_installed'));
}
// Disable certificate verification.
// This option works globally and must be set before a connection is created.
if ($config->get('tls_insecure')) {
LdapConnection::setGlobalOption(LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
}
$serverDetails = $config->getServers();
$lastException = null;
foreach ($serverDetails as $server) {
try {
$connection = $this->startServerConnection($server['host'], $server['port'], $config);
} catch (LdapException $exception) {
$lastException = $exception;
continue;
}
try {
$bound = $connection->bind($dn, $password);
if (!$bound) {
throw new LdapFailedBindException('Failed to perform LDAP bind');
}
} catch (ErrorException $exception) {
Log::error('LDAP bind error: ' . $exception->getMessage());
$lastException = new LdapException('Encountered error during LDAP bind');
continue;
}
$this->connectionCache[$server['host'] . ':' . $server['port']] = $connection;
return $connection;
}
throw $lastException;
}
/**
* Attempt to start a server connection from the provided details.
* @throws LdapException
*/
protected function startServerConnection(string $host, int $port, LdapConfig $config): LdapConnection
{
if (isset($this->connectionCache[$host . ':' . $port])) {
return $this->connectionCache[$host . ':' . $port];
}
/** @var LdapConnection $ldapConnection */
$ldapConnection = app()->make(LdapConnection::class, [$host, $port]);
if (!$ldapConnection) {
throw new LdapException(trans('errors.ldap_cannot_connect'));
}
// Set any required options
if ($config->get('version')) {
$ldapConnection->setVersion($config->get('version'));
}
// Start and verify TLS if it's enabled
if ($config->get('start_tls')) {
try {
$tlsStarted = $ldapConnection->startTls();
} catch (ErrorException $exception) {
$tlsStarted = false;
}
if (!$tlsStarted) {
throw new LdapException('Could not start TLS connection');
}
}
return $ldapConnection;
}
}

View File

@@ -1,12 +1,13 @@
<?php
namespace BookStack\Auth\Access;
namespace BookStack\Auth\Access\Ldap;
use BookStack\Auth\Access\GroupSyncService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LdapFailedBindException;
use BookStack\Uploads\UserAvatars;
use ErrorException;
use Illuminate\Support\Facades\Log;
/**
@@ -15,36 +16,18 @@ use Illuminate\Support\Facades\Log;
*/
class LdapService
{
protected Ldap $ldap;
protected LdapConnectionManager $ldap;
protected GroupSyncService $groupSyncService;
protected UserAvatars $userAvatars;
/**
* @var resource
*/
protected $ldapConnection;
protected LdapConfig $config;
protected array $config;
protected bool $enabled;
/**
* LdapService constructor.
*/
public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
public function __construct(LdapConnectionManager $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
{
$this->ldap = $ldap;
$this->userAvatars = $userAvatars;
$this->groupSyncService = $groupSyncService;
$this->config = config('services.ldap');
$this->enabled = config('auth.method') === 'ldap';
}
/**
* Check if groups should be synced.
*/
public function shouldSyncGroups(): bool
{
return $this->enabled && $this->config['user_to_groups'] !== false;
$this->config = new LdapConfig(config('services.ldap'));
}
/**
@@ -52,10 +35,9 @@ class LdapService
*
* @throws LdapException
*/
private function getUserWithAttributes(string $userName, array $attributes): ?array
protected function getUserWithAttributes(string $userName, array $attributes): ?array
{
$ldapConnection = $this->getConnection();
$this->bindSystemUser($ldapConnection);
$connection = $this->ldap->startSystemBind($this->config);
// Clean attributes
foreach ($attributes as $index => $attribute) {
@@ -65,12 +47,12 @@ class LdapService
}
// Find user
$userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
$baseDn = $this->config['base_dn'];
$userFilter = $this->buildFilter($this->config->get('user_filter'), ['user' => $userName]);
$baseDn = $this->config->get('base_dn');
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
$this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
$users = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $userFilter, $attributes);
$followReferrals = $this->config->get('follow_referrals') ? 1 : 0;
$connection->setOption(LDAP_OPT_REFERRALS, $followReferrals);
$users = $connection->searchAndGetEntries($baseDn, $userFilter, $attributes);
if ($users['count'] === 0) {
return null;
}
@@ -86,10 +68,10 @@ class LdapService
*/
public function getUserDetails(string $userName): ?array
{
$idAttr = $this->config['id_attribute'];
$emailAttr = $this->config['email_attribute'];
$displayNameAttr = $this->config['display_name_attribute'];
$thumbnailAttr = $this->config['thumbnail_attribute'];
$idAttr = $this->config->get('id_attribute');
$emailAttr = $this->config->get('email_attribute');
$displayNameAttr = $this->config->get('display_name_attribute');
$thumbnailAttr = $this->config->get('thumbnail_attribute');
$user = $this->getUserWithAttributes($userName, array_filter([
'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
@@ -108,7 +90,7 @@ class LdapService
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
];
if ($this->config['dump_user_details']) {
if ($this->config->get('dump_user_details')) {
throw new JsonDebugException([
'details_from_ldap' => $user,
'details_bookstack_parsed' => $formatted,
@@ -155,110 +137,15 @@ class LdapService
return false;
}
$ldapConnection = $this->getConnection();
try {
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password);
} catch (ErrorException $e) {
$ldapBind = false;
$this->ldap->startBind($ldapUserDetails['dn'], $password, $this->config);
} catch (LdapFailedBindException $e) {
return false;
} catch (LdapException $e) {
throw $e;
}
return $ldapBind;
}
/**
* Bind the system user to the LDAP connection using the given credentials
* otherwise anonymous access is attempted.
*
* @param resource $connection
*
* @throws LdapException
*/
protected function bindSystemUser($connection)
{
$ldapDn = $this->config['dn'];
$ldapPass = $this->config['pass'];
$isAnonymous = ($ldapDn === false || $ldapPass === false);
if ($isAnonymous) {
$ldapBind = $this->ldap->bind($connection);
} else {
$ldapBind = $this->ldap->bind($connection, $ldapDn, $ldapPass);
}
if (!$ldapBind) {
throw new LdapException(($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed')));
}
}
/**
* Get the connection to the LDAP server.
* Creates a new connection if one does not exist.
*
* @throws LdapException
*
* @return resource
*/
protected function getConnection()
{
if ($this->ldapConnection !== null) {
return $this->ldapConnection;
}
// Check LDAP extension in installed
if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
throw new LdapException(trans('errors.ldap_extension_not_installed'));
}
// Disable certificate verification.
// This option works globally and must be set before a connection is created.
if ($this->config['tls_insecure']) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
}
$serverDetails = $this->parseServerString($this->config['server']);
$ldapConnection = $this->ldap->connect($serverDetails['host'], $serverDetails['port']);
if ($ldapConnection === false) {
throw new LdapException(trans('errors.ldap_cannot_connect'));
}
// Set any required options
if ($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;
return $this->ldapConnection;
}
/**
* Parse a LDAP server string and return the host and port for a connection.
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
*/
protected function parseServerString(string $serverString): array
{
$serverNameParts = explode(':', $serverString);
// If we have a protocol just return the full string since PHP will ignore a separate port.
if ($serverNameParts[0] === 'ldaps' || $serverNameParts[0] === 'ldap') {
return ['host' => $serverString, 'port' => 389];
}
// Otherwise, extract the port out
$hostName = $serverNameParts[0];
$ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
return ['host' => $hostName, 'port' => $ldapPort];
return true;
}
/**
@@ -269,7 +156,7 @@ class LdapService
$newAttrs = [];
foreach ($attrs as $key => $attrText) {
$newKey = '${' . $key . '}';
$newAttrs[$newKey] = $this->ldap->escape($attrText);
$newAttrs[$newKey] = LdapConnection::escape($attrText);
}
return strtr($filterString, $newAttrs);
@@ -283,7 +170,7 @@ class LdapService
*/
public function getUserGroups(string $userName): array
{
$groupsAttr = $this->config['group_attribute'];
$groupsAttr = $this->config->get('group_attribute');
$user = $this->getUserWithAttributes($userName, [$groupsAttr]);
if ($user === null) {
@@ -293,7 +180,7 @@ class LdapService
$userGroups = $this->groupFilter($user);
$allGroups = $this->getGroupsRecursive($userGroups, []);
if ($this->config['dump_user_groups']) {
if ($this->config->get('dump_user_groups')) {
throw new JsonDebugException([
'details_from_ldap' => $user,
'parsed_direct_user_groups' => $userGroups,
@@ -338,17 +225,16 @@ class LdapService
*/
private function getGroupGroups(string $groupName): array
{
$ldapConnection = $this->getConnection();
$this->bindSystemUser($ldapConnection);
$connection = $this->ldap->startSystemBind($this->config);
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
$this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
$followReferrals = $this->config->get('follow_referrals') ? 1 : 0;
$connection->setOption(LDAP_OPT_REFERRALS, $followReferrals);
$baseDn = $this->config['base_dn'];
$groupsAttr = strtolower($this->config['group_attribute']);
$baseDn = $this->config->get('base_dn');
$groupsAttr = strtolower($this->config->get('group_attribute'));
$groupFilter = 'CN=' . $this->ldap->escape($groupName);
$groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $groupFilter, [$groupsAttr]);
$groupFilter = 'CN=' . LdapConnection::escape($groupName);
$groups = $connection->searchAndGetEntries($baseDn, $groupFilter, [$groupsAttr]);
if ($groups['count'] === 0) {
return [];
}
@@ -362,7 +248,7 @@ class LdapService
*/
protected function groupFilter(array $userGroupSearchResponse): array
{
$groupsAttr = strtolower($this->config['group_attribute']);
$groupsAttr = strtolower($this->config->get('group_attribute'));
$ldapGroups = [];
$count = 0;
@@ -371,7 +257,7 @@ class LdapService
}
for ($i = 0; $i < $count; $i++) {
$dnComponents = $this->ldap->explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
$dnComponents = LdapConnection::explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
if (!in_array($dnComponents[0], $ldapGroups)) {
$ldapGroups[] = $dnComponents[0];
}
@@ -389,7 +275,15 @@ class LdapService
public function syncGroups(User $user, string $username)
{
$userLdapGroups = $this->getUserGroups($username);
$this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);
$this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config->get('remove_from_groups'));
}
/**
* Check if groups should be synced.
*/
public function shouldSyncGroups(): bool
{
return $this->config->get('user_to_groups') !== false;
}
/**
@@ -398,7 +292,7 @@ class LdapService
*/
public function saveAndAttachAvatar(User $user, array $ldapUserDetails): void
{
if (is_null(config('services.ldap.thumbnail_attribute')) || is_null($ldapUserDetails['avatar'])) {
if (is_null($this->config->get('thumbnail_attribute')) || is_null($ldapUserDetails['avatar'])) {
return;
}

View File

@@ -67,10 +67,11 @@ class OidcJwtSigningKey
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
}
// 'use' is optional for a JWK but we assume 'sig' where no value exists since that's what
// the OIDC discovery spec infers since 'sig' MUST be set if encryption keys come into play.
$use = $jwk['use'] ?? 'sig';
if ($use !== 'sig') {
if (empty($jwk['use'])) {
throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected');
}
if ($jwk['use'] !== 'sig') {
throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
}

View File

@@ -15,17 +15,40 @@ use Psr\Http\Client\ClientInterface;
*/
class OidcProviderSettings
{
public string $issuer;
public string $clientId;
public string $clientSecret;
public ?string $redirectUri;
public ?string $authorizationEndpoint;
public ?string $tokenEndpoint;
/**
* @var string
*/
public $issuer;
/**
* @var string
*/
public $clientId;
/**
* @var string
*/
public $clientSecret;
/**
* @var string
*/
public $redirectUri;
/**
* @var string
*/
public $authorizationEndpoint;
/**
* @var string
*/
public $tokenEndpoint;
/**
* @var string[]|array[]
*/
public ?array $keys = [];
public $keys = [];
public function __construct(array $settings)
{
@@ -141,10 +164,9 @@ class OidcProviderSettings
protected function filterKeys(array $keys): array
{
return array_filter($keys, function (array $key) {
$alg = $key['alg'] ?? 'RS256';
$use = $key['use'] ?? 'sig';
$alg = $key['alg'] ?? null;
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
});
}

View File

@@ -52,6 +52,7 @@ class OidcService
{
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
return [
'url' => $provider->getAuthorizationUrl(),
'state' => $provider->getState(),
@@ -198,8 +199,7 @@ class OidcService
*/
protected function getUserDetails(OidcIdToken $token): array
{
$idClaim = $this->config()['external_id_claim'];
$id = $token->getClaim($idClaim);
$id = $token->getClaim('sub');
return [
'external_id' => $id,

View File

@@ -1,141 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
class EntityPermissionEvaluator
{
protected string $action;
public function __construct(string $action)
{
$this->action = $action;
}
public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool
{
if ($this->isUserSystemAdmin($userRoleIds)) {
return true;
}
$typeIdChain = $this->gatherEntityChainTypeIds(SimpleEntityData::fromEntity($entity));
$relevantPermissions = $this->getPermissionsMapByTypeId($typeIdChain, [...$userRoleIds, 0]);
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
$status = $this->evaluatePermitsByType($permitsByType);
return is_null($status) ? null : $status === PermissionStatus::IMPLICIT_ALLOW || $status === PermissionStatus::EXPLICIT_ALLOW;
}
/**
* @param array<string, array<string, int>> $permitsByType
*/
protected function evaluatePermitsByType(array $permitsByType): ?int
{
// Return grant or reject from role-level if exists
if (count($permitsByType['role']) > 0) {
return max($permitsByType['role']) ? PermissionStatus::EXPLICIT_ALLOW : PermissionStatus::EXPLICIT_DENY;
}
// Return fallback permission if exists
if (count($permitsByType['fallback']) > 0) {
return $permitsByType['fallback'][0] ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
}
return null;
}
/**
* @param string[] $typeIdChain
* @param array<string, EntityPermission[]> $permissionMapByTypeId
* @return array<string, array<string, int>>
*/
protected function collapseAndCategorisePermissions(array $typeIdChain, array $permissionMapByTypeId): array
{
$permitsByType = ['fallback' => [], 'role' => []];
foreach ($typeIdChain as $typeId) {
$permissions = $permissionMapByTypeId[$typeId] ?? [];
foreach ($permissions as $permission) {
$roleId = $permission->role_id;
$type = $roleId === 0 ? 'fallback' : 'role';
if (!isset($permitsByType[$type][$roleId])) {
$permitsByType[$type][$roleId] = $permission->{$this->action};
}
}
if (isset($permitsByType['fallback'][0])) {
break;
}
}
return $permitsByType;
}
/**
* @param string[] $typeIdChain
* @return array<string, EntityPermission[]>
*/
protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): array
{
$query = EntityPermission::query()->where(function (Builder $query) use ($typeIdChain) {
foreach ($typeIdChain as $typeId) {
$query->orWhere(function (Builder $query) use ($typeId) {
[$type, $id] = explode(':', $typeId);
$query->where('entity_type', '=', $type)
->where('entity_id', '=', $id);
});
}
});
if (!empty($filterRoleIds)) {
$query->where(function (Builder $query) use ($filterRoleIds) {
$query->whereIn('role_id', [...$filterRoleIds, 0]);
});
}
$relevantPermissions = $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
$map = [];
foreach ($relevantPermissions as $permission) {
$key = $permission->entity_type . ':' . $permission->entity_id;
if (!isset($map[$key])) {
$map[$key] = [];
}
$map[$key][] = $permission;
}
return $map;
}
/**
* @return string[]
*/
protected function gatherEntityChainTypeIds(SimpleEntityData $entity): array
{
// The array order here is very important due to the fact we walk up the chain
// elsewhere in the class. Earlier items in the chain have higher priority.
$chain = [$entity->type . ':' . $entity->id];
if ($entity->type === 'page' && $entity->chapter_id) {
$chain[] = 'chapter:' . $entity->chapter_id;
}
if ($entity->type === 'page' || $entity->type === 'chapter') {
$chain[] = 'book:' . $entity->book_id;
}
return $chain;
}
protected function isUserSystemAdmin($userRoleIds): bool
{
$adminRoleId = Role::getSystemRole('admin')->id;
return in_array($adminRoleId, $userRoleIds);
}
}

View File

@@ -19,6 +19,11 @@ use Illuminate\Support\Facades\DB;
*/
class JointPermissionBuilder
{
/**
* @var array<string, array<int, SimpleEntityData>>
*/
protected $entityCache;
/**
* Re-generate all entity permission from scratch.
*/
@@ -93,6 +98,40 @@ class JointPermissionBuilder
});
}
/**
* Prepare the local entity cache and ensure it's empty.
*
* @param SimpleEntityData[] $entities
*/
protected function readyEntityCache(array $entities)
{
$this->entityCache = [];
foreach ($entities as $entity) {
if (!isset($this->entityCache[$entity->type])) {
$this->entityCache[$entity->type] = [];
}
$this->entityCache[$entity->type][$entity->id] = $entity;
}
}
/**
* Get a book via ID, Checks local cache.
*/
protected function getBook(int $bookId): SimpleEntityData
{
return $this->entityCache['book'][$bookId];
}
/**
* Get a chapter via ID, Checks local cache.
*/
protected function getChapter(int $chapterId): SimpleEntityData
{
return $this->entityCache['chapter'][$chapterId];
}
/**
* Get a query for fetching a book with its children.
*/
@@ -101,7 +140,6 @@ class JointPermissionBuilder
return Book::query()->withTrashed()
->select(['id', 'owned_by'])->with([
'chapters' => function ($query) {
$query->withTrashed()->select(['id', 'owned_by', 'book_id']);
},
'pages' => function ($query) {
$query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
@@ -175,7 +213,13 @@ class JointPermissionBuilder
$simpleEntities = [];
foreach ($entities as $entity) {
$simple = SimpleEntityData::fromEntity($entity);
$attrs = $entity->getAttributes();
$simple = new SimpleEntityData();
$simple->id = $attrs['id'];
$simple->type = $entity->getMorphClass();
$simple->owned_by = $attrs['owned_by'] ?? 0;
$simple->book_id = $attrs['book_id'] ?? null;
$simple->chapter_id = $attrs['chapter_id'] ?? null;
$simpleEntities[] = $simple;
}
@@ -185,16 +229,24 @@ class JointPermissionBuilder
/**
* Create & Save entity jointPermissions for many entities and roles.
*
* @param Entity[] $originalEntities
* @param Entity[] $entities
* @param Role[] $roles
*/
protected function createManyJointPermissions(array $originalEntities, array $roles)
{
$entities = $this->entitiesToSimpleEntities($originalEntities);
$this->readyEntityCache($entities);
$jointPermissions = [];
// Fetch related entity permissions
$permissions = new MassEntityPermissionEvaluator($entities, 'view');
$permissions = $this->getEntityPermissionsForEntities($entities);
// Create a mapping of explicit entity permissions
$permissionMap = [];
foreach ($permissions as $permission) {
$key = $permission->entity_type . ':' . $permission->entity_id . ':' . $permission->role_id;
$permissionMap[$key] = $permission->view;
}
// Create a mapping of role permissions
$rolePermissionMap = [];
@@ -207,14 +259,13 @@ class JointPermissionBuilder
// Create Joint Permission Data
foreach ($entities as $entity) {
foreach ($roles as $role) {
$jp = $this->createJointPermissionData(
$jointPermissions[] = $this->createJointPermissionData(
$entity,
$role->getRawAttribute('id'),
$permissions,
$permissionMap,
$rolePermissionMap,
$role->system_name === 'admin'
);
$jointPermissions[] = $jp;
}
}
@@ -248,45 +299,109 @@ class JointPermissionBuilder
return $idsByType;
}
/**
* Get the entity permissions for all the given entities.
*
* @param SimpleEntityData[] $entities
*
* @return EntityPermission[]
*/
protected function getEntityPermissionsForEntities(array $entities): array
{
$idsByType = $this->entitiesToTypeIdMap($entities);
$permissionFetch = EntityPermission::query()
->where(function (Builder $query) use ($idsByType) {
foreach ($idsByType as $type => $ids) {
$query->orWhere(function (Builder $query) use ($type, $ids) {
$query->where('entity_type', '=', $type)->whereIn('entity_id', $ids);
});
}
});
return $permissionFetch->get()->all();
}
/**
* Create entity permission data for an entity and role
* for a particular action.
*/
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, MassEntityPermissionEvaluator $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, array $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
{
// Ensure system admin role retains permissions
if ($isAdminRole) {
return $this->createJointPermissionDataArray($entity, $roleId, PermissionStatus::EXPLICIT_ALLOW, true);
}
// Return evaluated entity permission status if it has an affect.
$entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId);
if ($entityPermissionStatus !== null) {
return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, false);
}
// Otherwise default to the role-level permissions
$permissionPrefix = $entity->type . '-view';
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
$status = $roleHasPermission ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
return $this->createJointPermissionDataArray($entity, $roleId, $status, $roleHasPermissionOwn);
if ($isAdminRole) {
return $this->createJointPermissionDataArray($entity, $roleId, true, true);
}
if ($this->entityPermissionsActiveForRole($permissionMap, $entity, $roleId)) {
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId);
return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess);
}
if ($entity->type === 'book' || $entity->type === 'bookshelf') {
return $this->createJointPermissionDataArray($entity, $roleId, $roleHasPermission, $roleHasPermissionOwn);
}
// For chapters and pages, Check if explicit permissions are set on the Book.
$book = $this->getBook($entity->book_id);
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId);
$hasPermissiveAccessToParents = !$this->entityPermissionsActiveForRole($permissionMap, $book, $roleId);
// For pages with a chapter, Check if explicit permissions are set on the Chapter
if ($entity->type === 'page' && $entity->chapter_id !== 0) {
$chapter = $this->getChapter($entity->chapter_id);
$chapterRestricted = $this->entityPermissionsActiveForRole($permissionMap, $chapter, $roleId);
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapterRestricted;
if ($chapterRestricted) {
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
}
}
return $this->createJointPermissionDataArray(
$entity,
$roleId,
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
);
}
/**
* Check if entity permissions are defined within the given map, for the given entity and role.
* Checks for the default `role_id=0` backup option as a fallback.
*/
protected function entityPermissionsActiveForRole(array $permissionMap, SimpleEntityData $entity, int $roleId): bool
{
$keyPrefix = $entity->type . ':' . $entity->id . ':';
return isset($permissionMap[$keyPrefix . $roleId]) || isset($permissionMap[$keyPrefix . '0']);
}
/**
* Check for an active restriction in an entity map.
*/
protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
{
$roleKey = $entity->type . ':' . $entity->id . ':' . $roleId;
$defaultKey = $entity->type . ':' . $entity->id . ':0';
return $entityMap[$roleKey] ?? $entityMap[$defaultKey] ?? false;
}
/**
* Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion.
*/
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, int $permissionStatus, bool $hasPermissionOwn): array
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, bool $permissionAll, bool $permissionOwn): array
{
$ownPermissionActive = ($hasPermissionOwn && $permissionStatus !== PermissionStatus::EXPLICIT_DENY && $entity->owned_by);
return [
'entity_id' => $entity->id,
'entity_type' => $entity->type,
'role_id' => $roleId,
'status' => $permissionStatus,
'owner_id' => $ownPermissionActive ? $entity->owned_by : null,
'entity_id' => $entity->id,
'entity_type' => $entity->type,
'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn,
'owned_by' => $entity->owned_by,
'role_id' => $roleId,
];
}
}

View File

@@ -1,81 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
class MassEntityPermissionEvaluator extends EntityPermissionEvaluator
{
/**
* @var SimpleEntityData[]
*/
protected array $entitiesInvolved;
protected array $permissionMapCache;
public function __construct(array $entitiesInvolved, string $action)
{
$this->entitiesInvolved = $entitiesInvolved;
parent::__construct($action);
}
public function evaluateEntityForRole(SimpleEntityData $entity, int $roleId): ?int
{
$typeIdChain = $this->gatherEntityChainTypeIds($entity);
$relevantPermissions = $this->getPermissionMapByTypeIdForChainAndRole($typeIdChain, $roleId);
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
return $this->evaluatePermitsByType($permitsByType);
}
/**
* @param string[] $typeIdChain
* @return array<string, EntityPermission[]>
*/
protected function getPermissionMapByTypeIdForChainAndRole(array $typeIdChain, int $roleId): array
{
$allPermissions = $this->getPermissionMapByTypeIdAndRoleForAllInvolved();
$relevantPermissions = [];
// Filter down permissions to just those for current typeId
// and current roleID or fallback permissions.
foreach ($typeIdChain as $typeId) {
$relevantPermissions[$typeId] = [
...($allPermissions[$typeId][$roleId] ?? []),
...($allPermissions[$typeId][0] ?? [])
];
}
return $relevantPermissions;
}
/**
* @return array<string, array<int, EntityPermission[]>>
*/
protected function getPermissionMapByTypeIdAndRoleForAllInvolved(): array
{
if (isset($this->permissionMapCache)) {
return $this->permissionMapCache;
}
$entityTypeIdChain = [];
foreach ($this->entitiesInvolved as $entity) {
$entityTypeIdChain[] = $entity->type . ':' . $entity->id;
}
$permissionMap = $this->getPermissionsMapByTypeId($entityTypeIdChain, []);
// Manipulate permission map to also be keyed by roleId.
foreach ($permissionMap as $typeId => $permissions) {
$permissionMap[$typeId] = [];
foreach ($permissions as $permission) {
$roleId = $permission->getRawAttribute('role_id');
if (!isset($permissionMap[$typeId][$roleId])) {
$permissionMap[$typeId][$roleId] = [];
}
$permissionMap[$typeId][$roleId][] = $permission;
}
}
$this->permissionMapCache = $permissionMap;
return $this->permissionMapCache;
}
}

View File

@@ -4,6 +4,7 @@ namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Model;
@@ -60,7 +61,46 @@ class PermissionApplicator
{
$this->ensureValidEntityAction($action);
return (new EntityPermissionEvaluator($action))->evaluateEntityForUser($entity, $userRoleIds);
$adminRoleId = Role::getSystemRole('admin')->id;
if (in_array($adminRoleId, $userRoleIds)) {
return true;
}
// The chain order here is very important due to the fact we walk up the chain
// in the loop below. Earlier items in the chain have higher priority.
$chain = [$entity];
if ($entity instanceof Page && $entity->chapter_id) {
$chain[] = $entity->chapter;
}
if ($entity instanceof Page || $entity instanceof Chapter) {
$chain[] = $entity->book;
}
foreach ($chain as $currentEntity) {
$allowedByRoleId = $currentEntity->permissions()
->whereIn('role_id', [0, ...$userRoleIds])
->pluck($action, 'role_id');
// Continue up the chain if no applicable entity permission overrides.
if ($allowedByRoleId->isEmpty()) {
continue;
}
// If we have user-role-specific permissions set, allow if any of those
// role permissions allow access.
$hasDefault = $allowedByRoleId->has(0);
if (!$hasDefault || $allowedByRoleId->count() > 1) {
return $allowedByRoleId->search(function (bool $allowed, int $roleId) {
return $roleId !== 0 && $allowed;
}) !== false;
}
// Otherwise, return the default "Other roles" fallback value.
return $allowedByRoleId->get(0);
}
return null;
}
/**
@@ -94,12 +134,10 @@ class PermissionApplicator
{
return $query->where(function (Builder $parentQuery) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) {
$permissionQuery->select(['entity_id', 'entity_type'])
->selectRaw('max(owner_id) as owner_id')
->selectRaw('max(status) as status')
->whereIn('role_id', $this->getCurrentUserRoleIds())
->groupBy(['entity_type', 'entity_id'])
->havingRaw('(status IN (1, 3) or (owner_id = ? and status != 2))', [$this->currentUser()->id]);
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoleIds())
->where(function (Builder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
});
}
@@ -123,23 +161,35 @@ class PermissionApplicator
* Filter items that have entities set as a polymorphic relation.
* For simplicity, this will not return results attached to draft pages.
* Draft pages should never really have related items though.
*
* @param Builder|QueryBuilder $query
*/
public function restrictEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder
public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn)
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$pageMorphClass = (new Page())->getMorphClass();
return $this->restrictEntityQuery($query)
->where(function ($query) use ($tableDetails, $pageMorphClass) {
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails) {
/** @var Builder $permissionQuery */
$permissionQuery->select(['role_id'])->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
})->where(function ($query) use ($tableDetails, $pageMorphClass) {
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
->where('pages.draft', '=', false);
});
});
});
return $q;
}
/**
@@ -151,15 +201,49 @@ class PermissionApplicator
public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
{
$fullPageIdColumn = $tableName . '.' . $pageIdColumn;
return $this->restrictEntityQuery($query)
->where(function ($query) use ($fullPageIdColumn) {
/** @var Builder $query */
$query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where('pages.draft', '=', false);
$morphClass = (new Page())->getMorphClass();
$existsQuery = function ($permissionQuery) use ($fullPageIdColumn, $morphClass) {
/** @var Builder $permissionQuery */
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $fullPageIdColumn)
->where('joint_permissions.entity_type', '=', $morphClass)
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
};
$q = $query->where(function ($query) use ($existsQuery, $fullPageIdColumn) {
$query->whereExists($existsQuery)
->orWhere($fullPageIdColumn, '=', 0);
});
// Prevent visibility of non-owned draft pages
$q->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where(function (QueryBuilder $query) {
$query->where('pages.draft', '=', false)
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
});
});
return $q;
}
/**
* Add the query for checking the given user id has permission
* within the join_permissions table.
*
* @param QueryBuilder|Builder $query
*/
protected function addJointHasPermissionCheck($query, int $userIdToCheck)
{
$query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
$query->where('joint_permissions.has_permission_own', '=', true)
->where('joint_permissions.owned_by', '=', $userIdToCheck);
});
}
/**

View File

@@ -1,11 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
class PermissionStatus
{
const IMPLICIT_DENY = 0;
const IMPLICIT_ALLOW = 1;
const EXPLICIT_DENY = 2;
const EXPLICIT_ALLOW = 3;
}

View File

@@ -2,8 +2,6 @@
namespace BookStack\Auth\Permissions;
use BookStack\Entities\Models\Entity;
class SimpleEntityData
{
public int $id;
@@ -11,18 +9,4 @@ class SimpleEntityData
public int $owned_by;
public ?int $book_id;
public ?int $chapter_id;
public static function fromEntity(Entity $entity): self
{
$attrs = $entity->getAttributes();
$simple = new self();
$simple->id = $attrs['id'];
$simple->type = $entity->getMorphClass();
$simple->owned_by = $attrs['owned_by'] ?? 0;
$simple->book_id = $attrs['book_id'] ?? null;
$simple->chapter_id = $attrs['chapter_id'] ?? null;
return $simple;
}
}

View File

@@ -3,7 +3,6 @@
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use BookStack\Util\SimpleListOptions;
use Illuminate\Pagination\LengthAwarePaginator;
/**
@@ -12,23 +11,23 @@ use Illuminate\Pagination\LengthAwarePaginator;
* user is assumed to be trusted. (Admin users).
* Email search can be abused to extract email addresses.
*/
class UsersAllPaginatedAndSorted
class AllUsersPaginatedAndSorted
{
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
/**
* @param array{sort: string, order: string, search: string} $sortData
*/
public function run(int $count, array $sortData): LengthAwarePaginator
{
$sort = $listOptions->getSort();
if ($sort === 'created_at') {
$sort = 'users.created_at';
}
$sort = $sortData['sort'];
$query = User::query()->select(['*'])
->scopes(['withLastActivityAt'])
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $listOptions->getOrder());
->orderBy($sort, $sortData['order']);
if ($listOptions->getSearch()) {
$term = '%' . $listOptions->getSearch() . '%';
if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%';
$query->where(function ($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('email', 'like', $term);

View File

@@ -1,35 +0,0 @@
<?php
namespace BookStack\Auth\Queries;
use BookStack\Auth\Role;
use BookStack\Util\SimpleListOptions;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Get all the roles in the system in a paginated format.
*/
class RolesAllPaginatedAndSorted
{
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
{
$sort = $listOptions->getSort();
if ($sort === 'created_at') {
$sort = 'users.created_at';
}
$query = Role::query()->select(['*'])
->withCount(['users', 'permissions'])
->orderBy($sort, $listOptions->getOrder());
if ($listOptions->getSearch()) {
$term = '%' . $listOptions->getSearch() . '%';
$query->where(function ($query) use ($term) {
$query->where('display_name', 'like', $term)
->orWhere('description', 'like', $term);
});
}
return $query->paginate($count);
}
}

View File

@@ -110,6 +110,14 @@ class Role extends Model implements Loggable
return static::query()->where('system_name', '=', $systemName)->first();
}
/**
* Get all visible roles.
*/
public static function visible(): Collection
{
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
}
/**
* {@inheritdoc}
*/

View File

@@ -200,7 +200,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
public function attachRole(Role $role)
{
$this->roles()->attach($role->id);
$this->unsetRelation('roles');
}
/**

View File

@@ -158,9 +158,6 @@ class UserRepo
// Delete user profile images
$this->userAvatar->destroyAllForUser($user);
// Delete related activities
setting()->deleteUserSettings($user->id);
if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
@@ -234,8 +231,6 @@ class UserRepo
*/
protected function setUserRoles(User $user, array $roles)
{
$roles = array_filter(array_values($roles));
if ($this->demotingLastAdmin($user, $roles)) {
throw new UserUpdateException(trans('errors.role_cannot_remove_only_admin'), $user->getEditUrl());
}

View File

@@ -75,7 +75,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ka', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
// Application Fallback Locale
'fallback_locale' => 'en',

View File

@@ -8,12 +8,9 @@ return [
// Dump user details after a login request for debugging purposes
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
// Claim, within an OpenId token, to find the user's display name
// Attribute, within a OpenId token, to find the user's display name
'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
// Claim, within an OpenID token, to use to connect a BookStack user to the OIDC user.
'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'),
// OAuth2/OpenId client id, as configured in your Authorization server.
'client_id' => env('OIDC_CLIENT_ID', null),

View File

@@ -16,27 +16,16 @@ return [
'app-editor' => 'wysiwyg',
'app-color' => '#206ea7',
'app-color-light' => 'rgba(32,110,167,0.15)',
'link-color' => '#206ea7',
'bookshelf-color' => '#a94747',
'book-color' => '#077b70',
'chapter-color' => '#af4d0d',
'page-color' => '#206ea7',
'page-draft-color' => '#7e50b1',
'app-color-dark' => '#195785',
'app-color-light-dark' => 'rgba(32,110,167,0.15)',
'link-color-dark' => '#429fe3',
'bookshelf-color-dark' => '#ff5454',
'book-color-dark' => '#389f60',
'chapter-color-dark' => '#ee7a2d',
'page-color-dark' => '#429fe3',
'page-draft-color-dark' => '#a66ce8',
'app-custom-head' => false,
'registration-enabled' => false,
// User-level default settings
'user' => [
'ui-shortcuts' => '{}',
'ui-shortcuts-enabled' => false,
'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'),

View File

@@ -88,6 +88,8 @@ class Page extends BookChild
/**
* Get the current revision for the page if existing.
*
* @return PageRevision|null
*/
public function currentRevision(): HasOne
{

View File

@@ -87,14 +87,14 @@ class BaseRepo
{
if ($coverImage) {
$imageType = $entity->coverImageTypeKey();
$this->imageRepo->destroyImage($entity->cover()->first());
$this->imageRepo->destroyImage($entity->cover);
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
$entity->cover()->associate($image);
$entity->save();
}
if ($removeImage) {
$this->imageRepo->destroyImage($entity->cover()->first());
$this->imageRepo->destroyImage($entity->cover);
$entity->image_id = 0;
$entity->save();
}

View File

@@ -181,7 +181,7 @@ class BookContents
$model->changeBook($newBook->id);
}
if ($model instanceof Page && $chapterChanged) {
if ($chapterChanged) {
$model->chapter_id = $newChapter->id ?? 0;
}
@@ -235,7 +235,7 @@ class BookContents
}
$hasPageEditPermission = userCan('page-update', $model);
$newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
$newParentInRightLocation = ($newParent instanceof Book || $newParent->book_id === $newBook->id);
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasNewParentPermission = userCan($newParentPermission, $newParent);

View File

@@ -7,7 +7,6 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo;
@@ -110,11 +109,9 @@ class Cloner
$inputData['tags'] = $this->entityTagsToInputArray($entity);
// Add a cover to the data if existing on the original entity
if ($entity instanceof HasCoverImage) {
$cover = $entity->cover()->first();
if ($cover) {
$inputData['image'] = $this->imageToUploadedFile($cover);
}
if ($entity->cover instanceof Image) {
$uploadedFile = $this->imageToUploadedFile($entity->cover);
$inputData['image'] = $uploadedFile;
}
return $inputData;

View File

@@ -5,10 +5,10 @@ namespace BookStack\Entities\Tools\Markdown;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;
class MarkdownToHtml
{
@@ -26,7 +26,7 @@ class MarkdownToHtml
$environment->addExtension(new TaskListExtension());
$environment->addExtension(new CustomStrikeThroughExtension());
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
$converter = new MarkdownConverter($environment);
$converter = new CommonMarkConverter([], $environment);
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);

View File

@@ -0,0 +1,7 @@
<?php
namespace BookStack\Exceptions;
class LdapFailedBindException extends LdapException
{
}

View File

@@ -3,8 +3,6 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\Activity;
use BookStack\Actions\ActivityType;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -15,15 +13,10 @@ class AuditLogController extends Controller
$this->checkPermission('settings-manage');
$this->checkPermission('users-manage');
$sort = $request->get('sort', 'activity_date');
$order = $request->get('order', 'desc');
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
'created_at' => trans('settings.audit_table_date'),
'type' => trans('settings.audit_table_event'),
]);
$filters = [
$listDetails = [
'order' => $request->get('order', 'desc'),
'event' => $request->get('event', ''),
'sort' => $request->get('sort', 'created_at'),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'user' => $request->get('user', ''),
@@ -32,38 +25,39 @@ class AuditLogController extends Controller
$query = Activity::query()
->with([
'entity' => fn ($query) => $query->withTrashed(),
'entity' => function ($query) {
$query->withTrashed();
},
'user',
])
->orderBy($listOptions->getSort(), $listOptions->getOrder());
->orderBy($listDetails['sort'], $listDetails['order']);
if ($filters['event']) {
$query->where('type', '=', $filters['event']);
if ($listDetails['event']) {
$query->where('type', '=', $listDetails['event']);
}
if ($filters['user']) {
$query->where('user_id', '=', $filters['user']);
if ($listDetails['user']) {
$query->where('user_id', '=', $listDetails['user']);
}
if ($filters['date_from']) {
$query->where('created_at', '>=', $filters['date_from']);
if ($listDetails['date_from']) {
$query->where('created_at', '>=', $listDetails['date_from']);
}
if ($filters['date_to']) {
$query->where('created_at', '<=', $filters['date_to']);
if ($listDetails['date_to']) {
$query->where('created_at', '<=', $listDetails['date_to']);
}
if ($filters['ip']) {
$query->where('ip', 'like', $filters['ip'] . '%');
if ($listDetails['ip']) {
$query->where('ip', 'like', $listDetails['ip'] . '%');
}
$activities = $query->paginate(100);
$activities->appends($request->all());
$activities->appends($listDetails);
$types = ActivityType::all();
$types = DB::table('activities')->select('type')->distinct()->pluck('type');
$this->setPageTitle(trans('settings.audit'));
return view('settings.audit', [
'activities' => $activities,
'filters' => $filters,
'listOptions' => $listOptions,
'listDetails' => $listDetails,
'activityTypes' => $types,
]);
}

View File

@@ -51,28 +51,14 @@ class ConfirmEmailController extends Controller
return view('auth.user-unconfirmed', ['user' => $user]);
}
/**
* Show the form for a user to provide their positive confirmation of their email.
*/
public function showAcceptForm(string $token)
{
return view('auth.register-confirm-accept', ['token' => $token]);
}
/**
* Confirms an email via a token and logs the user into the system.
*
* @throws ConfirmationEmailException
* @throws Exception
*/
public function confirm(Request $request)
public function confirm(string $token)
{
$validated = $this->validate($request, [
'token' => ['required', 'string']
]);
$token = $validated['token'];
try {
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
} catch (UserTokenNotFoundException $exception) {

View File

@@ -15,7 +15,6 @@ use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
@@ -36,16 +35,13 @@ class BookController extends Controller
/**
* Display a listing of the book.
*/
public function index(Request $request)
public function index()
{
$view = setting()->getForCurrentUser('books_view_type');
$listOptions = SimpleListOptions::fromRequest($request, 'books')->withSortOptions([
'name' => trans('common.sort_name'),
'created_at' => trans('common.sort_created_at'),
'updated_at' => trans('common.sort_updated_at'),
]);
$sort = setting()->getForCurrentUser('books_sort', 'name');
$order = setting()->getForCurrentUser('books_sort_order', 'asc');
$books = $this->bookRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
$books = $this->bookRepo->getAllPaginated(18, $sort, $order);
$recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
$popular = $this->bookRepo->getPopular(4);
$new = $this->bookRepo->getRecentlyCreated(4);
@@ -60,7 +56,8 @@ class BookController extends Controller
'popular' => $popular,
'new' => $new,
'view' => $view,
'listOptions' => $listOptions,
'sort' => $sort,
'order' => $order,
]);
}

View File

@@ -10,7 +10,6 @@ use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\SimpleListOptions;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -31,16 +30,18 @@ class BookshelfController extends Controller
/**
* Display a listing of the book.
*/
public function index(Request $request)
public function index()
{
$view = setting()->getForCurrentUser('bookshelves_view_type');
$listOptions = SimpleListOptions::fromRequest($request, 'bookshelves')->withSortOptions([
$sort = setting()->getForCurrentUser('bookshelves_sort', 'name');
$order = setting()->getForCurrentUser('bookshelves_sort_order', 'asc');
$sortOptions = [
'name' => trans('common.sort_name'),
'created_at' => trans('common.sort_created_at'),
'updated_at' => trans('common.sort_updated_at'),
]);
];
$shelves = $this->shelfRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
$shelves = $this->shelfRepo->getAllPaginated(18, $sort, $order);
$recents = $this->isSignedIn() ? $this->shelfRepo->getRecentlyViewed(4) : false;
$popular = $this->shelfRepo->getPopular(4);
$new = $this->shelfRepo->getRecentlyCreated(4);
@@ -54,7 +55,9 @@ class BookshelfController extends Controller
'popular' => $popular,
'new' => $new,
'view' => $view,
'listOptions' => $listOptions,
'sort' => $sort,
'order' => $order,
'sortOptions' => $sortOptions,
]);
}
@@ -97,21 +100,16 @@ class BookshelfController extends Controller
*
* @throws NotFoundException
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
public function show(ActivityQueries $activities, string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-view', $shelf);
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
'default' => trans('common.sort_default'),
'name' => trans('common.sort_name'),
'created_at' => trans('common.sort_created_at'),
'updated_at' => trans('common.sort_updated_at'),
]);
$sort = setting()->getForCurrentUser('shelf_books_sort', 'default');
$order = setting()->getForCurrentUser('shelf_books_sort_order', 'asc');
$sort = $listOptions->getSort();
$sortedVisibleShelfBooks = $shelf->visibleBooks()->get()
->sortBy($sort === 'default' ? 'pivot.order' : $sort, SORT_REGULAR, $listOptions->getOrder() === 'desc')
->sortBy($sort === 'default' ? 'pivot.order' : $sort, SORT_REGULAR, $order === 'desc')
->values()
->all();
@@ -126,7 +124,8 @@ class BookshelfController extends Controller
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
'view' => $view,
'activity' => $activities->entityActivity($shelf, 20, 1),
'listOptions' => $listOptions,
'order' => $order,
'sort' => $sort,
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
]);
}

View File

@@ -10,15 +10,13 @@ use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Tools\PageContent;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
class HomeController extends Controller
{
/**
* Display the homepage.
*/
public function index(Request $request, ActivityQueries $activities)
public function index(ActivityQueries $activities)
{
$activity = $activities->latest(10);
$draftPages = [];
@@ -63,27 +61,33 @@ class HomeController extends Controller
if ($homepageOption === 'bookshelves' || $homepageOption === 'books') {
$key = $homepageOption;
$view = setting()->getForCurrentUser($key . '_view_type');
$listOptions = SimpleListOptions::fromRequest($request, $key)->withSortOptions([
'name' => trans('common.sort_name'),
$sort = setting()->getForCurrentUser($key . '_sort', 'name');
$order = setting()->getForCurrentUser($key . '_sort_order', 'asc');
$sortOptions = [
'name' => trans('common.sort_name'),
'created_at' => trans('common.sort_created_at'),
'updated_at' => trans('common.sort_updated_at'),
]);
];
$commonData = array_merge($commonData, [
'view' => $view,
'listOptions' => $listOptions,
'sort' => $sort,
'order' => $order,
'sortOptions' => $sortOptions,
]);
}
if ($homepageOption === 'bookshelves') {
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['sort'], $commonData['order']);
$data = array_merge($commonData, ['shelves' => $shelves]);
return view('home.shelves', $data);
}
if ($homepageOption === 'books') {
$books = app(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$bookRepo = app(BookRepo::class);
$books = $bookRepo->getAllPaginated(18, $commonData['sort'], $commonData['order']);
$data = array_merge($commonData, ['books' => $books]);
return view('home.books', $data);

View File

@@ -66,19 +66,14 @@ class DrawioImageController extends Controller
*/
public function getAsBase64($id)
{
try {
$image = $this->imageRepo->getById($id);
} catch (Exception $exception) {
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
}
if ($image->type !== 'drawio' || !userCan('page-view', $image->getPage())) {
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
$image = $this->imageRepo->getById($id);
if (is_null($image) || $image->type !== 'drawio' || !userCan('page-view', $image->getPage())) {
return $this->jsonError('Image data could not be found');
}
$imageData = $this->imageRepo->getImageData($image);
if (is_null($imageData)) {
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
return $this->jsonError('Image data could not be found');
}
return response()->json([

View File

@@ -3,13 +3,10 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\PageRevision;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\PageContent;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
use Ssddanbrown\HtmlDiff\Diff;
class PageRevisionController extends Controller
@@ -26,29 +23,22 @@ class PageRevisionController extends Controller
*
* @throws NotFoundException
*/
public function index(Request $request, string $bookSlug, string $pageSlug)
public function index(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
'id' => trans('entities.pages_revisions_sort_number')
]);
$revisions = $page->revisions()->select([
'id', 'page_id', 'name', 'created_at', 'created_by', 'updated_at',
'type', 'revision_number', 'summary',
])
'id', 'page_id', 'name', 'created_at', 'created_by', 'updated_at',
'type', 'revision_number', 'summary',
])
->selectRaw("IF(markdown = '', false, true) as is_markdown")
->with(['page.book', 'createdBy'])
->reorder('id', $listOptions->getOrder())
->reorder('created_at', $listOptions->getOrder())
->paginate(50);
->get();
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName' => $page->getShortName()]));
return view('pages.revisions', [
'revisions' => $revisions,
'page' => $page,
'listOptions' => $listOptions,
'revisions' => $revisions,
'page' => $page,
]);
}
@@ -60,7 +50,6 @@ class PageRevisionController extends Controller
public function show(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
/** @var ?PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
if ($revision === null) {
throw new NotFoundException();
@@ -89,7 +78,6 @@ class PageRevisionController extends Controller
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
/** @var ?PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
if ($revision === null) {
throw new NotFoundException();

View File

@@ -3,18 +3,19 @@
namespace BookStack\Http\Controllers;
use BookStack\Auth\Permissions\PermissionsRepo;
use BookStack\Auth\Queries\RolesAllPaginatedAndSorted;
use BookStack\Auth\Role;
use BookStack\Exceptions\PermissionsException;
use BookStack\Util\SimpleListOptions;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class RoleController extends Controller
{
protected PermissionsRepo $permissionsRepo;
protected $permissionsRepo;
/**
* PermissionController constructor.
*/
public function __construct(PermissionsRepo $permissionsRepo)
{
$this->permissionsRepo = $permissionsRepo;
@@ -23,27 +24,14 @@ class RoleController extends Controller
/**
* Show a listing of the roles in the system.
*/
public function index(Request $request)
public function index()
{
$this->checkPermission('user-roles-manage');
$listOptions = SimpleListOptions::fromRequest($request, 'roles')->withSortOptions([
'display_name' => trans('common.sort_name'),
'users_count' => trans('settings.roles_assigned_users'),
'permissions_count' => trans('settings.roles_permissions_provided'),
'created_at' => trans('common.sort_created_at'),
'updated_at' => trans('common.sort_updated_at'),
]);
$roles = (new RolesAllPaginatedAndSorted())->run(20, $listOptions);
$roles->appends($listOptions->getPaginationAppends());
$roles = $this->permissionsRepo->getAllRoles();
$this->setPageTitle(trans('settings.roles'));
return view('settings.roles.index', [
'roles' => $roles,
'listOptions' => $listOptions,
]);
return view('settings.roles.index', ['roles' => $roles]);
}
/**
@@ -87,11 +75,16 @@ class RoleController extends Controller
/**
* Show the form for editing a user role.
*
* @throws PermissionsException
*/
public function edit(string $id)
{
$this->checkPermission('user-roles-manage');
$role = $this->permissionsRepo->getRoleById($id);
if ($role->hidden) {
throw new PermissionsException(trans('errors.role_cannot_be_edited'));
}
$this->setPageTitle(trans('settings.role_edit'));

View File

@@ -11,7 +11,7 @@ use Illuminate\Http\Request;
class SearchController extends Controller
{
protected SearchRunner $searchRunner;
protected $searchRunner;
public function __construct(SearchRunner $searchRunner)
{
@@ -69,7 +69,7 @@ class SearchController extends Controller
* Search for a list of entities and return a partial HTML response of matching entities.
* Returns the most popular entities if no search is provided.
*/
public function searchForSelector(Request $request)
public function searchEntitiesAjax(Request $request)
{
$entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book'];
$searchTerm = $request->get('term', false);
@@ -83,25 +83,7 @@ class SearchController extends Controller
$entities = (new Popular())->run(20, 0, $entityTypes);
}
return view('search.parts.entity-selector-list', ['entities' => $entities, 'permission' => $permission]);
}
/**
* Search for a list of entities and return a partial HTML response of matching entities
* to be used as a result preview suggestion list for global system searches.
*/
public function searchSuggestions(Request $request)
{
$searchTerm = $request->get('term', '');
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 5)['results'];
foreach ($entities as $entity) {
$entity->setAttribute('preview_content', '');
}
return view('search.parts.entity-suggestion-list', [
'entities' => $entities->slice(0, 5)
]);
return view('search.parts.entity-ajax-list', ['entities' => $entities, 'permission' => $permission]);
}
/**

View File

@@ -4,14 +4,20 @@ namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Auth\User;
use BookStack\Settings\AppSettingsStore;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\Request;
class SettingController extends Controller
{
protected ImageRepo $imageRepo;
protected array $settingCategories = ['features', 'customization', 'registration'];
public function __construct(ImageRepo $imageRepo)
{
$this->imageRepo = $imageRepo;
}
/**
* Handle requests to the settings index path.
*/
@@ -42,17 +48,37 @@ class SettingController extends Controller
/**
* Update the specified settings in storage.
*/
public function update(Request $request, AppSettingsStore $store, string $category)
public function update(Request $request, string $category)
{
$this->ensureCategoryExists($category);
$this->preventAccessInDemoMode();
$this->checkPermission('settings-manage');
$this->validate($request, [
'app_logo' => ['nullable', ...$this->getImageValidationRules()],
'app_icon' => ['nullable', ...$this->getImageValidationRules()],
'app_logo' => array_merge(['nullable'], $this->getImageValidationRules()),
]);
$store->storeFromUpdateRequest($request, $category);
// Cycles through posted settings and update them
foreach ($request->all() as $name => $value) {
$key = str_replace('setting-', '', trim($name));
if (strpos($name, 'setting-') !== 0) {
continue;
}
setting()->put($key, $value);
}
// Update logo image if set
if ($category === 'customization' && $request->hasFile('app_logo')) {
$logoFile = $request->file('app_logo');
$this->imageRepo->destroyByType('system');
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
setting()->put('app-logo', $image->url);
}
// Clear logo image if requested
if ($category === 'customization' && $request->get('app_logo_reset', null)) {
$this->imageRepo->destroyByType('system');
setting()->remove('app-logo');
}
$this->logActivity(ActivityType::SETTINGS_UPDATE, $category);
$this->showSuccessNotification(trans('settings.settings_save_success'));

View File

@@ -3,7 +3,6 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\TagRepo;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
class TagController extends Controller
@@ -20,25 +19,22 @@ class TagController extends Controller
*/
public function index(Request $request)
{
$listOptions = SimpleListOptions::fromRequest($request, 'tags')->withSortOptions([
'name' => trans('common.sort_name'),
'usages' => trans('entities.tags_usages'),
]);
$search = $request->get('search', '');
$nameFilter = $request->get('name', '');
$tags = $this->tagRepo
->queryWithTotals($listOptions, $nameFilter)
->queryWithTotals($search, $nameFilter)
->paginate(50)
->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
->appends(array_filter([
'search' => $search,
'name' => $nameFilter,
])));
]));
$this->setPageTitle(trans('entities.tags'));
return view('tags.index', [
'tags' => $tags,
'nameFilter' => $nameFilter,
'listOptions' => $listOptions,
'tags' => $tags,
'search' => $search,
'nameFilter' => $nameFilter,
]);
}

View File

@@ -3,13 +3,13 @@
namespace BookStack\Http\Controllers;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\Queries\UsersAllPaginatedAndSorted;
use BookStack\Auth\Queries\AllUsersPaginatedAndSorted;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Uploads\ImageRepo;
use BookStack\Util\SimpleListOptions;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -21,6 +21,9 @@ class UserController extends Controller
protected UserRepo $userRepo;
protected ImageRepo $imageRepo;
/**
* UserController constructor.
*/
public function __construct(UserRepo $userRepo, ImageRepo $imageRepo)
{
$this->userRepo = $userRepo;
@@ -33,23 +36,20 @@ class UserController extends Controller
public function index(Request $request)
{
$this->checkPermission('users-manage');
$listDetails = [
'order' => $request->get('order', 'asc'),
'search' => $request->get('search', ''),
'sort' => $request->get('sort', 'name'),
];
$listOptions = SimpleListOptions::fromRequest($request, 'users')->withSortOptions([
'name' => trans('common.sort_name'),
'email' => trans('auth.email'),
'created_at' => trans('common.sort_created_at'),
'updated_at' => trans('common.sort_updated_at'),
'last_activity_at' => trans('settings.users_latest_activity'),
]);
$users = (new UsersAllPaginatedAndSorted())->run(20, $listOptions);
$users = (new AllUsersPaginatedAndSorted())->run(20, $listDetails);
$this->setPageTitle(trans('settings.users'));
$users->appends($listOptions->getPaginationAppends());
$users->appends($listDetails);
return view('users.index', [
'users' => $users,
'listOptions' => $listOptions,
'listDetails' => $listDetails,
]);
}
@@ -107,8 +107,9 @@ class UserController extends Controller
{
$this->checkPermissionOrCurrentUser('users-manage', $id);
$user = $this->userRepo->getById($id);
$user->load(['apiTokens', 'mfaValues']);
/** @var User $user */
$user = User::query()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
@@ -164,8 +165,6 @@ class UserController extends Controller
// Delete the profile image if reset option is in request
if ($request->has('profile_image_reset')) {
$this->imageRepo->destroyImage($user->avatar);
$user->image_id = 0;
$user->save();
}
$redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}";
@@ -203,4 +202,137 @@ class UserController extends Controller
return redirect('/settings/users');
}
/**
* Update the user's preferred book-list display setting.
*/
public function switchBooksView(Request $request, int $id)
{
return $this->switchViewType($id, $request, 'books');
}
/**
* Update the user's preferred shelf-list display setting.
*/
public function switchShelvesView(Request $request, int $id)
{
return $this->switchViewType($id, $request, 'bookshelves');
}
/**
* Update the user's preferred shelf-view book list display setting.
*/
public function switchShelfView(Request $request, int $id)
{
return $this->switchViewType($id, $request, 'bookshelf');
}
/**
* For a type of list, switch with stored view type for a user.
*/
protected function switchViewType(int $userId, Request $request, string $listName)
{
$this->checkPermissionOrCurrentUser('users-manage', $userId);
$viewType = $request->get('view_type');
if (!in_array($viewType, ['grid', 'list'])) {
$viewType = 'list';
}
$user = $this->userRepo->getById($userId);
$key = $listName . '_view_type';
setting()->putUser($user, $key, $viewType);
return redirect()->back(302, [], "/settings/users/$userId");
}
/**
* Change the stored sort type for a particular view.
*/
public function changeSort(Request $request, string $id, string $type)
{
$validSortTypes = ['books', 'bookshelves', 'shelf_books'];
if (!in_array($type, $validSortTypes)) {
return redirect()->back(500);
}
return $this->changeListSort($id, $request, $type);
}
/**
* Toggle dark mode for the current user.
*/
public function toggleDarkMode()
{
$enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
setting()->putUser(user(), 'dark-mode-enabled', $enabled ? 'false' : 'true');
return redirect()->back();
}
/**
* Update the stored section expansion preference for the given user.
*/
public function updateExpansionPreference(Request $request, string $id, string $key)
{
$this->checkPermissionOrCurrentUser('users-manage', $id);
$keyWhitelist = ['home-details'];
if (!in_array($key, $keyWhitelist)) {
return response('Invalid key', 500);
}
$newState = $request->get('expand', 'false');
$user = $this->userRepo->getById($id);
setting()->putUser($user, 'section_expansion#' . $key, $newState);
return response('', 204);
}
public function updateCodeLanguageFavourite(Request $request)
{
$validated = $this->validate($request, [
'language' => ['required', 'string', 'max:20'],
'active' => ['required', 'bool'],
]);
$currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', '');
$currentFavorites = array_filter(explode(',', $currentFavoritesStr));
$isFav = in_array($validated['language'], $currentFavorites);
if (!$isFav && $validated['active']) {
$currentFavorites[] = $validated['language'];
} elseif ($isFav && !$validated['active']) {
$index = array_search($validated['language'], $currentFavorites);
array_splice($currentFavorites, $index, 1);
}
setting()->putUser(user(), 'code-language-favourites', implode(',', $currentFavorites));
}
/**
* Changed the stored preference for a list sort order.
*/
protected function changeListSort(int $userId, Request $request, string $listName)
{
$this->checkPermissionOrCurrentUser('users-manage', $userId);
$sort = $request->get('sort');
if (!in_array($sort, ['name', 'created_at', 'updated_at', 'default'])) {
$sort = 'name';
}
$order = $request->get('order');
if (!in_array($order, ['asc', 'desc'])) {
$order = 'asc';
}
$user = $this->userRepo->getById($userId);
$sortKey = $listName . '_sort';
$orderKey = $listName . '_sort_order';
setting()->putUser($user, $sortKey, $sort);
setting()->putUser($user, $orderKey, $order);
return redirect()->back(302, [], "/settings/users/$userId");
}
}

View File

@@ -1,142 +0,0 @@
<?php
namespace BookStack\Http\Controllers;
use BookStack\Auth\UserRepo;
use BookStack\Settings\UserShortcutMap;
use Illuminate\Http\Request;
class UserPreferencesController extends Controller
{
protected UserRepo $userRepo;
public function __construct(UserRepo $userRepo)
{
$this->userRepo = $userRepo;
}
/**
* Show the user-specific interface shortcuts.
*/
public function showShortcuts()
{
$shortcuts = UserShortcutMap::fromUserPreferences();
$enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
return view('users.preferences.shortcuts', [
'shortcuts' => $shortcuts,
'enabled' => $enabled,
]);
}
/**
* Update the user-specific interface shortcuts.
*/
public function updateShortcuts(Request $request)
{
$enabled = $request->get('enabled') === 'true';
$providedShortcuts = $request->get('shortcut', []);
$shortcuts = new UserShortcutMap($providedShortcuts);
setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled);
$this->showSuccessNotification(trans('preferences.shortcuts_update_success'));
return redirect('/preferences/shortcuts');
}
/**
* Update the preferred view format for a list view of the given type.
*/
public function changeView(Request $request, string $type)
{
$valueViewTypes = ['books', 'bookshelves', 'bookshelf'];
if (!in_array($type, $valueViewTypes)) {
return redirect()->back(500);
}
$view = $request->get('view');
if (!in_array($view, ['grid', 'list'])) {
$view = 'list';
}
$key = $type . '_view_type';
setting()->putForCurrentUser($key, $view);
return redirect()->back(302, [], "/");
}
/**
* Change the stored sort type for a particular view.
*/
public function changeSort(Request $request, string $type)
{
$validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks', 'tags', 'page_revisions'];
if (!in_array($type, $validSortTypes)) {
return redirect()->back(500);
}
$sort = substr($request->get('sort') ?: 'name', 0, 50);
$order = $request->get('order') === 'desc' ? 'desc' : 'asc';
$sortKey = $type . '_sort';
$orderKey = $type . '_sort_order';
setting()->putForCurrentUser($sortKey, $sort);
setting()->putForCurrentUser($orderKey, $order);
return redirect()->back(302, [], "/");
}
/**
* Toggle dark mode for the current user.
*/
public function toggleDarkMode()
{
$enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
setting()->putForCurrentUser('dark-mode-enabled', $enabled ? 'false' : 'true');
return redirect()->back();
}
/**
* Update the stored section expansion preference for the given user.
*/
public function changeExpansion(Request $request, string $type)
{
$typeWhitelist = ['home-details'];
if (!in_array($type, $typeWhitelist)) {
return response('Invalid key', 500);
}
$newState = $request->get('expand', 'false');
setting()->putForCurrentUser('section_expansion#' . $type, $newState);
return response('', 204);
}
/**
* Update the favorite status for a code language.
*/
public function updateCodeLanguageFavourite(Request $request)
{
$validated = $this->validate($request, [
'language' => ['required', 'string', 'max:20'],
'active' => ['required', 'bool'],
]);
$currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', '');
$currentFavorites = array_filter(explode(',', $currentFavoritesStr));
$isFav = in_array($validated['language'], $currentFavorites);
if (!$isFav && $validated['active']) {
$currentFavorites[] = $validated['language'];
} elseif ($isFav && !$validated['active']) {
$index = array_search($validated['language'], $currentFavorites);
array_splice($currentFavorites, $index, 1);
}
setting()->putForCurrentUser('code-language-favourites', implode(',', $currentFavorites));
return response('', 204);
}
}

View File

@@ -3,9 +3,7 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Actions\Queries\WebhooksAllPaginatedAndSorted;
use BookStack\Actions\Webhook;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
class WebhookController extends Controller
@@ -20,25 +18,16 @@ class WebhookController extends Controller
/**
* Show all webhooks configured in the system.
*/
public function index(Request $request)
public function index()
{
$listOptions = SimpleListOptions::fromRequest($request, 'webhooks')->withSortOptions([
'name' => trans('common.sort_name'),
'endpoint' => trans('settings.webhooks_endpoint'),
'created_at' => trans('common.sort_created_at'),
'updated_at' => trans('common.sort_updated_at'),
'active' => trans('common.status'),
]);
$webhooks = (new WebhooksAllPaginatedAndSorted())->run(20, $listOptions);
$webhooks->appends($listOptions->getPaginationAppends());
$webhooks = Webhook::query()
->orderBy('name', 'desc')
->with('trackedEvents')
->get();
$this->setPageTitle(trans('settings.webhooks'));
return view('settings.webhooks.index', [
'webhooks' => $webhooks,
'listOptions' => $listOptions,
]);
return view('settings.webhooks.index', ['webhooks' => $webhooks]);
}
/**

View File

@@ -6,7 +6,7 @@ use BookStack\Api\ApiTokenGuard;
use BookStack\Auth\Access\ExternalBaseUserProvider;
use BookStack\Auth\Access\Guards\AsyncExternalBaseSessionGuard;
use BookStack\Auth\Access\Guards\LdapSessionGuard;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\Access\Ldap\LdapService;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use Illuminate\Support\Facades\Auth;

View File

@@ -19,6 +19,14 @@ class RouteServiceProvider extends ServiceProvider
*/
public const HOME = '/';
/**
* This namespace is applied to the controller routes in your routes file.
*
* In addition, it is set as the URL generator's root namespace.
*
* @var string
*/
/**
* Define your route model bindings, pattern filters, etc.
*

View File

@@ -3,41 +3,10 @@
namespace BookStack\Providers;
use BookStack\Translation\FileLoader;
use BookStack\Translation\MessageSelector;
use Illuminate\Translation\TranslationServiceProvider as BaseProvider;
use Illuminate\Translation\Translator;
class TranslationServiceProvider extends BaseProvider
{
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$this->registerLoader();
// This is a tweak upon Laravel's based translation service registration to allow
// usage of a custom MessageSelector class
$this->app->singleton('translator', function ($app) {
$loader = $app['translation.loader'];
// When registering the translator component, we'll need to set the default
// locale as well as the fallback locale. So, we'll grab the application
// configuration so we can easily get both of these values from there.
$locale = $app['config']['app.locale'];
$trans = new Translator($loader, $locale);
$trans->setFallback($app['config']['app.fallback_locale']);
$trans->setSelector(new MessageSelector());
return $trans;
});
}
/**
* Register the translation line loader.
* Overrides the default register action from Laravel so a custom loader can be used.

View File

@@ -2,9 +2,7 @@
namespace BookStack\References;
use BookStack\Auth\Permissions\JointPermission;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
@@ -26,10 +24,4 @@ class Reference extends Model
{
return $this->morphTo('to');
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'from_id')
->whereColumn('references.from_type', '=', 'joint_permissions.entity_type');
}
}

View File

@@ -5,7 +5,6 @@ namespace BookStack\References;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\Relation;
@@ -24,7 +23,8 @@ class ReferenceFetcher
*/
public function getPageReferencesToEntity(Entity $entity): Collection
{
$baseQuery = $this->queryPageReferencesToEntity($entity)
$baseQuery = $entity->referencesTo()
->where('from_type', '=', (new Page())->getMorphClass())
->with([
'from' => fn (Relation $query) => $query->select(Page::$listAttributes),
'from.book' => fn (Relation $query) => $query->scopes('visible'),
@@ -47,8 +47,11 @@ class ReferenceFetcher
*/
public function getPageReferenceCountToEntity(Entity $entity): int
{
$baseQuery = $entity->referencesTo()
->where('from_type', '=', (new Page())->getMorphClass());
$count = $this->permissions->restrictEntityRelationQuery(
$this->queryPageReferencesToEntity($entity),
$baseQuery,
'references',
'from_id',
'from_type'
@@ -56,12 +59,4 @@ class ReferenceFetcher
return $count;
}
protected function queryPageReferencesToEntity(Entity $entity): Builder
{
return Reference::query()
->where('to_type', '=', $entity->getMorphClass())
->where('to_id', '=', $entity->id)
->where('from_type', '=', (new Page())->getMorphClass());
}
}

View File

@@ -112,12 +112,12 @@ class SearchIndex
*
* @returns array<string, int>
*/
protected function generateTermScoreMapFromText(string $text, float $scoreAdjustment = 1): array
protected function generateTermScoreMapFromText(string $text, int $scoreAdjustment = 1): array
{
$termMap = $this->textToTermCountMap($text);
foreach ($termMap as $term => $count) {
$termMap[$term] = floor($count * $scoreAdjustment);
$termMap[$term] = $count * $scoreAdjustment;
}
return $termMap;

View File

@@ -50,7 +50,7 @@ class SearchRunner
* The provided count is for each entity to search,
* Total returned could be larger and not guaranteed.
*
* @return array{total: int, count: int, has_more: bool, results: Collection<Entity>}
* @return array{total: int, count: int, has_more: bool, results: Entity[]}
*/
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array
{

View File

@@ -1,91 +0,0 @@
<?php
namespace BookStack\Settings;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\Request;
class AppSettingsStore
{
protected ImageRepo $imageRepo;
public function __construct(ImageRepo $imageRepo)
{
$this->imageRepo = $imageRepo;
}
public function storeFromUpdateRequest(Request $request, string $category)
{
$this->storeSimpleSettings($request);
if ($category === 'customization') {
$this->updateAppLogo($request);
$this->updateAppIcon($request);
}
}
protected function updateAppIcon(Request $request): void
{
$sizes = [180, 128, 64, 32];
// Update icon image if set
if ($request->hasFile('app_icon')) {
$iconFile = $request->file('app_icon');
$this->destroyExistingSettingImage('app-icon');
$image = $this->imageRepo->saveNew($iconFile, 'system', 0, 256, 256);
setting()->put('app-icon', $image->url);
foreach ($sizes as $size) {
$this->destroyExistingSettingImage('app-icon-' . $size);
$icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size);
setting()->put('app-icon-' . $size, $icon->url);
}
}
// Clear icon image if requested
if ($request->get('app_icon_reset')) {
$this->destroyExistingSettingImage('app-icon');
setting()->remove('app-icon');
foreach ($sizes as $size) {
$this->destroyExistingSettingImage('app-icon-' . $size);
setting()->remove('app-icon-' . $size);
}
}
}
protected function updateAppLogo(Request $request): void
{
// Update logo image if set
if ($request->hasFile('app_logo')) {
$logoFile = $request->file('app_logo');
$this->destroyExistingSettingImage('app-logo');
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
setting()->put('app-logo', $image->url);
}
// Clear logo image if requested
if ($request->get('app_logo_reset')) {
$this->destroyExistingSettingImage('app-logo');
setting()->remove('app-logo');
}
}
protected function storeSimpleSettings(Request $request): void
{
foreach ($request->all() as $name => $value) {
if (strpos($name, 'setting-') !== 0) {
continue;
}
$key = str_replace('setting-', '', trim($name));
setting()->put($key, $value);
}
}
protected function destroyExistingSettingImage(string $settingKey)
{
$existingVal = setting()->get($settingKey);
if ($existingVal) {
$this->imageRepo->destroyByUrlAndType($existingVal, 'system');
}
}
}

View File

@@ -12,11 +12,15 @@ use Illuminate\Contracts\Cache\Repository as Cache;
*/
class SettingService
{
protected Setting $setting;
protected Cache $cache;
protected array $localCache = [];
protected string $cachePrefix = 'setting-';
protected $setting;
protected $cache;
protected $localCache = [];
protected $cachePrefix = 'setting-';
/**
* SettingService constructor.
*/
public function __construct(Setting $setting, Cache $cache)
{
$this->setting = $setting;
@@ -190,8 +194,6 @@ class SettingService
/**
* Put a user-specific setting into the database.
* Can only take string value types since this may use
* the session which is less flexible to data types.
*/
public function putUser(User $user, string $key, string $value): bool
{
@@ -204,16 +206,6 @@ class SettingService
return $this->put($this->userKey($user->id, $key), $value);
}
/**
* Put a user-specific setting into the database for the current access user.
* Can only take string value types since this may use
* the session which is less flexible to data types.
*/
public function putForCurrentUser(string $key, string $value)
{
return $this->putUser(user(), $key, $value);
}
/**
* Convert a setting key into a user-specific key.
*/

View File

@@ -1,82 +0,0 @@
<?php
namespace BookStack\Settings;
class UserShortcutMap
{
protected const DEFAULTS = [
// Header actions
"home_view" => "1",
"shelves_view" => "2",
"books_view" => "3",
"settings_view" => "4",
"favourites_view" => "5",
"profile_view" => "6",
"global_search" => "/",
"logout" => "0",
// Common actions
"edit" => "e",
"new" => "n",
"copy" => "c",
"delete" => "d",
"favourite" => "f",
"export" => "x",
"sort" => "s",
"permissions" => "p",
"move" => "m",
"revisions" => "r",
// Navigation
"next" => "ArrowRight",
"previous" => "ArrowLeft",
];
/**
* @var array<string, string>
*/
protected array $mapping;
public function __construct(array $map)
{
$this->mapping = static::DEFAULTS;
$this->merge($map);
}
/**
* Merge the given map into the current shortcut mapping.
*/
protected function merge(array $map): void
{
foreach ($map as $key => $value) {
if (is_string($value) && isset($this->mapping[$key])) {
$this->mapping[$key] = $value;
}
}
}
/**
* Get the shortcut defined for the given ID.
*/
public function getShortcut(string $id): string
{
return $this->mapping[$id] ?? '';
}
/**
* Convert this mapping to JSON.
*/
public function toJson(): string
{
return json_encode($this->mapping);
}
/**
* Create a new instance from the current user's preferences.
*/
public static function fromUserPreferences(): self
{
$userKeyMap = setting()->getForCurrentUser('ui-shortcuts');
return new self(json_decode($userKeyMap, true) ?: []);
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace BookStack\Translation;
use Illuminate\Translation\MessageSelector as BaseClass;
/**
* This is a customization of the default Laravel MessageSelector class to tweak pluralization,
* so that is uses just the first part of the locale string to provide support with
* non-standard locales such as "de_informal".
*/
class MessageSelector extends BaseClass
{
public function getPluralIndex($locale, $number)
{
$locale = explode('_', $locale)[0];
return parent::getPluralIndex($locale, $number);
}
}

View File

@@ -2,7 +2,6 @@
namespace BookStack\Uploads;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
@@ -11,7 +10,6 @@ use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
@@ -58,12 +56,6 @@ class Attachment extends Model
return $this->belongsTo(Page::class, 'uploaded_to');
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'uploaded_to')
->where('joint_permissions.entity_type', '=', 'page');
}
/**
* Get the url of this file.
*/

View File

@@ -2,12 +2,10 @@
namespace BookStack\Uploads;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Entities\Models\Page;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
@@ -27,12 +25,6 @@ class Image extends Model
protected $fillable = ['name'];
protected $hidden = [];
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'uploaded_to')
->where('joint_permissions.entity_type', '=', 'page');
}
/**
* Get a thumbnail for this image.
*

View File

@@ -123,10 +123,7 @@ class ImageRepo
public function saveNew(UploadedFile $uploadFile, string $type, int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, bool $keepRatio = true): Image
{
$image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
if ($type !== 'system') {
$this->loadThumbs($image);
}
$this->loadThumbs($image);
return $image;
}
@@ -183,17 +180,13 @@ class ImageRepo
}
/**
* Destroy images that have a specific URL and type combination.
* Destroy all images of a certain type.
*
* @throws Exception
*/
public function destroyByUrlAndType(string $url, string $imageType): void
public function destroyByType(string $imageType): void
{
$images = Image::query()
->where('url', '=', $url)
->where('type', '=', $imageType)
->get();
$images = Image::query()->where('type', '=', $imageType)->get();
foreach ($images as $image) {
$this->destroyImage($image);
}

View File

@@ -88,17 +88,16 @@ class ImageService
protected function getStorageDiskName(string $imageType): string
{
$storageType = config('filesystems.images');
$localSecureInUse = ($storageType === 'local_secure' || $storageType === 'local_secure_restricted');
// Ensure system images (App logo) are uploaded to a public space
if ($imageType === 'system' && $localSecureInUse) {
return 'local';
if ($imageType === 'system' && $storageType === 'local_secure') {
$storageType = 'local';
}
// Rename local_secure options to get our image specific storage driver which
// is scoped to the relevant image directories.
if ($localSecureInUse) {
return 'local_secure_images';
if ($storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
$storageType = 'local_secure_images';
}
return $storageType;

View File

@@ -126,7 +126,7 @@ class CspService
protected function getAllowedIframeHosts(): array
{
$hosts = config('app.iframe_hosts') ?? '';
$hosts = config('app.iframe_hosts', '');
return array_filter(explode(' ', $hosts));
}

View File

@@ -28,7 +28,6 @@ class LanguageManager
'de' => ['iso' => 'de_DE', 'windows' => 'German'],
'de_informal' => ['iso' => 'de_DE', 'windows' => 'German'],
'en' => ['iso' => 'en_GB', 'windows' => 'English'],
'el' => ['iso' => 'el_GR', 'windows' => 'Greek'],
'es' => ['iso' => 'es_ES', 'windows' => 'Spanish'],
'es_AR' => ['iso' => 'es_AR', 'windows' => 'Spanish'],
'et' => ['iso' => 'et_EE', 'windows' => 'Estonian'],

View File

@@ -1,104 +0,0 @@
<?php
namespace BookStack\Util;
use Illuminate\Http\Request;
/**
* Handled options commonly used for item lists within the system, providing a standard
* model for handling and validating sort, order and search options.
*/
class SimpleListOptions
{
protected string $typeKey;
protected string $sort;
protected string $order;
protected string $search;
protected array $sortOptions = [];
public function __construct(string $typeKey, string $sort, string $order, string $search = '')
{
$this->typeKey = $typeKey;
$this->sort = $sort;
$this->order = $order;
$this->search = $search;
}
/**
* Create a new instance from the given request.
* Takes the item type (plural) that's used as a key for storing sort preferences.
*/
public static function fromRequest(Request $request, string $typeKey, bool $sortDescDefault = false): self
{
$search = $request->get('search', '');
$sort = setting()->getForCurrentUser($typeKey . '_sort', '');
$order = setting()->getForCurrentUser($typeKey . '_sort_order', $sortDescDefault ? 'desc' : 'asc');
return new self($typeKey, $sort, $order, $search);
}
/**
* Configure the valid sort options for this set of list options.
* Provided sort options must be an array, keyed by search properties
* with values being user-visible option labels.
* Returns current options for easy fluent usage during creation.
*/
public function withSortOptions(array $sortOptions): self
{
$this->sortOptions = array_merge($this->sortOptions, $sortOptions);
return $this;
}
/**
* Get the current order option.
*/
public function getOrder(): string
{
return strtolower($this->order) === 'desc' ? 'desc' : 'asc';
}
/**
* Get the current sort option.
*/
public function getSort(): string
{
$default = array_key_first($this->sortOptions) ?? 'name';
$sort = $this->sort ?: $default;
if (empty($this->sortOptions) || array_key_exists($sort, $this->sortOptions)) {
return $sort;
}
return $default;
}
/**
* Get the set search term.
*/
public function getSearch(): string
{
return $this->search;
}
/**
* Get the data to append for pagination.
*/
public function getPaginationAppends(): array
{
return ['search' => $this->search];
}
/**
* Get the data required by the sort control view.
*/
public function getSortControlData(): array
{
return [
'options' => $this->sortOptions,
'order' => $this->getOrder(),
'sort' => $this->getSort(),
'type' => $this->typeKey,
];
}
}

772
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,6 @@
project_identifier: bookstack
base_path: .
preserve_hierarchy: false
pull_request_title: Updated translations with latest Crowdin changes
pull_request_labels:
- ":earth_africa: Translations"
files:
- source: /resources/lang/en/*.php
translation: /resources/lang/%two_letters_code%/%original_file_name%

View File

@@ -21,7 +21,7 @@ class TagFactory extends Factory
public function definition()
{
return [
'name' => $this->faker->city(),
'name' => $this->faker->city,
'value' => $this->faker->sentence(3),
];
}

View File

@@ -18,7 +18,7 @@ class WebhookFactory extends Factory
{
return [
'name' => 'My webhook for ' . $this->faker->country(),
'endpoint' => $this->faker->url(),
'endpoint' => $this->faker->url,
'active' => true,
'timeout' => 3,
];

View File

@@ -22,11 +22,11 @@ class UserFactory extends Factory
*/
public function definition()
{
$name = $this->faker->name();
$name = $this->faker->name;
return [
'name' => $name,
'email' => $this->faker->email(),
'email' => $this->faker->email,
'slug' => Str::slug($name . '-' . Str::random(5)),
'password' => Str::random(10),
'remember_token' => Str::random(10),

View File

@@ -22,9 +22,9 @@ class BookFactory extends Factory
public function definition()
{
return [
'name' => $this->faker->sentence(),
'name' => $this->faker->sentence,
'slug' => Str::random(10),
'description' => $this->faker->paragraph(),
'description' => $this->faker->paragraph,
];
}
}

View File

@@ -22,9 +22,9 @@ class ChapterFactory extends Factory
public function definition()
{
return [
'name' => $this->faker->sentence(),
'name' => $this->faker->sentence,
'slug' => Str::random(10),
'description' => $this->faker->paragraph(),
'description' => $this->faker->paragraph,
];
}
}

View File

@@ -24,7 +24,7 @@ class PageFactory extends Factory
$html = '<p>' . implode('</p>', $this->faker->paragraphs(5)) . '</p>';
return [
'name' => $this->faker->sentence(),
'name' => $this->faker->sentence,
'slug' => Str::random(10),
'html' => $html,
'text' => strip_tags($html),

View File

@@ -21,9 +21,9 @@ class ImageFactory extends Factory
public function definition()
{
return [
'name' => $this->faker->slug() . '.jpg',
'url' => $this->faker->url(),
'path' => $this->faker->url(),
'name' => $this->faker->slug . '.jpg',
'url' => $this->faker->url,
'path' => $this->faker->url,
'type' => 'gallery',
'uploaded_to' => 0,
];

View File

@@ -1,52 +0,0 @@
<?php
use BookStack\Auth\Permissions\JointPermissionBuilder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class RefactorJointPermissionsStorage extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// Truncate before schema changes to avoid performance issues
// since we'll need to rebuild anyway.
DB::table('joint_permissions')->truncate();
if (Schema::hasColumn('joint_permissions', 'owned_by')) {
Schema::table('joint_permissions', function (Blueprint $table) {
$table->dropColumn(['has_permission', 'has_permission_own', 'owned_by']);
$table->unsignedTinyInteger('status')->index();
$table->unsignedInteger('owner_id')->nullable()->index();
});
}
// Rebuild permissions
app(JointPermissionBuilder::class)->rebuildForAll();
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
DB::table('joint_permissions')->truncate();
Schema::table('joint_permissions', function (Blueprint $table) {
$table->dropColumn(['status', 'owner_id']);
$table->boolean('has_permission')->index();
$table->boolean('has_permission_own')->index();
$table->unsignedInteger('owned_by')->index();
});
}
}

View File

@@ -1,69 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
class CopyColorSettingsForDarkMode extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$colorSettings = [
'app-color',
'app-color-light',
'bookshelf-color',
'book-color',
'chapter-color',
'page-color',
'page-draft-color',
];
$existing = DB::table('settings')
->whereIn('setting_key', $colorSettings)
->get()->toArray();
$newData = [];
foreach ($existing as $setting) {
$newSetting = (array) $setting;
$newSetting['setting_key'] .= '-dark';
$newData[] = $newSetting;
if ($newSetting['setting_key'] === 'app-color-dark') {
$newSetting['setting_key'] = 'link-color';
$newData[] = $newSetting;
$newSetting['setting_key'] = 'link-color-dark';
$newData[] = $newSetting;
}
}
DB::table('settings')->insert($newData);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
$colorSettings = [
'app-color-dark',
'link-color',
'link-color-dark',
'app-color-light-dark',
'bookshelf-color-dark',
'book-color-dark',
'chapter-color-dark',
'page-color-dark',
'page-draft-color-dark',
];
DB::table('settings')
->whereIn('setting_key', $colorSettings)
->delete();
}
}

View File

@@ -29,8 +29,6 @@ The testing database will also need migrating and seeding beforehand. This can b
Once done you can run `composer test` in the application root directory to run all tests. Tests can be ran in parallel by running them via `composer t`. This will use Laravel's built-in parallel testing functionality, and attempt to create and seed a database instance for each testing thread. If required these parallel testing instances can be reset, before testing again, by running `composer t-reset`.
If the codebase needs to be tested with deprecations, this can be done via uncommenting the relevant line within the TestCase@setUp function.
## Code Standards
PHP code standards are managed by [using PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer).

View File

@@ -24,7 +24,7 @@ class Dropdown {
All usage of $refs, $manyRefs and $opts should be done at the top of the `setup` function so any requirements can be easily seen.
Once defined, the component has to be registered for use. This is done in the `resources/js/components/index.js` file by defining an additional export, following the pattern of other components.
Once defined, the component has to be registered for use. This is done in the `resources/js/components/index.js` file. You'll need to import the component class then add it to `componentMapping` object, following the pattern of other components.
### Using a Component in HTML
@@ -80,9 +80,9 @@ Will result with `this.$opts` being:
}
```
#### Component Properties & Methods
#### Component Properties
A component has the below shown properties & methods available for use. As mentioned above, most of these should be used within the `setup()` function to make the requirements/dependencies of the component clear.
A component has the below shown properties available for use. As mentioned above, most of these should be used within the `setup()` function to make the requirements/dependencies of the component clear.
```javascript
// The root element that the compontent has been applied to.
@@ -98,15 +98,6 @@ this.$manyRefs
// Options defined for the compontent.
this.$opts
// The registered name of the component, usually kebab-case.
this.$name
// Emit a custom event from this component.
// Will be bubbled up from the dom element this is registered on,
// as a custom event with the name `<elementName>-<eventName>`,
// with the provided data in the event detail.
this.$emit(eventName, data = {})
```
## Global JavaScript Helpers
@@ -141,16 +132,7 @@ window.trans_plural(translationString, count, replacements);
// Component System
// Parse and initialise any components from the given root el down.
window.$components.init(rootEl);
// Register component models to be used by the component system.
// Takes a mapping of classes/constructors keyed by component names.
// Names will be converted to kebab-case.
window.$components.register(mapping);
// Get the first active component of the given name.
window.$components.first(name);
// Get all the active components of the given name.
window.$components.get(name);
// Get the first active component of the given name that's been
// created on the given element.
window.$components.firstOnElement(element, name);
window.components.init(rootEl);
// Get the first active component of the given name
window.components.first(name);
```

View File

@@ -1,363 +0,0 @@
# Permission Scenario Testing
Due to complexity that can arise in the various combinations of permissions, this document details scenarios and their expected results.
Test cases are written ability abstract, since all abilities should act the same in theory. Functional test cases may test abilities separate due to implementation differences.
Tests are categorised by the most specific element involved in the scenario, where the below list is most specific to least:
- Role entity permissions.
- Fallback entity permissions.
- Role permissions.
## General Permission Logical Rules
The below are some general rules we follow to standardise the behaviour of permissions in the platform:
- Most specific permission application (as above) take priority and can deny less specific permissions.
- Parent role entity permissions that may be inherited, are considered to essentially be applied on the item they are inherited to unless a lower level has its own permission rule for an already specific role.
- Where both grant and deny exist at the same specificity, we side towards grant.
## Cases
### Content Role Permissions
These are tests related to item/entity permissions that are set only at a role level.
#### test_01_allow
- Role A has role all-page permission.
- User has Role A.
User granted page permission.
#### test_02_deny
- Role A has no page permission.
- User has Role A.
User denied page permission.
#### test_10_allow_on_own_with_own
- Role A has role own-page permission.
- User has Role A.
- User is owner of page.
User granted page permission.
#### test_11_deny_on_other_with_own
- Role A has role own-page permission.
- User has Role A.
- User is not owner of page.
User denied page permission.
#### test_20_multiple_role_conflicting_all
- Role A has role all-page permission.
- Role B has no page permission.
- User has Role A & B.
User granted page permission.
#### test_21_multiple_role_conflicting_own
- Role A has role own-page permission.
- Role B has no page permission.
- User has Role A & B.
- User is owner of page.
User granted page permission.
---
### Entity Role Permissions
These are tests related to entity-level role-specific permission overrides.
#### test_01_explicit_allow
- Page permissions have inherit disabled.
- Role A has entity allow page permission.
- User has Role A.
User granted page permission.
#### test_02_explicit_deny
- Page permissions have inherit disabled.
- Role A has entity deny page permission.
- User has Role A.
User denied page permission.
#### test_03_same_level_conflicting
- Page permissions have inherit disabled.
- Role A has entity allow page permission.
- Role B has entity deny page permission.
- User has both Role A & B.
User granted page permission.
Explicit grant overrides entity deny at same level.
#### test_20_inherit_allow
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity allow chapter permission.
- User has Role A.
User granted page permission.
#### test_21_inherit_deny
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity deny chapter permission.
- User has Role A.
User denied page permission.
#### test_22_same_level_conflict_inherit
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity deny chapter permission.
- Role B has entity allow chapter permission.
- User has both Role A & B.
User granted page permission.
#### test_30_child_inherit_override_allow
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity deny chapter permission.
- Role A has entity allow page permission.
- User has Role A.
User granted page permission.
#### test_31_child_inherit_override_deny
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity allow chapter permission.
- Role A has entity deny page permission.
- User has Role A.
User denied page permission.
#### test_40_multi_role_inherit_conflict_override_deny
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity deny page permission.
- Role B has entity allow chapter permission.
- User has Role A & B.
User granted page permission.
#### test_41_multi_role_inherit_conflict_retain_allow
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity allow page permission.
- Role B has entity deny chapter permission.
- User has Role A & B.
User granted page permission.
#### test_50_role_override_allow
- Page permissions have inherit enabled.
- Role A has no page role permission.
- Role A has entity allow page permission.
- User has Role A.
User granted page permission.
#### test_51_role_override_deny
- Page permissions have inherit enabled.
- Role A has no page-view-all role permission.
- Role A has entity deny page permission.
- User has Role A.
User denied page permission.
#### test_60_inherited_role_override_allow
- Page permissions have inherit enabled.
- Chapter permissions have inherit enabled.
- Role A has no page role permission.
- Role A has entity allow chapter permission.
- User has Role A.
User granted page permission.
#### test_61_inherited_role_override_deny
- Page permissions have inherit enabled.
- Chapter permissions have inherit enabled.
- Role A has page role permission.
- Role A has entity denied chapter permission.
- User has Role A.
User denied page permission.
#### test_62_inherited_role_override_deny_on_own
- Page permissions have inherit enabled.
- Chapter permissions have inherit enabled.
- Role A has own-page role permission.
- Role A has entity denied chapter permission.
- User has Role A.
- User owns Page.
User denied page permission.
#### test_70_multi_role_inheriting_deny
- Page permissions have inherit enabled.
- Role A has all page role permission.
- Role B has entity denied page permission.
- User has Role A and B.
User denied page permission.
#### test_71_multi_role_inheriting_deny_on_own
- Page permissions have inherit enabled.
- Role A has own page role permission.
- Role B has entity denied page permission.
- User has Role A and B.
- Use owns Page.
User denied page permission.
#### test_75_multi_role_inherited_deny_via_parent
- Page permissions have inherit enabled.
- Chapter permissions have inherit enabled.
- Role A has all-pages role permission.
- Role B has entity denied chapter permission.
- User has Role A & B.
User denied page permission.
#### test_76_multi_role_inherited_deny_via_parent_on_own
- Page permissions have inherit enabled.
- Chapter permissions have inherit enabled.
- Role A has own page role permission.
- Role B has entity denied chapter permission.
- User has Role A & B.
User denied page permission.
#### test_80_fallback_override_allow
- Page permissions have inherit disabled.
- Page fallback has entity deny permission.
- Role A has entity allow page permission.
- User has Role A.
User granted page permission.
#### test_81_fallback_override_deny
- Page permissions have inherit disabled.
- Page fallback has entity allow permission.
- Role A has entity deny page permission.
- User has Role A.
User denied page permission.
#### test_84_fallback_override_allow_multi_role
- Page permissions have inherit disabled.
- Page fallback has entity deny permission.
- Role A has entity allow page permission.
- Role B has no entity page permissions.
- User has Role A & B.
User granted page permission.
#### test_85_fallback_override_deny_multi_role
- Page permissions have inherit disabled.
- Page fallback has entity allow permission.
- Role A has entity deny page permission.
- Role B has no entity page permissions.
- User has Role A & B.
User denied page permission.
#### test_86_fallback_override_allow_inherit
- Chapter permissions have inherit disabled.
- Page permissions have inherit enabled.
- Chapter fallback has entity deny permission.
- Role A has entity allow chapter permission.
- User has Role A.
User granted page permission.
#### test_87_fallback_override_deny_inherit
- Chapter permissions have inherit disabled.
- Page permissions have inherit enabled.
- Chapter fallback has entity allow permission.
- Role A has entity deny chapter permission.
- User has Role A.
User denied page permission.
#### test_88_fallback_override_allow_multi_role_inherit
- Chapter permissions have inherit disabled.
- Page permissions have inherit enabled.
- Chapter fallback has entity deny permission.
- Role A has entity allow chapter permission.
- Role B has no entity chapter permissions.
- User has Role A & B.
User granted page permission.
#### test_89_fallback_override_deny_multi_role_inherit
- Chapter permissions have inherit disabled.
- Page permissions have inherit enabled.
- Chapter fallback has entity allow permission.
- Role A has entity deny chapter permission.
- Role B has no entity chapter permissions.
- User has Role A & B.
User denied page permission.
#### test_90_fallback_overrides_parent_entity_role_deny
- Chapter permissions have inherit disabled.
- Page permissions have inherit disabled.
- Chapter fallback has entity deny permission.
- Page fallback has entity deny permission.
- Role A has entity allow chapter permission.
- User has Role A.
User denied page permission.
#### test_91_fallback_overrides_parent_entity_role_inherit
- Book permissions have inherit disabled.
- Chapter permissions have inherit disabled.
- Page permissions have inherit enabled.
- Book fallback has entity deny permission.
- Chapter fallback has entity deny permission.
- Role A has entity allow book permission.
- User has Role A.
User denied page permission.

1812
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,11 +16,11 @@
},
"devDependencies": {
"chokidar-cli": "^3.0",
"esbuild": "^0.17.3",
"esbuild": "0.14.42",
"livereload": "^0.9.3",
"npm-run-all": "^4.1.5",
"punycode": "^2.3.0",
"sass": "^1.57.0"
"punycode": "^2.1.1",
"sass": "^1.52.1"
},
"dependencies": {
"clipboard": "^2.0.11",
@@ -28,7 +28,7 @@
"dropzone": "^5.9.3",
"markdown-it": "^13.0.1",
"markdown-it-task-lists": "^2.1.1",
"snabbdom": "^3.5.1",
"snabbdom": "^3.5.0",
"sortablejs": "^1.15.0"
}
}

69
public/dist/app.js vendored

File diff suppressed because one or more lines are too long

38
public/dist/code.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
:root{--color-primary: #206ea7;--color-primary-light: rgba(32,110,167,0.15);--color-link: #206ea7;--color-page: #206ea7;--color-page-draft: #7e50b1;--color-chapter: #af4d0d;--color-book: #077b70;--color-bookshelf: #a94747;--bg-disabled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='100%25' width='100%25'%3E%3Cdefs%3E%3Cpattern id='doodad' width='19' height='19' viewBox='0 0 40 40' patternUnits='userSpaceOnUse' patternTransform='rotate(143)'%3E%3Crect width='100%25' height='100%25' fill='rgba(42, 67, 101,0)'/%3E%3Cpath d='M-10 30h60v20h-60zM-10-10h60v20h-60' fill='rgba(26, 32, 44,0)'/%3E%3Cpath d='M-10 10h60v20h-60zM-10-30h60v20h-60z' fill='rgba(0, 0, 0,0.05)'/%3E%3C/pattern%3E%3C/defs%3E%3Crect fill='url(%23doodad)' height='200%25' width='200%25'/%3E%3C/svg%3E")}:root.dark-mode{--bg-disabled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='100%25' width='100%25'%3E%3Cdefs%3E%3Cpattern id='doodad' width='19' height='19' viewBox='0 0 40 40' patternUnits='userSpaceOnUse' patternTransform='rotate(143)'%3E%3Crect width='100%25' height='100%25' fill='rgba(42, 67, 101,0)'/%3E%3Cpath d='M-10 30h60v20h-60zM-10-10h60v20h-60' fill='rgba(26, 32, 44,0)'/%3E%3Cpath d='M-10 10h60v20h-60zM-10-30h60v20h-60z' fill='rgba(255, 255, 255,0.05)'/%3E%3C/pattern%3E%3C/defs%3E%3Crect fill='url(%23doodad)' height='200%25' width='200%25'/%3E%3C/svg%3E");color-scheme:only dark}:root:not(.dark-mode){color-scheme:only light}header{display:none}html,body{font-size:12px;background-color:#fff}.page-content{margin:0 auto}.print-hidden{display:none !important}.tri-layout-container{grid-template-columns:1fr;grid-template-areas:"b";margin-inline-start:0;margin-inline-end:0;display:block}.card{box-shadow:none}.content-wrap.card{padding-inline-start:0;padding-inline-end:0}/*# sourceMappingURL=print-styles.css.map */

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -45,7 +45,7 @@ Note: Listed services are not tested, vetted nor supported by the official BookS
<img width="400" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
</a></td>
<td><a href="https://cloudabove.com/hosting" target="_blank">
<img height="100" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/cloudabove.png" alt="Cloudabove">
<img height="100" src="https://raw.githubusercontent.com/BookStackApp/website/main/static/images/sponsors/cloudabove.svg" alt="Cloudabove">
</a></td>
</tr></tbody></table>
@@ -55,9 +55,6 @@ Note: Listed services are not tested, vetted nor supported by the official BookS
<td><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
</a></td>
<td><a href="https://www.practicali.be" target="_blank">
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/practicali.png" alt="Stellar Hosted">
</a></td>
</tr></tbody></table>
## 🛣️ Road Map

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