mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-06 09:09:38 +03:00
Compare commits
105 Commits
v22.10
...
user_permi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3083979855 | ||
|
|
55642a33ee | ||
|
|
93ba572369 | ||
|
|
a825f27930 | ||
|
|
932e1d7c61 | ||
|
|
2f1491c5a4 | ||
|
|
026e9030b9 | ||
|
|
451e4ac452 | ||
|
|
7330139555 | ||
|
|
39acbeac68 | ||
|
|
2d9d2bba80 | ||
|
|
adabf06dbe | ||
|
|
5ffc10e688 | ||
|
|
6a6f5e4d19 | ||
|
|
491beee93e | ||
|
|
f844ae0902 | ||
|
|
d54ea1b3ed | ||
|
|
e8a8fedfd6 | ||
|
|
60bf838a4a | ||
|
|
0411185fbb | ||
|
|
93cbd3b8aa | ||
|
|
7a269e7689 | ||
|
|
f8c4725166 | ||
|
|
1c53ffc4d1 | ||
|
|
69d702c783 | ||
|
|
dd92cf9e96 | ||
|
|
0cd0b44cdb | ||
|
|
31c28be57a | ||
|
|
38db3a28ea | ||
|
|
09fa2d2c9c | ||
|
|
b786ed07be | ||
|
|
0527c4a1ea | ||
|
|
ec3713bc74 | ||
|
|
9fd5190c70 | ||
|
|
3995b01399 | ||
|
|
3fdb88c7aa | ||
|
|
8e4bb32b77 | ||
|
|
63d6272282 | ||
|
|
40a1377c0b | ||
|
|
e20c944350 | ||
|
|
85b7b10c01 | ||
|
|
35f73bb474 | ||
|
|
ffc9c28ad5 | ||
|
|
fcff206853 | ||
|
|
0e528986ab | ||
|
|
e7e83a4109 | ||
|
|
891543ff0a | ||
|
|
c617190905 | ||
|
|
2c1f20969a | ||
|
|
851ab47f8a | ||
|
|
bbf13e9242 | ||
|
|
05a24ea355 | ||
|
|
be736b3939 | ||
|
|
25c23a2e5f | ||
|
|
3b8ee3954e | ||
|
|
db79167469 | ||
|
|
b37e84dc10 | ||
|
|
4310d34135 | ||
|
|
09c6a3c240 | ||
|
|
796f4090b5 | ||
|
|
19a792bc12 | ||
|
|
a1b1f8138a | ||
|
|
0e627a6e05 | ||
|
|
d2cd33e226 | ||
|
|
2fa5c2581c | ||
|
|
d2260b234c | ||
|
|
832356d56e | ||
|
|
5fd1c07c9d | ||
|
|
4c75358abd | ||
|
|
d520d6cab8 | ||
|
|
737904fa63 | ||
|
|
a3fcc98d6e | ||
|
|
24a7e8500d | ||
|
|
9067902267 | ||
|
|
66c8809799 | ||
|
|
1fc994177f | ||
|
|
78b6450031 | ||
|
|
b4cb375a02 | ||
|
|
33e5c85503 | ||
|
|
9e8240a736 | ||
|
|
37afd35b6f | ||
|
|
6364c541ea | ||
|
|
8ec6b07690 | ||
|
|
7101ec09ed | ||
|
|
2c5efddf6c | ||
|
|
a37bdffcd9 | ||
|
|
e95ab36f76 | ||
|
|
f809bd3a62 | ||
|
|
d4e71e431b | ||
|
|
de807f8538 | ||
|
|
80d2889217 | ||
|
|
9e8516c2df | ||
|
|
09f2bc28d2 | ||
|
|
be320c5501 | ||
|
|
2bbf7b2194 | ||
|
|
ab184c01d8 | ||
|
|
2c114e1a4a | ||
|
|
ec4cbbd004 | ||
|
|
f75091a1c5 | ||
|
|
98b59a1024 | ||
|
|
0ef06fd298 | ||
|
|
986346a0e9 | ||
|
|
2a65331573 | ||
|
|
45d0860448 | ||
|
|
ea6eacb400 |
10
.github/translators.txt
vendored
10
.github/translators.txt
vendored
@@ -288,3 +288,13 @@ Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
|
||||
구인회 (laskdjlaskdj12) :: Korean
|
||||
LiZerui (CNLiZerui) :: Chinese Traditional
|
||||
Fabrice Boyer (FabriceBoyer) :: French
|
||||
mikael (bitcanon) :: Swedish
|
||||
Matthias Mai (schnapsidee) :: German
|
||||
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
|
||||
|
||||
4
.github/workflows/analyse-php.yml
vendored
4
.github/workflows/analyse-php.yml
vendored
@@ -18,10 +18,10 @@ jobs:
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-8.1
|
||||
|
||||
6
.github/workflows/test-migrations.yml
vendored
6
.github/workflows/test-migrations.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4', '8.0', '8.1']
|
||||
php: ['7.4', '8.0', '8.1', '8.2']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
@@ -21,10 +21,10 @@ jobs:
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
|
||||
6
.github/workflows/test-php.yml
vendored
6
.github/workflows/test-php.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4', '8.0', '8.1']
|
||||
php: ['7.4', '8.0', '8.1', '8.2']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
@@ -21,10 +21,10 @@ jobs:
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
|
||||
@@ -15,6 +15,8 @@ class ActivityQueries
|
||||
{
|
||||
protected PermissionApplicator $permissions;
|
||||
|
||||
protected array $fieldsForLists = ['id', 'type', 'detail', 'activities.entity_type', 'activities.entity_id', 'user_id', 'created_at'];
|
||||
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
@@ -25,9 +27,11 @@ class ActivityQueries
|
||||
*/
|
||||
public function latest(int $count = 20, int $page = 0): array
|
||||
{
|
||||
$query = Activity::query()->select($this->fieldsForLists);
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->restrictEntityRelationQuery($query, 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->whereNotNull('activities.entity_id')
|
||||
->with(['user', 'entity'])
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
@@ -78,10 +82,12 @@ class ActivityQueries
|
||||
*/
|
||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
$query = Activity::query()->select($this->fieldsForLists);
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->restrictEntityRelationQuery($query, 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->where('user_id', '=', $user->id)
|
||||
->whereNotNull('activities.entity_id')
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
30
app/Actions/Queries/WebhooksAllPaginatedAndSorted.php
Normal file
30
app/Actions/Queries/WebhooksAllPaginatedAndSorted.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -20,19 +21,26 @@ class TagRepo
|
||||
/**
|
||||
* Start a query against all tags in the system.
|
||||
*/
|
||||
public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
|
||||
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
|
||||
{
|
||||
$searchTerm = $listOptions->getSearch();
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'name' && $nameFilter) {
|
||||
$sort = 'value';
|
||||
}
|
||||
|
||||
$entityTypeCol = DB::getTablePrefix() . 'tags.entity_type';
|
||||
$query = Tag::query()
|
||||
->select([
|
||||
'name',
|
||||
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
|
||||
DB::raw('COUNT(id) as usages'),
|
||||
DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'page', 1, 0)) as page_count"),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'chapter', 1, 0)) as chapter_count"),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'book', 1, 0)) as book_count"),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'bookshelf', 1, 0)) as shelf_count"),
|
||||
])
|
||||
->orderBy($nameFilter ? 'value' : 'name');
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
if ($nameFilter) {
|
||||
$query->where('name', '=', $nameFilter);
|
||||
|
||||
@@ -4,21 +4,29 @@ 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 $query;
|
||||
protected $request;
|
||||
protected $fields;
|
||||
protected Builder $query;
|
||||
protected Request $request;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $fields;
|
||||
|
||||
/**
|
||||
* @var array<callable>
|
||||
*/
|
||||
protected $resultModifiers = [];
|
||||
protected array $resultModifiers = [];
|
||||
|
||||
protected $filterOperators = [
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $filterOperators = [
|
||||
'eq' => '=',
|
||||
'ne' => '!=',
|
||||
'gt' => '>',
|
||||
@@ -62,9 +70,9 @@ class ListingResponseBuilder
|
||||
/**
|
||||
* Add a callback to modify each element of the results.
|
||||
*
|
||||
* @param (callable(Model)) $modifier
|
||||
* @param (callable(Model): void) $modifier
|
||||
*/
|
||||
public function modifyResults($modifier): void
|
||||
public function modifyResults(callable $modifier): void
|
||||
{
|
||||
$this->resultModifiers[] = $modifier;
|
||||
}
|
||||
|
||||
@@ -67,11 +67,10 @@ class OidcJwtSigningKey
|
||||
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
|
||||
}
|
||||
|
||||
if (empty($jwk['use'])) {
|
||||
throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected');
|
||||
}
|
||||
|
||||
if ($jwk['use'] !== 'sig') {
|
||||
// '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') {
|
||||
throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
|
||||
}
|
||||
|
||||
|
||||
@@ -15,40 +15,17 @@ use Psr\Http\Client\ClientInterface;
|
||||
*/
|
||||
class OidcProviderSettings
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $issuer;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $clientId;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $clientSecret;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $redirectUri;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $authorizationEndpoint;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $tokenEndpoint;
|
||||
public string $issuer;
|
||||
public string $clientId;
|
||||
public string $clientSecret;
|
||||
public ?string $redirectUri;
|
||||
public ?string $authorizationEndpoint;
|
||||
public ?string $tokenEndpoint;
|
||||
|
||||
/**
|
||||
* @var string[]|array[]
|
||||
*/
|
||||
public $keys = [];
|
||||
public ?array $keys = [];
|
||||
|
||||
public function __construct(array $settings)
|
||||
{
|
||||
@@ -164,9 +141,10 @@ class OidcProviderSettings
|
||||
protected function filterKeys(array $keys): array
|
||||
{
|
||||
return array_filter($keys, function (array $key) {
|
||||
$alg = $key['alg'] ?? null;
|
||||
$alg = $key['alg'] ?? 'RS256';
|
||||
$use = $key['use'] ?? 'sig';
|
||||
|
||||
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
|
||||
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ class OidcService
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
return [
|
||||
'url' => $provider->getAuthorizationUrl(),
|
||||
'state' => $provider->getState(),
|
||||
|
||||
18
app/Auth/Permissions/CollapsedPermission.php
Normal file
18
app/Auth/Permissions/CollapsedPermission.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property ?int $role_id
|
||||
* @property ?int $user_id
|
||||
* @property string $entity_type
|
||||
* @property int $entity_id
|
||||
* @property bool $view
|
||||
*/
|
||||
class CollapsedPermission extends Model
|
||||
{
|
||||
protected $table = 'entity_permissions_collapsed';
|
||||
}
|
||||
278
app/Auth/Permissions/CollapsedPermissionBuilder.php
Normal file
278
app/Auth/Permissions/CollapsedPermissionBuilder.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Collapsed permissions act as a "flattened" view of entity-level permissions in the system
|
||||
* so inheritance does not have to managed as part of permission querying.
|
||||
*/
|
||||
class CollapsedPermissionBuilder
|
||||
{
|
||||
/**
|
||||
* Re-generate all collapsed permissions from scratch.
|
||||
*/
|
||||
public function rebuildForAll()
|
||||
{
|
||||
DB::table('entity_permissions_collapsed')->truncate();
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) {
|
||||
$this->buildForBooks($books, false);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->withTrashed()
|
||||
->select(['id'])
|
||||
->chunk(50, function (EloquentCollection $shelves) {
|
||||
$this->generateCollapsedPermissions($shelves->all());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the collapsed permissions for a particular entity.
|
||||
*/
|
||||
public function rebuildForEntity(Entity $entity)
|
||||
{
|
||||
$entities = [$entity];
|
||||
if ($entity instanceof Book) {
|
||||
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
|
||||
$this->buildForBooks($books, true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BookChild $entity */
|
||||
if ($entity->book) {
|
||||
$entities[] = $entity->book;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$entities[] = $entity->chapter;
|
||||
}
|
||||
|
||||
if ($entity instanceof Chapter) {
|
||||
foreach ($entity->pages as $page) {
|
||||
$entities[] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
$this->buildForEntities($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query for fetching a book with its children.
|
||||
*/
|
||||
protected function bookFetchQuery(): Builder
|
||||
{
|
||||
return Book::query()->withTrashed()
|
||||
->select(['id'])->with([
|
||||
'chapters' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'book_id']);
|
||||
},
|
||||
'pages' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'book_id', 'chapter_id']);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build collapsed permissions for the given books.
|
||||
*/
|
||||
protected function buildForBooks(EloquentCollection $books, bool $deleteOld)
|
||||
{
|
||||
$entities = clone $books;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($books->all() as $book) {
|
||||
foreach ($book->getRelation('chapters') as $chapter) {
|
||||
$entities->push($chapter);
|
||||
}
|
||||
foreach ($book->getRelation('pages') as $page) {
|
||||
$entities->push($page);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteOld) {
|
||||
$this->deleteForEntities($entities->all());
|
||||
}
|
||||
|
||||
$this->generateCollapsedPermissions($entities->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the collapsed permissions for a collection of entities.
|
||||
*/
|
||||
protected function buildForEntities(array $entities)
|
||||
{
|
||||
$this->deleteForEntities($entities);
|
||||
$this->generateCollapsedPermissions($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored collapsed permissions for a list of entities.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function deleteForEntities(array $entities)
|
||||
{
|
||||
$simpleEntities = $this->entitiesToSimpleEntities($entities);
|
||||
$idsByType = $this->entitiesToTypeIdMap($simpleEntities);
|
||||
|
||||
DB::transaction(function () use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
foreach (array_chunk($ids, 1000) as $idChunk) {
|
||||
DB::table('entity_permissions_collapsed')
|
||||
->where('entity_type', '=', $type)
|
||||
->whereIn('entity_id', $idChunk)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given list of entities into "SimpleEntityData" representations
|
||||
* for faster usage and property access.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*
|
||||
* @return SimpleEntityData[]
|
||||
*/
|
||||
protected function entitiesToSimpleEntities(array $entities): array
|
||||
{
|
||||
$simpleEntities = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$attrs = $entity->getAttributes();
|
||||
$simple = new SimpleEntityData();
|
||||
$simple->id = $attrs['id'];
|
||||
$simple->type = $entity->getMorphClass();
|
||||
$simple->book_id = $attrs['book_id'] ?? null;
|
||||
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
||||
$simpleEntities[] = $simple;
|
||||
}
|
||||
|
||||
return $simpleEntities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create & Save collapsed entity permissions.
|
||||
*
|
||||
* @param Entity[] $originalEntities
|
||||
*/
|
||||
protected function generateCollapsedPermissions(array $originalEntities)
|
||||
{
|
||||
$entities = $this->entitiesToSimpleEntities($originalEntities);
|
||||
$collapsedPermData = [];
|
||||
|
||||
// Fetch related entity permissions
|
||||
$permissions = $this->getEntityPermissionsForEntities($entities);
|
||||
|
||||
// Create a mapping of explicit entity permissions
|
||||
$permissionMap = new EntityPermissionMap($permissions);
|
||||
|
||||
// Create Joint Permission Data
|
||||
foreach ($entities as $entity) {
|
||||
array_push($collapsedPermData, ...$this->createCollapsedPermissionData($entity, $permissionMap));
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($collapsedPermData) {
|
||||
foreach (array_chunk($collapsedPermData, 1000) as $dataChunk) {
|
||||
DB::table('entity_permissions_collapsed')->insert($dataChunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create collapsed permission data for the given entity using the given permission map.
|
||||
*/
|
||||
protected function createCollapsedPermissionData(SimpleEntityData $entity, EntityPermissionMap $permissionMap): array
|
||||
{
|
||||
$chain = [
|
||||
$entity->type . ':' . $entity->id,
|
||||
$entity->chapter_id ? ('chapter:' . $entity->chapter_id) : null,
|
||||
$entity->book_id ? ('book:' . $entity->book_id) : null,
|
||||
];
|
||||
|
||||
$permissionData = [];
|
||||
$overridesApplied = [];
|
||||
|
||||
foreach ($chain as $entityTypeId) {
|
||||
if ($entityTypeId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permissions = $permissionMap->getForEntity($entityTypeId);
|
||||
foreach ($permissions as $permission) {
|
||||
$related = $permission->getAssignedType() . ':' . $permission->getAssignedTypeId();
|
||||
if (!isset($overridesApplied[$related])) {
|
||||
$permissionData[] = [
|
||||
'role_id' => $permission->role_id,
|
||||
'user_id' => $permission->user_id,
|
||||
'view' => $permission->view,
|
||||
'entity_type' => $entity->type,
|
||||
'entity_id' => $entity->id,
|
||||
];
|
||||
$overridesApplied[$related] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $permissionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* From the given entity list, provide back a mapping of entity types to
|
||||
* the ids of that given type. The type used is the DB morph class.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return array<string, int[]>
|
||||
*/
|
||||
protected function entitiesToTypeIdMap(array $entities): array
|
||||
{
|
||||
$idsByType = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if (!isset($idsByType[$entity->type])) {
|
||||
$idsByType[$entity->type] = [];
|
||||
}
|
||||
|
||||
$idsByType[$entity->type][] = $entity->id;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $role_id
|
||||
* @property int $user_id
|
||||
* @property int $entity_id
|
||||
* @property string $entity_type
|
||||
* @property boolean $view
|
||||
@@ -21,17 +22,9 @@ class EntityPermission extends Model
|
||||
{
|
||||
public const PERMISSIONS = ['view', 'create', 'update', 'delete'];
|
||||
|
||||
protected $fillable = ['role_id', 'view', 'create', 'update', 'delete'];
|
||||
protected $fillable = ['role_id', 'user_id', 'view', 'create', 'update', 'delete'];
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Get this restriction's attached entity.
|
||||
*/
|
||||
public function restrictable(): MorphTo
|
||||
{
|
||||
return $this->morphTo('restrictable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role assigned to this entity permission.
|
||||
*/
|
||||
@@ -39,4 +32,38 @@ class EntityPermission extends Model
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user assigned to this entity permission.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of entity permission this is.
|
||||
* Will be one of: user, role, fallback
|
||||
*/
|
||||
public function getAssignedType(): string
|
||||
{
|
||||
if ($this->user_id) {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
if ($this->role_id) {
|
||||
return 'role';
|
||||
}
|
||||
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID for the assigned type of permission.
|
||||
* (Role/User ID). Defaults to 0 for fallback.
|
||||
*/
|
||||
public function getAssignedTypeId(): int
|
||||
{
|
||||
return $this->user_id ?? $this->role_id ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Auth/Permissions/EntityPermissionMap.php
Normal file
37
app/Auth/Permissions/EntityPermissionMap.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
class EntityPermissionMap
|
||||
{
|
||||
protected array $map = [];
|
||||
|
||||
/**
|
||||
* @param EntityPermission[] $permissions
|
||||
*/
|
||||
public function __construct(array $permissions = [])
|
||||
{
|
||||
foreach ($permissions as $entityPermission) {
|
||||
$this->addPermission($entityPermission);
|
||||
}
|
||||
}
|
||||
|
||||
protected function addPermission(EntityPermission $permission)
|
||||
{
|
||||
$entityCombinedId = $permission->entity_type . ':' . $permission->entity_id;
|
||||
|
||||
if (!isset($this->map[$entityCombinedId])) {
|
||||
$this->map[$entityCombinedId] = [];
|
||||
}
|
||||
|
||||
$this->map[$entityCombinedId][] = $permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EntityPermission[]
|
||||
*/
|
||||
public function getForEntity(string $typeIdString): array
|
||||
{
|
||||
return $this->map[$typeIdString] ?? [];
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
|
||||
class JointPermission extends Model
|
||||
{
|
||||
protected $primaryKey = null;
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Get the role that this points to.
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity this points to.
|
||||
*/
|
||||
public function entity(): MorphOne
|
||||
{
|
||||
return $this->morphOne(Entity::class, 'entity');
|
||||
}
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Joint permissions provide a pre-query "cached" table of view permissions for all core entity
|
||||
* types for all roles in the system. This class generates out that table for different scenarios.
|
||||
*/
|
||||
class JointPermissionBuilder
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<int, SimpleEntityData>>
|
||||
*/
|
||||
protected $entityCache;
|
||||
|
||||
/**
|
||||
* Re-generate all entity permission from scratch.
|
||||
*/
|
||||
public function rebuildForAll()
|
||||
{
|
||||
JointPermission::query()->truncate();
|
||||
|
||||
// Get all roles (Should be the most limited dimension)
|
||||
$roles = Role::query()->with('permissions')->get()->all();
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
|
||||
$this->buildJointPermissionsForBooks($books, $roles);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->withTrashed()->select(['id', 'owned_by'])
|
||||
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
|
||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entity jointPermissions for a particular entity.
|
||||
*/
|
||||
public function rebuildForEntity(Entity $entity)
|
||||
{
|
||||
$entities = [$entity];
|
||||
if ($entity instanceof Book) {
|
||||
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
|
||||
$this->buildJointPermissionsForBooks($books, Role::query()->with('permissions')->get()->all(), true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BookChild $entity */
|
||||
if ($entity->book) {
|
||||
$entities[] = $entity->book;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$entities[] = $entity->chapter;
|
||||
}
|
||||
|
||||
if ($entity instanceof Chapter) {
|
||||
foreach ($entity->pages as $page) {
|
||||
$entities[] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
$this->buildJointPermissionsForEntities($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the entity jointPermissions for a particular role.
|
||||
*/
|
||||
public function rebuildForRole(Role $role)
|
||||
{
|
||||
$roles = [$role];
|
||||
$role->jointPermissions()->delete();
|
||||
$role->load('permissions');
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
|
||||
$this->buildJointPermissionsForBooks($books, $roles);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->select(['id', 'owned_by'])
|
||||
->chunk(50, function ($shelves) use ($roles) {
|
||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
protected function bookFetchQuery(): Builder
|
||||
{
|
||||
return Book::query()->withTrashed()
|
||||
->select(['id', 'owned_by'])->with([
|
||||
'chapters' => function ($query) {
|
||||
},
|
||||
'pages' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build joint permissions for the given book and role combinations.
|
||||
*/
|
||||
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
|
||||
{
|
||||
$entities = clone $books;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($books->all() as $book) {
|
||||
foreach ($book->getRelation('chapters') as $chapter) {
|
||||
$entities->push($chapter);
|
||||
}
|
||||
foreach ($book->getRelation('pages') as $page) {
|
||||
$entities->push($page);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteOld) {
|
||||
$this->deleteManyJointPermissionsForEntities($entities->all());
|
||||
}
|
||||
|
||||
$this->createManyJointPermissions($entities->all(), $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entity jointPermissions for a collection of entities.
|
||||
*/
|
||||
protected function buildJointPermissionsForEntities(array $entities)
|
||||
{
|
||||
$roles = Role::query()->get()->values()->all();
|
||||
$this->deleteManyJointPermissionsForEntities($entities);
|
||||
$this->createManyJointPermissions($entities, $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all the entity jointPermissions for a list of entities.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function deleteManyJointPermissionsForEntities(array $entities)
|
||||
{
|
||||
$simpleEntities = $this->entitiesToSimpleEntities($entities);
|
||||
$idsByType = $this->entitiesToTypeIdMap($simpleEntities);
|
||||
|
||||
DB::transaction(function () use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
foreach (array_chunk($ids, 1000) as $idChunk) {
|
||||
DB::table('joint_permissions')
|
||||
->where('entity_type', '=', $type)
|
||||
->whereIn('entity_id', $idChunk)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Entity[] $entities
|
||||
*
|
||||
* @return SimpleEntityData[]
|
||||
*/
|
||||
protected function entitiesToSimpleEntities(array $entities): array
|
||||
{
|
||||
$simpleEntities = [];
|
||||
|
||||
foreach ($entities as $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;
|
||||
}
|
||||
|
||||
return $simpleEntities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create & Save entity jointPermissions for many entities and roles.
|
||||
*
|
||||
* @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 = $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 = [];
|
||||
foreach ($roles as $role) {
|
||||
foreach ($role->permissions as $permission) {
|
||||
$rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Create Joint Permission Data
|
||||
foreach ($entities as $entity) {
|
||||
foreach ($roles as $role) {
|
||||
$jointPermissions[] = $this->createJointPermissionData(
|
||||
$entity,
|
||||
$role->getRawAttribute('id'),
|
||||
$permissionMap,
|
||||
$rolePermissionMap,
|
||||
$role->system_name === 'admin'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($jointPermissions) {
|
||||
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
|
||||
DB::table('joint_permissions')->insert($jointPermissionChunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* From the given entity list, provide back a mapping of entity types to
|
||||
* the ids of that given type. The type used is the DB morph class.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return array<string, int[]>
|
||||
*/
|
||||
protected function entitiesToTypeIdMap(array $entities): array
|
||||
{
|
||||
$idsByType = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if (!isset($idsByType[$entity->type])) {
|
||||
$idsByType[$entity->type] = [];
|
||||
}
|
||||
|
||||
$idsByType[$entity->type][] = $entity->id;
|
||||
}
|
||||
|
||||
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, array $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
|
||||
{
|
||||
$permissionPrefix = $entity->type . '-view';
|
||||
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
|
||||
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
|
||||
|
||||
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, bool $permissionAll, bool $permissionOwn): array
|
||||
{
|
||||
return [
|
||||
'entity_id' => $entity->id,
|
||||
'entity_type' => $entity->type,
|
||||
'has_permission' => $permissionAll,
|
||||
'has_permission_own' => $permissionOwn,
|
||||
'owned_by' => $entity->owned_by,
|
||||
'role_id' => $roleId,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use BookStack\Traits\HasOwner;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class PermissionApplicator
|
||||
@@ -48,7 +50,7 @@ class PermissionApplicator
|
||||
return $hasRolePermission;
|
||||
}
|
||||
|
||||
$hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action);
|
||||
$hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $user->id, $action);
|
||||
|
||||
return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions;
|
||||
}
|
||||
@@ -57,7 +59,7 @@ class PermissionApplicator
|
||||
* Check if there are permissions that are applicable for the given entity item, action and roles.
|
||||
* Returns null when no entity permissions are in force.
|
||||
*/
|
||||
protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
|
||||
protected function hasEntityPermission(Entity $entity, array $userRoleIds, int $userId, string $action): ?bool
|
||||
{
|
||||
$this->ensureValidEntityAction($action);
|
||||
|
||||
@@ -66,38 +68,63 @@ class PermissionApplicator
|
||||
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];
|
||||
// The array order here is very important due to the fact we walk up the chain
|
||||
// in the flattening loop below. Earlier items in the chain have higher priority.
|
||||
$typeIdList = [$entity->getMorphClass() . ':' . $entity->id];
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$chain[] = $entity->chapter;
|
||||
$typeIdList[] = 'chapter:' . $entity->chapter_id;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page || $entity instanceof Chapter) {
|
||||
$chain[] = $entity->book;
|
||||
$typeIdList[] = 'book:' . $entity->book_id;
|
||||
}
|
||||
|
||||
foreach ($chain as $currentEntity) {
|
||||
$allowedByRoleId = $currentEntity->permissions()
|
||||
->whereIn('role_id', [0, ...$userRoleIds])
|
||||
->pluck($action, 'role_id');
|
||||
$relevantPermissions = EntityPermission::query()
|
||||
->where(function (Builder $query) use ($typeIdList) {
|
||||
foreach ($typeIdList as $typeId) {
|
||||
$query->orWhere(function (Builder $query) use ($typeId) {
|
||||
[$type, $id] = explode(':', $typeId);
|
||||
$query->where('entity_type', '=', $type)
|
||||
->where('entity_id', '=', $id);
|
||||
});
|
||||
}
|
||||
})->where(function (Builder $query) use ($userRoleIds, $userId) {
|
||||
$query->whereIn('role_id', $userRoleIds)
|
||||
->orWhere('user_id', '=', $userId)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->whereNull(['role_id', 'user_id']);
|
||||
});
|
||||
})->get(['entity_id', 'entity_type', 'role_id', 'user_id', $action])
|
||||
->all();
|
||||
|
||||
// Continue up the chain if no applicable entity permission overrides.
|
||||
if ($allowedByRoleId->isEmpty()) {
|
||||
continue;
|
||||
$permissionMap = new EntityPermissionMap($relevantPermissions);
|
||||
$permitsByType = ['user' => [], 'fallback' => [], 'role' => []];
|
||||
|
||||
// Collapse and simplify permission structure
|
||||
foreach ($typeIdList as $typeId) {
|
||||
$permissions = $permissionMap->getForEntity($typeId);
|
||||
foreach ($permissions as $permission) {
|
||||
$related = $permission->getAssignedType();
|
||||
$relatedId = $permission->getAssignedTypeId();
|
||||
if (!isset($permitsByType[$related][$relatedId])) {
|
||||
$permitsByType[$related][$relatedId] = $permission->$action;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Return user-level permission if exists
|
||||
if (count($permitsByType['user']) > 0) {
|
||||
return boolval(array_values($permitsByType['user'])[0]);
|
||||
}
|
||||
|
||||
// Otherwise, return the default "Other roles" fallback value.
|
||||
return $allowedByRoleId->get(0);
|
||||
// Return grant or reject from role-level if exists
|
||||
if (count($permitsByType['role']) > 0) {
|
||||
return boolval(max($permitsByType['role']));
|
||||
}
|
||||
|
||||
// Return fallback permission if exists
|
||||
if (count($permitsByType['fallback']) > 0) {
|
||||
return boolval($permitsByType['fallback'][0]);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -113,7 +140,10 @@ class PermissionApplicator
|
||||
|
||||
$permissionQuery = EntityPermission::query()
|
||||
->where($action, '=', true)
|
||||
->whereIn('role_id', $this->getCurrentUserRoleIds());
|
||||
->where(function (Builder $query) {
|
||||
$query->whereIn('role_id', $this->getCurrentUserRoleIds())
|
||||
->orWhere('user_id', '=', $this->currentUser()->id);
|
||||
});
|
||||
|
||||
if (!empty($entityClass)) {
|
||||
/** @var Entity $entityInstance */
|
||||
@@ -130,18 +160,140 @@ class PermissionApplicator
|
||||
* Limit the given entity query so that the query will only
|
||||
* return items that the user has view permission for.
|
||||
*/
|
||||
public function restrictEntityQuery(Builder $query): Builder
|
||||
public function restrictEntityQuery(Builder $query, string $morphClass): Builder
|
||||
{
|
||||
return $query->where(function (Builder $parentQuery) {
|
||||
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) {
|
||||
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoleIds())
|
||||
->where(function (Builder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
$this->applyPermissionsToQuery($query, $query->getModel()->getTable(), $morphClass, 'id', '');
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyPermissionsToQuery($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn): void
|
||||
{
|
||||
if ($this->currentUser()->hasSystemRole('admin')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyFallbackJoin($query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
$this->applyRoleJoin($query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
$this->applyUserJoin($query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
$this->applyPermissionWhereFilter($query, $queryTable, $entityTypeLimiter, $entityTypeColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the where condition to a permission restricting query, to limit based upon the values of the joined
|
||||
* permission data. Query must have joins pre-applied.
|
||||
* Either entityTypeLimiter or entityTypeColumn should be supplied, with the other empty.
|
||||
* Both should not be applied since that would conflict upon intent.
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyPermissionWhereFilter($query, string $queryTable, string $entityTypeLimiter, string $entityTypeColumn)
|
||||
{
|
||||
$abilities = ['all' => [], 'own' => []];
|
||||
$types = $entityTypeLimiter ? [$entityTypeLimiter] : ['page', 'chapter', 'bookshelf', 'book'];
|
||||
$fullEntityTypeColumn = $queryTable . '.' . $entityTypeColumn;
|
||||
foreach ($types as $type) {
|
||||
$abilities['all'][$type] = userCan($type . '-view-all');
|
||||
$abilities['own'][$type] = userCan($type . '-view-own');
|
||||
}
|
||||
|
||||
$abilities['all'] = array_filter($abilities['all']);
|
||||
$abilities['own'] = array_filter($abilities['own']);
|
||||
|
||||
$query->where(function (Builder $query) use ($abilities, $fullEntityTypeColumn, $entityTypeColumn) {
|
||||
$query->where('perms_user', '=', 1)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->whereNull('perms_user')->where('perms_role', '=', 1);
|
||||
})->orWhere(function (Builder $query) {
|
||||
$query->whereNull(['perms_user', 'perms_role'])
|
||||
->where('perms_fallback', '=', 1);
|
||||
});
|
||||
|
||||
if (count($abilities['all']) > 0) {
|
||||
$query->orWhere(function (Builder $query) use ($abilities, $fullEntityTypeColumn, $entityTypeColumn) {
|
||||
$query->whereNull(['perms_user', 'perms_role', 'perms_fallback']);
|
||||
if ($entityTypeColumn) {
|
||||
$query->whereIn($fullEntityTypeColumn, array_keys($abilities['all']));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (count($abilities['own']) > 0) {
|
||||
$query->orWhere(function (Builder $query) use ($abilities, $fullEntityTypeColumn, $entityTypeColumn) {
|
||||
$query->whereNull(['perms_user', 'perms_role', 'perms_fallback'])
|
||||
->where('owned_by', '=', $this->currentUser()->id);
|
||||
if ($entityTypeColumn) {
|
||||
$query->whereIn($fullEntityTypeColumn, array_keys($abilities['all']));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyPermissionJoin(callable $joinCallable, string $subAlias, $query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$joinCondition = $this->getJoinCondition($queryTable, $subAlias, $entityIdColumn, $entityTypeColumn);
|
||||
|
||||
$query->joinSub(function (QueryBuilder $joinQuery) use ($joinCallable, $entityTypeLimiter) {
|
||||
$joinQuery->select(['entity_id', 'entity_type'])->from('entity_permissions_collapsed')
|
||||
->groupBy('entity_id', 'entity_type');
|
||||
$joinCallable($joinQuery);
|
||||
|
||||
if ($entityTypeLimiter) {
|
||||
$joinQuery->where('entity_type', '=', $entityTypeLimiter);
|
||||
}
|
||||
}, $subAlias, $joinCondition, null, null, 'left');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyUserJoin($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$this->applyPermissionJoin(function (QueryBuilder $joinQuery) {
|
||||
$joinQuery->selectRaw('max(view) as perms_user')
|
||||
->where('user_id', '=', $this->currentUser()->id);
|
||||
}, 'p_u', $query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyRoleJoin($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$this->applyPermissionJoin(function (QueryBuilder $joinQuery) {
|
||||
$joinQuery->selectRaw('max(view) as perms_role')
|
||||
->whereIn('role_id', $this->getCurrentUserRoleIds());
|
||||
}, 'p_r', $query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyFallbackJoin($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$this->applyPermissionJoin(function (QueryBuilder $joinQuery) {
|
||||
$joinQuery->selectRaw('max(view) as perms_fallback')
|
||||
->whereNull(['role_id', 'user_id']);
|
||||
}, 'p_f', $query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
}
|
||||
|
||||
protected function getJoinCondition(string $queryTable, string $joinTableName, string $entityIdColumn, string $entityTypeColumn): callable
|
||||
{
|
||||
return function (JoinClause $join) use ($queryTable, $joinTableName, $entityIdColumn, $entityTypeColumn) {
|
||||
$join->on($queryTable . '.' . $entityIdColumn, '=', $joinTableName . '.entity_id');
|
||||
if ($entityTypeColumn) {
|
||||
$join->on($queryTable . '.' . $entityTypeColumn, '=', $joinTableName . '.entity_type');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the given page query to ensure draft items are not visible
|
||||
* unless created by the given user.
|
||||
@@ -166,30 +318,23 @@ class PermissionApplicator
|
||||
*/
|
||||
public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
|
||||
$pageMorphClass = (new Page())->getMorphClass();
|
||||
|
||||
$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);
|
||||
$query->leftJoinSub(function (QueryBuilder $query) {
|
||||
$query->select(['id as entity_id', DB::raw("'page' as entity_type"), 'owned_by', 'deleted_at', 'draft'])->from('pages');
|
||||
$tablesByType = ['page' => 'pages', 'book' => 'books', 'chapter' => 'chapters', 'bookshelf' => 'bookshelves'];
|
||||
foreach ($tablesByType as $type => $table) {
|
||||
$query->unionAll(function (QueryBuilder $query) use ($type, $table) {
|
||||
$query->select(['id as entity_id', DB::raw("'{$type}' as entity_type"), 'owned_by', 'deleted_at', DB::raw('0 as draft')])->from($table);
|
||||
});
|
||||
}
|
||||
}, 'entities', function (JoinClause $join) use ($tableName, $entityIdColumn, $entityTypeColumn) {
|
||||
$join->on($tableName . '.' . $entityIdColumn, '=', 'entities.entity_id')
|
||||
->on($tableName . '.' . $entityTypeColumn, '=', 'entities.entity_type');
|
||||
});
|
||||
|
||||
return $q;
|
||||
$this->applyPermissionsToQuery($query, $tableName, '', $entityIdColumn, $entityTypeColumn);
|
||||
// TODO - Test page draft access (Might allow drafts which should not be seen)
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,50 +345,12 @@ class PermissionApplicator
|
||||
*/
|
||||
public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
|
||||
{
|
||||
$fullPageIdColumn = $tableName . '.' . $pageIdColumn;
|
||||
$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);
|
||||
});
|
||||
$this->applyPermissionsToQuery($query, $tableName, $morphClass, $pageIdColumn, '');
|
||||
// TODO - Draft display
|
||||
// TODO - Likely need owned_by entity join workaround as used above
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,19 +21,32 @@ class PermissionFormData
|
||||
{
|
||||
return $this->entity->permissions()
|
||||
->with('role')
|
||||
->where('role_id', '!=', 0)
|
||||
->whereNotNull('role_id')
|
||||
->get()
|
||||
->sortBy('role.display_name')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permissions with assigned users.
|
||||
*/
|
||||
public function permissionsWithUsers(): array
|
||||
{
|
||||
return $this->entity->permissions()
|
||||
->with('user')
|
||||
->whereNotNull('user_id')
|
||||
->get()
|
||||
->sortBy('user.name')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles that don't yet have specific permissions for the
|
||||
* entity we're managing permissions for.
|
||||
*/
|
||||
public function rolesNotAssigned(): array
|
||||
{
|
||||
$assigned = $this->entity->permissions()->pluck('role_id');
|
||||
$assigned = $this->entity->permissions()->whereNotNull('role_id')->pluck('role_id');
|
||||
return Role::query()
|
||||
->where('system_name', '!=', 'admin')
|
||||
->whereNotIn('id', $assigned)
|
||||
@@ -49,20 +62,19 @@ class PermissionFormData
|
||||
{
|
||||
/** @var ?EntityPermission $permission */
|
||||
$permission = $this->entity->permissions()
|
||||
->where('role_id', '=', 0)
|
||||
->whereNull(['role_id', 'user_id'])
|
||||
->first();
|
||||
return $permission ?? (new EntityPermission());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the "Everyone Else" role entry.
|
||||
* Check if the "Everyone else" option is inheriting default role system permissions.
|
||||
* Is determined by any system entity_permission existing for the current entity.
|
||||
*/
|
||||
public function everyoneElseRole(): Role
|
||||
public function everyoneElseInheriting(): bool
|
||||
{
|
||||
return (new Role())->forceFill([
|
||||
'id' => 0,
|
||||
'display_name' => trans('entities.permissions_role_everyone_else'),
|
||||
'description' => trans('entities.permissions_role_everyone_else_desc'),
|
||||
]);
|
||||
return !$this->entity->permissions()
|
||||
->whereNull(['role_id', 'user_id'])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class PermissionsRepo
|
||||
{
|
||||
protected JointPermissionBuilder $permissionBuilder;
|
||||
protected $systemRoles = ['admin', 'public'];
|
||||
protected CollapsedPermissionBuilder $permissionBuilder;
|
||||
protected array $systemRoles = ['admin', 'public'];
|
||||
|
||||
/**
|
||||
* PermissionsRepo constructor.
|
||||
*/
|
||||
public function __construct(JointPermissionBuilder $permissionBuilder)
|
||||
public function __construct(CollapsedPermissionBuilder $permissionBuilder)
|
||||
{
|
||||
$this->permissionBuilder = $permissionBuilder;
|
||||
}
|
||||
@@ -57,7 +57,6 @@ class PermissionsRepo
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
$this->assignRolePermissions($role, $permissions);
|
||||
$this->permissionBuilder->rebuildForRole($role);
|
||||
|
||||
Activity::add(ActivityType::ROLE_CREATE, $role);
|
||||
|
||||
@@ -88,7 +87,6 @@ class PermissionsRepo
|
||||
$role->fill($roleData);
|
||||
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
|
||||
$role->save();
|
||||
$this->permissionBuilder->rebuildForRole($role);
|
||||
|
||||
Activity::add(ActivityType::ROLE_UPDATE, $role);
|
||||
}
|
||||
@@ -140,7 +138,7 @@ class PermissionsRepo
|
||||
}
|
||||
|
||||
$role->entityPermissions()->delete();
|
||||
$role->jointPermissions()->delete();
|
||||
$role->collapsedPermissions()->delete();
|
||||
Activity::add(ActivityType::ROLE_DELETE, $role);
|
||||
$role->delete();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ class SimpleEntityData
|
||||
{
|
||||
public int $id;
|
||||
public string $type;
|
||||
public int $owned_by;
|
||||
public ?int $book_id;
|
||||
public ?int $chapter_id;
|
||||
}
|
||||
|
||||
35
app/Auth/Queries/RolesAllPaginatedAndSorted.php
Normal file
35
app/Auth/Queries/RolesAllPaginatedAndSorted.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
@@ -11,23 +12,23 @@ use Illuminate\Pagination\LengthAwarePaginator;
|
||||
* user is assumed to be trusted. (Admin users).
|
||||
* Email search can be abused to extract email addresses.
|
||||
*/
|
||||
class AllUsersPaginatedAndSorted
|
||||
class UsersAllPaginatedAndSorted
|
||||
{
|
||||
/**
|
||||
* @param array{sort: string, order: string, search: string} $sortData
|
||||
*/
|
||||
public function run(int $count, array $sortData): LengthAwarePaginator
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
{
|
||||
$sort = $sortData['sort'];
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'created_at') {
|
||||
$sort = 'users.created_at';
|
||||
}
|
||||
|
||||
$query = User::query()->select(['*'])
|
||||
->scopes(['withLastActivityAt'])
|
||||
->with(['roles', 'avatar'])
|
||||
->withCount('mfaValues')
|
||||
->orderBy($sort, $sortData['order']);
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
if ($sortData['search']) {
|
||||
$term = '%' . $sortData['search'] . '%';
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term);
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use BookStack\Auth\Permissions\CollapsedPermission;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\RolePermission;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
@@ -39,14 +39,6 @@ class Role extends Model implements Loggable
|
||||
return $this->belongsToMany(User::class)->orderBy('name', 'asc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related JointPermissions.
|
||||
*/
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The RolePermissions that belong to the role.
|
||||
*/
|
||||
@@ -63,6 +55,14 @@ class Role extends Model implements Loggable
|
||||
return $this->hasMany(EntityPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related entity collapsed permissions.
|
||||
*/
|
||||
public function collapsedPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CollapsedPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this role has a permission.
|
||||
*/
|
||||
@@ -110,14 +110,6 @@ 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}
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace BookStack\Auth;
|
||||
use BookStack\Actions\Favourite;
|
||||
use BookStack\Api\ApiToken;
|
||||
use BookStack\Auth\Access\Mfa\MfaValue;
|
||||
use BookStack\Auth\Permissions\CollapsedPermission;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Interfaces\Sluggable;
|
||||
@@ -298,6 +300,22 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
}, 'activities', 'users.id', '=', 'activities.user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions assigned to this specific user.
|
||||
*/
|
||||
public function entityPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntityPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related entity collapsed permissions.
|
||||
*/
|
||||
public function collapsedPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CollapsedPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the url for editing this user.
|
||||
*/
|
||||
|
||||
@@ -153,11 +153,16 @@ class UserRepo
|
||||
$user->apiTokens()->delete();
|
||||
$user->favourites()->delete();
|
||||
$user->mfaValues()->delete();
|
||||
$user->collapsedPermissions()->delete();
|
||||
$user->entityPermissions()->delete();
|
||||
$user->delete();
|
||||
|
||||
// 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)) {
|
||||
|
||||
@@ -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', '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', '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'],
|
||||
|
||||
// Application Fallback Locale
|
||||
'fallback_locale' => 'en',
|
||||
|
||||
@@ -26,6 +26,8 @@ return [
|
||||
|
||||
// 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'),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -22,12 +22,12 @@ class RegeneratePermissions extends Command
|
||||
*/
|
||||
protected $description = 'Regenerate all system permissions';
|
||||
|
||||
protected JointPermissionBuilder $permissionBuilder;
|
||||
protected CollapsedPermissionBuilder $permissionBuilder;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*/
|
||||
public function __construct(JointPermissionBuilder $permissionBuilder)
|
||||
public function __construct(CollapsedPermissionBuilder $permissionBuilder)
|
||||
{
|
||||
$this->permissionBuilder = $permissionBuilder;
|
||||
parent::__construct();
|
||||
|
||||
@@ -7,9 +7,9 @@ use BookStack\Actions\Comment;
|
||||
use BookStack\Actions\Favourite;
|
||||
use BookStack\Actions\Tag;
|
||||
use BookStack\Actions\View;
|
||||
use BookStack\Auth\Permissions\CollapsedPermission;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\JointPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Interfaces\Deletable;
|
||||
@@ -69,7 +69,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
|
||||
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query, $this->getMorphClass());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,11 +187,11 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity jointPermissions this is connected to.
|
||||
* Get the entity collapsed permissions this is connected to.
|
||||
*/
|
||||
public function jointPermissions(): MorphMany
|
||||
public function collapsedPermissions(): MorphMany
|
||||
{
|
||||
return $this->morphMany(JointPermission::class, 'entity');
|
||||
return $this->morphMany(CollapsedPermission::class, 'entity');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,7 +292,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function rebuildPermissions()
|
||||
{
|
||||
app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
|
||||
app()->make(CollapsedPermissionBuilder::class)->rebuildForEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -88,8 +88,6 @@ class Page extends BookChild
|
||||
|
||||
/**
|
||||
* Get the current revision for the page if existing.
|
||||
*
|
||||
* @return PageRevision|null
|
||||
*/
|
||||
public function currentRevision(): HasOne
|
||||
{
|
||||
|
||||
@@ -87,14 +87,14 @@ class BaseRepo
|
||||
{
|
||||
if ($coverImage) {
|
||||
$imageType = $entity->coverImageTypeKey();
|
||||
$this->imageRepo->destroyImage($entity->cover);
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
|
||||
$entity->cover()->associate($image);
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
if ($removeImage) {
|
||||
$this->imageRepo->destroyImage($entity->cover);
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$entity->image_id = 0;
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ class BookContents
|
||||
$model->changeBook($newBook->id);
|
||||
}
|
||||
|
||||
if ($chapterChanged) {
|
||||
if ($model instanceof Page && $chapterChanged) {
|
||||
$model->chapter_id = $newChapter->id ?? 0;
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ class BookContents
|
||||
}
|
||||
|
||||
$hasPageEditPermission = userCan('page-update', $model);
|
||||
$newParentInRightLocation = ($newParent instanceof Book || $newParent->book_id === $newBook->id);
|
||||
$newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
|
||||
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
|
||||
$hasNewParentPermission = userCan($newParentPermission, $newParent);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -109,9 +110,11 @@ class Cloner
|
||||
$inputData['tags'] = $this->entityTagsToInputArray($entity);
|
||||
|
||||
// Add a cover to the data if existing on the original entity
|
||||
if ($entity->cover instanceof Image) {
|
||||
$uploadedFile = $this->imageToUploadedFile($entity->cover);
|
||||
$inputData['image'] = $uploadedFile;
|
||||
if ($entity instanceof HasCoverImage) {
|
||||
$cover = $entity->cover()->first();
|
||||
if ($cover) {
|
||||
$inputData['image'] = $this->imageToUploadedFile($cover);
|
||||
}
|
||||
}
|
||||
|
||||
return $inputData;
|
||||
|
||||
@@ -10,7 +10,6 @@ use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Activity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PermissionsUpdater
|
||||
{
|
||||
@@ -58,13 +57,30 @@ class PermissionsUpdater
|
||||
protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): array
|
||||
{
|
||||
$formatted = [];
|
||||
$columnsByType = [
|
||||
'role' => 'role_id',
|
||||
'user' => 'user_id',
|
||||
'fallback' => '',
|
||||
];
|
||||
|
||||
foreach ($permissions as $roleId => $info) {
|
||||
$entityPermissionData = ['role_id' => $roleId];
|
||||
foreach (EntityPermission::PERMISSIONS as $permission) {
|
||||
$entityPermissionData[$permission] = (($info[$permission] ?? false) === "true");
|
||||
foreach ($permissions as $type => $byId) {
|
||||
$column = $columnsByType[$type] ?? null;
|
||||
if (is_null($column)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($byId as $id => $info) {
|
||||
$entityPermissionData = [];
|
||||
|
||||
if (!empty($column)) {
|
||||
$entityPermissionData[$column] = $id;
|
||||
}
|
||||
|
||||
foreach (EntityPermission::PERMISSIONS as $permission) {
|
||||
$entityPermissionData[$permission] = (($info[$permission] ?? false) === "true");
|
||||
}
|
||||
$formatted[] = $entityPermissionData;
|
||||
}
|
||||
$formatted[] = $entityPermissionData;
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
|
||||
@@ -372,7 +372,7 @@ class TrashCan
|
||||
$entity->permissions()->delete();
|
||||
$entity->tags()->delete();
|
||||
$entity->comments()->delete();
|
||||
$entity->jointPermissions()->delete();
|
||||
$entity->collapsedPermissions()->delete();
|
||||
$entity->searchTerms()->delete();
|
||||
$entity->deletions()->delete();
|
||||
$entity->favourites()->delete();
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
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;
|
||||
|
||||
@@ -13,10 +15,15 @@ class AuditLogController extends Controller
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->checkPermission('users-manage');
|
||||
|
||||
$listDetails = [
|
||||
'order' => $request->get('order', 'desc'),
|
||||
$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 = [
|
||||
'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', ''),
|
||||
@@ -25,39 +32,38 @@ class AuditLogController extends Controller
|
||||
|
||||
$query = Activity::query()
|
||||
->with([
|
||||
'entity' => function ($query) {
|
||||
$query->withTrashed();
|
||||
},
|
||||
'entity' => fn ($query) => $query->withTrashed(),
|
||||
'user',
|
||||
])
|
||||
->orderBy($listDetails['sort'], $listDetails['order']);
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder());
|
||||
|
||||
if ($listDetails['event']) {
|
||||
$query->where('type', '=', $listDetails['event']);
|
||||
if ($filters['event']) {
|
||||
$query->where('type', '=', $filters['event']);
|
||||
}
|
||||
if ($listDetails['user']) {
|
||||
$query->where('user_id', '=', $listDetails['user']);
|
||||
if ($filters['user']) {
|
||||
$query->where('user_id', '=', $filters['user']);
|
||||
}
|
||||
|
||||
if ($listDetails['date_from']) {
|
||||
$query->where('created_at', '>=', $listDetails['date_from']);
|
||||
if ($filters['date_from']) {
|
||||
$query->where('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
if ($listDetails['date_to']) {
|
||||
$query->where('created_at', '<=', $listDetails['date_to']);
|
||||
if ($filters['date_to']) {
|
||||
$query->where('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
if ($listDetails['ip']) {
|
||||
$query->where('ip', 'like', $listDetails['ip'] . '%');
|
||||
if ($filters['ip']) {
|
||||
$query->where('ip', 'like', $filters['ip'] . '%');
|
||||
}
|
||||
|
||||
$activities = $query->paginate(100);
|
||||
$activities->appends($listDetails);
|
||||
$activities->appends($request->all());
|
||||
|
||||
$types = DB::table('activities')->select('type')->distinct()->pluck('type');
|
||||
$types = ActivityType::all();
|
||||
$this->setPageTitle(trans('settings.audit'));
|
||||
|
||||
return view('settings.audit', [
|
||||
'activities' => $activities,
|
||||
'listDetails' => $listDetails,
|
||||
'filters' => $filters,
|
||||
'listOptions' => $listOptions,
|
||||
'activityTypes' => $types,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -51,14 +51,28 @@ 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(string $token)
|
||||
public function confirm(Request $request)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'token' => ['required', 'string']
|
||||
]);
|
||||
|
||||
$token = $validated['token'];
|
||||
|
||||
try {
|
||||
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
|
||||
} catch (UserTokenNotFoundException $exception) {
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -35,13 +36,16 @@ class BookController extends Controller
|
||||
/**
|
||||
* Display a listing of the book.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$view = setting()->getForCurrentUser('books_view_type');
|
||||
$sort = setting()->getForCurrentUser('books_sort', 'name');
|
||||
$order = setting()->getForCurrentUser('books_sort_order', 'asc');
|
||||
$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'),
|
||||
]);
|
||||
|
||||
$books = $this->bookRepo->getAllPaginated(18, $sort, $order);
|
||||
$books = $this->bookRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
|
||||
$recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
|
||||
$popular = $this->bookRepo->getPopular(4);
|
||||
$new = $this->bookRepo->getRecentlyCreated(4);
|
||||
@@ -56,8 +60,7 @@ class BookController extends Controller
|
||||
'popular' => $popular,
|
||||
'new' => $new,
|
||||
'view' => $view,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -30,18 +31,16 @@ class BookshelfController extends Controller
|
||||
/**
|
||||
* Display a listing of the book.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$view = setting()->getForCurrentUser('bookshelves_view_type');
|
||||
$sort = setting()->getForCurrentUser('bookshelves_sort', 'name');
|
||||
$order = setting()->getForCurrentUser('bookshelves_sort_order', 'asc');
|
||||
$sortOptions = [
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'bookshelves')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
];
|
||||
]);
|
||||
|
||||
$shelves = $this->shelfRepo->getAllPaginated(18, $sort, $order);
|
||||
$shelves = $this->shelfRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
|
||||
$recents = $this->isSignedIn() ? $this->shelfRepo->getRecentlyViewed(4) : false;
|
||||
$popular = $this->shelfRepo->getPopular(4);
|
||||
$new = $this->shelfRepo->getRecentlyCreated(4);
|
||||
@@ -55,9 +54,7 @@ class BookshelfController extends Controller
|
||||
'popular' => $popular,
|
||||
'new' => $new,
|
||||
'view' => $view,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'sortOptions' => $sortOptions,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -100,16 +97,21 @@ class BookshelfController extends Controller
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function show(ActivityQueries $activities, string $slug)
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('bookshelf-view', $shelf);
|
||||
|
||||
$sort = setting()->getForCurrentUser('shelf_books_sort', 'default');
|
||||
$order = setting()->getForCurrentUser('shelf_books_sort_order', 'asc');
|
||||
$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 = $listOptions->getSort();
|
||||
$sortedVisibleShelfBooks = $shelf->visibleBooks()->get()
|
||||
->sortBy($sort === 'default' ? 'pivot.order' : $sort, SORT_REGULAR, $order === 'desc')
|
||||
->sortBy($sort === 'default' ? 'pivot.order' : $sort, SORT_REGULAR, $listOptions->getOrder() === 'desc')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
@@ -124,8 +126,7 @@ class BookshelfController extends Controller
|
||||
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
|
||||
'view' => $view,
|
||||
'activity' => $activities->entityActivity($shelf, 20, 1),
|
||||
'order' => $order,
|
||||
'sort' => $sort,
|
||||
'listOptions' => $listOptions,
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -10,13 +10,15 @@ 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(ActivityQueries $activities)
|
||||
public function index(Request $request, ActivityQueries $activities)
|
||||
{
|
||||
$activity = $activities->latest(10);
|
||||
$draftPages = [];
|
||||
@@ -61,33 +63,27 @@ class HomeController extends Controller
|
||||
if ($homepageOption === 'bookshelves' || $homepageOption === 'books') {
|
||||
$key = $homepageOption;
|
||||
$view = setting()->getForCurrentUser($key . '_view_type');
|
||||
$sort = setting()->getForCurrentUser($key . '_sort', 'name');
|
||||
$order = setting()->getForCurrentUser($key . '_sort_order', 'asc');
|
||||
|
||||
$sortOptions = [
|
||||
'name' => trans('common.sort_name'),
|
||||
$listOptions = SimpleListOptions::fromRequest($request, $key)->withSortOptions([
|
||||
'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,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'sortOptions' => $sortOptions,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($homepageOption === 'bookshelves') {
|
||||
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['sort'], $commonData['order']);
|
||||
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
|
||||
$data = array_merge($commonData, ['shelves' => $shelves]);
|
||||
|
||||
return view('home.shelves', $data);
|
||||
}
|
||||
|
||||
if ($homepageOption === 'books') {
|
||||
$bookRepo = app(BookRepo::class);
|
||||
$books = $bookRepo->getAllPaginated(18, $commonData['sort'], $commonData['order']);
|
||||
$books = app(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
|
||||
$data = array_merge($commonData, ['books' => $books]);
|
||||
|
||||
return view('home.books', $data);
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
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
|
||||
@@ -23,22 +26,29 @@ class PageRevisionController extends Controller
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function index(string $bookSlug, string $pageSlug)
|
||||
public function index(Request $request, 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'])
|
||||
->get();
|
||||
->reorder('id', $listOptions->getOrder())
|
||||
->reorder('created_at', $listOptions->getOrder())
|
||||
->paginate(50);
|
||||
|
||||
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName' => $page->getShortName()]));
|
||||
|
||||
return view('pages.revisions', [
|
||||
'revisions' => $revisions,
|
||||
'page' => $page,
|
||||
'revisions' => $revisions,
|
||||
'page' => $page,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -50,6 +60,7 @@ 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();
|
||||
@@ -78,6 +89,7 @@ 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();
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Http\Controllers;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\PermissionFormData;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
@@ -162,10 +163,35 @@ class PermissionsController extends Controller
|
||||
{
|
||||
$this->checkPermissionOr('restrictions-manage-all', fn() => userCan('restrictions-manage-own'));
|
||||
|
||||
/** @var Role $role */
|
||||
$role = Role::query()->findOrFail($roleId);
|
||||
|
||||
return view('form.entity-permissions-row', [
|
||||
'role' => $role,
|
||||
'modelType' => 'role',
|
||||
'modelId' => $role->id,
|
||||
'modelName' => $role->display_name,
|
||||
'modelDescription' => $role->description,
|
||||
'permission' => new EntityPermission(),
|
||||
'entityType' => $entityType,
|
||||
'inheriting' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an empty entity permissions form row for the given user.
|
||||
*/
|
||||
public function formRowForUser(string $entityType, string $userId)
|
||||
{
|
||||
$this->checkPermissionOr('restrictions-manage-all', fn() => userCan('restrictions-manage-own'));
|
||||
|
||||
/** @var User $user */
|
||||
$user = User::query()->findOrFail($userId);
|
||||
|
||||
return view('form.entity-permissions-row', [
|
||||
'modelType' => 'user',
|
||||
'modelId' => $user->id,
|
||||
'modelName' => $user->name,
|
||||
'modelDescription' => '',
|
||||
'permission' => new EntityPermission(),
|
||||
'entityType' => $entityType,
|
||||
'inheriting' => false,
|
||||
|
||||
@@ -3,19 +3,18 @@
|
||||
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;
|
||||
protected PermissionsRepo $permissionsRepo;
|
||||
|
||||
/**
|
||||
* PermissionController constructor.
|
||||
*/
|
||||
public function __construct(PermissionsRepo $permissionsRepo)
|
||||
{
|
||||
$this->permissionsRepo = $permissionsRepo;
|
||||
@@ -24,14 +23,27 @@ class RoleController extends Controller
|
||||
/**
|
||||
* Show a listing of the roles in the system.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$roles = $this->permissionsRepo->getAllRoles();
|
||||
|
||||
$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());
|
||||
|
||||
$this->setPageTitle(trans('settings.roles'));
|
||||
|
||||
return view('settings.roles.index', ['roles' => $roles]);
|
||||
return view('settings.roles.index', [
|
||||
'roles' => $roles,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,16 +87,11 @@ 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'));
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Http\Request;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
protected $searchRunner;
|
||||
protected SearchRunner $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 searchEntitiesAjax(Request $request)
|
||||
public function searchForSelector(Request $request)
|
||||
{
|
||||
$entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book'];
|
||||
$searchTerm = $request->get('term', false);
|
||||
@@ -83,7 +83,25 @@ class SearchController extends Controller
|
||||
$entities = (new Popular())->run(20, 0, $entityTypes);
|
||||
}
|
||||
|
||||
return view('search.parts.entity-ajax-list', ['entities' => $entities, 'permission' => $permission]);
|
||||
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)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\TagRepo;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TagController extends Controller
|
||||
@@ -19,22 +20,25 @@ class TagController extends Controller
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$search = $request->get('search', '');
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'tags')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'usages' => trans('entities.tags_usages'),
|
||||
]);
|
||||
|
||||
$nameFilter = $request->get('name', '');
|
||||
$tags = $this->tagRepo
|
||||
->queryWithTotals($search, $nameFilter)
|
||||
->queryWithTotals($listOptions, $nameFilter)
|
||||
->paginate(50)
|
||||
->appends(array_filter([
|
||||
'search' => $search,
|
||||
->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
|
||||
'name' => $nameFilter,
|
||||
]));
|
||||
])));
|
||||
|
||||
$this->setPageTitle(trans('entities.tags'));
|
||||
|
||||
return view('tags.index', [
|
||||
'tags' => $tags,
|
||||
'search' => $search,
|
||||
'nameFilter' => $nameFilter,
|
||||
'tags' => $tags,
|
||||
'nameFilter' => $nameFilter,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Auth\Queries\AllUsersPaginatedAndSorted;
|
||||
use BookStack\Auth\Queries\UsersAllPaginatedAndSorted;
|
||||
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,9 +21,6 @@ class UserController extends Controller
|
||||
protected UserRepo $userRepo;
|
||||
protected ImageRepo $imageRepo;
|
||||
|
||||
/**
|
||||
* UserController constructor.
|
||||
*/
|
||||
public function __construct(UserRepo $userRepo, ImageRepo $imageRepo)
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
@@ -36,20 +33,23 @@ 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'),
|
||||
];
|
||||
|
||||
$users = (new AllUsersPaginatedAndSorted())->run(20, $listDetails);
|
||||
$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);
|
||||
|
||||
$this->setPageTitle(trans('settings.users'));
|
||||
$users->appends($listDetails);
|
||||
$users->appends($listOptions->getPaginationAppends());
|
||||
|
||||
return view('users.index', [
|
||||
'users' => $users,
|
||||
'listDetails' => $listDetails,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -107,9 +107,8 @@ class UserController extends Controller
|
||||
{
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
|
||||
/** @var User $user */
|
||||
$user = User::query()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
|
||||
|
||||
$user = $this->userRepo->getById($id);
|
||||
$user->load(['apiTokens', 'mfaValues']);
|
||||
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
|
||||
|
||||
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
|
||||
@@ -202,137 +201,4 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
142
app/Http/Controllers/UserPreferencesController.php
Normal file
142
app/Http/Controllers/UserPreferencesController.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
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
|
||||
@@ -18,16 +20,25 @@ class WebhookController extends Controller
|
||||
/**
|
||||
* Show all webhooks configured in the system.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$webhooks = Webhook::query()
|
||||
->orderBy('name', 'desc')
|
||||
->with('trackedEvents')
|
||||
->get();
|
||||
$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());
|
||||
|
||||
$this->setPageTitle(trans('settings.webhooks'));
|
||||
|
||||
return view('settings.webhooks.index', ['webhooks' => $webhooks]);
|
||||
return view('settings.webhooks.index', [
|
||||
'webhooks' => $webhooks,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,14 +19,6 @@ 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.
|
||||
*
|
||||
|
||||
@@ -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: Entity[]}
|
||||
* @return array{total: int, count: int, has_more: bool, results: Collection<Entity>}
|
||||
*/
|
||||
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array
|
||||
{
|
||||
@@ -223,7 +223,7 @@ class SearchRunner
|
||||
});
|
||||
$subQuery->groupBy('entity_type', 'entity_id');
|
||||
|
||||
$entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id');
|
||||
$entityQuery->joinSub($subQuery, 's', 'id', '=', 's.entity_id');
|
||||
$entityQuery->addSelect('s.score');
|
||||
$entityQuery->orderBy('score', 'desc');
|
||||
}
|
||||
|
||||
@@ -194,6 +194,8 @@ 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
|
||||
{
|
||||
@@ -206,6 +208,16 @@ 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.
|
||||
*/
|
||||
|
||||
82
app/Settings/UserShortcutMap.php
Normal file
82
app/Settings/UserShortcutMap.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?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) ?: []);
|
||||
}
|
||||
}
|
||||
@@ -88,16 +88,17 @@ 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' && $storageType === 'local_secure') {
|
||||
$storageType = 'local';
|
||||
if ($imageType === 'system' && $localSecureInUse) {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
// Rename local_secure options to get our image specific storage driver which
|
||||
// is scoped to the relevant image directories.
|
||||
if ($storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
|
||||
$storageType = 'local_secure_images';
|
||||
if ($localSecureInUse) {
|
||||
return 'local_secure_images';
|
||||
}
|
||||
|
||||
return $storageType;
|
||||
|
||||
104
app/Util/SimpleListOptions.php
Normal file
104
app/Util/SimpleListOptions.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,9 @@ function hasAppAccess(): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has a permission. If an ownable element
|
||||
* is passed in the jointPermissions are checked against that particular item.
|
||||
* Check if the current user has a permission.
|
||||
* Checks a generic role permission or, if an ownable model is passed in, it will
|
||||
* check against the given entity model, taking into account entity-level permissions.
|
||||
*/
|
||||
function userCan(string $permission, Model $ownable = null): bool
|
||||
{
|
||||
|
||||
470
composer.lock
generated
470
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddUserIdToEntityPermissions extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('entity_permissions', function (Blueprint $table) {
|
||||
$table->unsignedInteger('role_id')->nullable()->default(null)->change();
|
||||
$table->unsignedInteger('user_id')->nullable()->default(null)->index();
|
||||
});
|
||||
|
||||
DB::table('entity_permissions')
|
||||
->where('role_id', '=', 0)
|
||||
->update(['role_id' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
DB::table('entity_permissions')
|
||||
->whereNull('role_id')
|
||||
->update(['role_id' => 0]);
|
||||
|
||||
DB::table('entity_permissions')
|
||||
->whereNotNull('user_id')
|
||||
->delete();
|
||||
|
||||
Schema::table('entity_permissions', function (Blueprint $table) {
|
||||
$table->unsignedInteger('role_id')->nullable(false)->change();
|
||||
$table->dropColumn('user_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateCollapsedRolePermissionsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
// TODO - Drop joint permissions
|
||||
// TODO - Run collapsed table rebuild.
|
||||
|
||||
Schema::create('entity_permissions_collapsed', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('role_id')->nullable()->index();
|
||||
$table->unsignedInteger('user_id')->nullable()->index();
|
||||
$table->string('entity_type');
|
||||
$table->unsignedInteger('entity_id');
|
||||
$table->boolean('view')->index();
|
||||
|
||||
$table->index(['entity_type', 'entity_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('entity_permissions_collapsed');
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use BookStack\Api\ApiToken;
|
||||
use BookStack\Auth\Permissions\JointPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\RolePermission;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
@@ -69,7 +69,7 @@ class DummyContentSeeder extends Seeder
|
||||
]);
|
||||
$token->save();
|
||||
|
||||
app(JointPermissionBuilder::class)->rebuildForAll();
|
||||
app(CollapsedPermissionBuilder::class)->rebuildForAll();
|
||||
app(SearchIndex::class)->indexAllEntities();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
@@ -35,7 +35,7 @@ class LargeContentSeeder extends Seeder
|
||||
$largeBook->chapters()->saveMany($chapters);
|
||||
$all = array_merge([$largeBook], array_values($pages->all()), array_values($chapters->all()));
|
||||
|
||||
app()->make(JointPermissionBuilder::class)->rebuildForEntity($largeBook);
|
||||
app()->make(CollapsedPermissionBuilder::class)->rebuildForEntity($largeBook);
|
||||
app()->make(SearchIndex::class)->indexEntities($all);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. You'll need to import the component class then add it to `componentMapping` object, 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 by defining an additional export, following the pattern of other components.
|
||||
|
||||
### Using a Component in HTML
|
||||
|
||||
@@ -80,9 +80,9 @@ Will result with `this.$opts` being:
|
||||
}
|
||||
```
|
||||
|
||||
#### Component Properties
|
||||
#### Component Properties & Methods
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```javascript
|
||||
// The root element that the compontent has been applied to.
|
||||
@@ -98,6 +98,15 @@ 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
|
||||
@@ -132,7 +141,16 @@ window.trans_plural(translationString, count, replacements);
|
||||
|
||||
// Component System
|
||||
// Parse and initialise any components from the given root el down.
|
||||
window.components.init(rootEl);
|
||||
// Get the first active component of the given name
|
||||
window.components.first(name);
|
||||
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);
|
||||
```
|
||||
421
dev/docs/permission-scenario-testing.md
Normal file
421
dev/docs/permission-scenario-testing.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# 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:
|
||||
|
||||
- User entity permissions.
|
||||
- Role entity permissions.
|
||||
- Fallback entity permissions.
|
||||
- Role permissions.
|
||||
|
||||
- TODO - Test fallback in the context of the above.
|
||||
|
||||
## 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 user/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/user.
|
||||
- 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_80_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.
|
||||
|
||||
---
|
||||
|
||||
### Entity User Permissions
|
||||
|
||||
These are tests related to entity-level user-specific permission overrides.
|
||||
|
||||
#### test_01_explicit_allow
|
||||
|
||||
- Page permissions have inherit disabled.
|
||||
- User has entity allow page permission.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_02_explicit_deny
|
||||
|
||||
- Page permissions have inherit disabled.
|
||||
- User has entity deny page permission.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_10_allow_inherit
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- User has entity allow chapter permission.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_11_deny_inherit
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- User has entity deny chapter permission.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_12_allow_inherit_override
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- User has entity deny chapter permission.
|
||||
- User has entity allow page permission.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_13_deny_inherit_override
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- User has entity allow chapter permission.
|
||||
- User has entity deny page permission.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_40_entity_role_override_allow
|
||||
|
||||
- Page permissions have inherit disabled.
|
||||
- User has entity allow page permission.
|
||||
- Role A has entity deny page permission.
|
||||
- User has role A.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_41_entity_role_override_deny
|
||||
|
||||
- Page permissions have inherit disabled.
|
||||
- User has entity deny page permission.
|
||||
- Role A has entity allow page permission.
|
||||
- User has role A.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_42_entity_role_override_allow_via_inherit
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- User has entity allow chapter permission.
|
||||
- Role A has entity deny page permission.
|
||||
- User has role A.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_43_entity_role_override_deny_via_inherit
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- User has entity deny chapter permission.
|
||||
- Role A has entity allow page permission.
|
||||
- User has role A.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_50_role_override_allow
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has no page role permission.
|
||||
- User 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 all-page role permission.
|
||||
- User has entity deny page permission.
|
||||
- User has Role A.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_60_inherited_role_override_allow
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has no page role permission.
|
||||
- User has entity allow chapter permission.
|
||||
- User has Role A.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_61_inherited_role_override_deny
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has view-all page role permission.
|
||||
- User has entity deny chapter permission.
|
||||
- User has Role A.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_61_inherited_role_override_deny_on_own
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has view-own page role permission.
|
||||
- User has entity deny chapter permission.
|
||||
- User has Role A.
|
||||
- User owns Page.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_70_all_override_allow
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has no page role permission.
|
||||
- Role A has entity deny page permission.
|
||||
- User has entity allow page permission.
|
||||
- User has Role A.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_71_all_override_deny
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has page-all role permission.
|
||||
- Role A has entity allow page permission.
|
||||
- User has entity deny page permission.
|
||||
- User has Role A.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_80_inherited_all_override_allow
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has no page role permission.
|
||||
- Role A has entity deny chapter permission.
|
||||
- User has entity allow chapter permission.
|
||||
- User has Role A.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_81_inherited_all_override_deny
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has view-all page role permission.
|
||||
- Role A has entity allow chapter permission.
|
||||
- User has entity deny chapter permission.
|
||||
- User has Role A.
|
||||
|
||||
User denied page permission.
|
||||
1116
package-lock.json
generated
1116
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,11 +16,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "^3.0",
|
||||
"esbuild": "0.14.42",
|
||||
"esbuild": "^0.15.12",
|
||||
"livereload": "^0.9.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"punycode": "^2.1.1",
|
||||
"sass": "^1.52.1"
|
||||
"sass": "^1.55.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"clipboard": "^2.0.11",
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 246 B After Width: | Height: | Size: 216 B |
1
resources/icons/shortcuts.svg
Normal file
1
resources/icons/shortcuts.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm8 7H9c-.55 0-1-.45-1-1s.45-1 1-1h6c.55 0 1 .45 1 1s-.45 1-1 1zm1-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z"/></svg>
|
||||
|
After Width: | Height: | Size: 367 B |
@@ -27,5 +27,8 @@ window.trans_choice = translator.getPlural.bind(translator);
|
||||
window.trans_plural = translator.parsePlural.bind(translator);
|
||||
|
||||
// Load Components
|
||||
import components from "./components"
|
||||
components();
|
||||
import * as components from "./services/components"
|
||||
import * as componentMap from "./components";
|
||||
components.register(componentMap);
|
||||
window.$components = components;
|
||||
components.init();
|
||||
|
||||
@@ -4,6 +4,7 @@ import Clipboard from "clipboard/dist/clipboard.min";
|
||||
// Modes
|
||||
import 'codemirror/mode/css/css';
|
||||
import 'codemirror/mode/clike/clike';
|
||||
import 'codemirror/mode/dart/dart';
|
||||
import 'codemirror/mode/diff/diff';
|
||||
import 'codemirror/mode/fortran/fortran';
|
||||
import 'codemirror/mode/go/go';
|
||||
@@ -27,6 +28,7 @@ import 'codemirror/mode/rust/rust';
|
||||
import 'codemirror/mode/shell/shell';
|
||||
import 'codemirror/mode/sql/sql';
|
||||
import 'codemirror/mode/stex/stex';
|
||||
import 'codemirror/mode/swift/swift';
|
||||
import 'codemirror/mode/toml/toml';
|
||||
import 'codemirror/mode/vb/vb';
|
||||
import 'codemirror/mode/vbscript/vbscript';
|
||||
@@ -49,6 +51,7 @@ const modeMap = {
|
||||
'c++': 'text/x-c++src',
|
||||
'c#': 'text/x-csharp',
|
||||
csharp: 'text/x-csharp',
|
||||
dart: 'application/dart',
|
||||
diff: 'diff',
|
||||
for: 'fortran',
|
||||
fortran: 'fortran',
|
||||
@@ -91,11 +94,12 @@ const modeMap = {
|
||||
rs: 'rust',
|
||||
shell: 'shell',
|
||||
sh: 'shell',
|
||||
sql: 'text/x-sql',
|
||||
stext: 'text/x-stex',
|
||||
swift: 'text/x-swift',
|
||||
toml: 'toml',
|
||||
ts: 'text/typescript',
|
||||
typescript: 'text/typescript',
|
||||
sql: 'text/x-sql',
|
||||
vbs: 'vbscript',
|
||||
vbscript: 'vbscript',
|
||||
'vb.net': 'text/x-vb',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {onChildEvent} from "../services/dom";
|
||||
import {uniqueId} from "../services/util";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* AddRemoveRows
|
||||
* Allows easy row add/remove controls onto a table.
|
||||
* Needs a model row to use when adding a new row.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AddRemoveRows {
|
||||
export class AddRemoveRows extends Component {
|
||||
setup() {
|
||||
this.modelRow = this.$refs.model;
|
||||
this.addButton = this.$refs.add;
|
||||
@@ -31,7 +31,7 @@ class AddRemoveRows {
|
||||
clone.classList.remove('hidden');
|
||||
this.setClonedInputNames(clone);
|
||||
this.modelRow.parentNode.insertBefore(clone, this.modelRow);
|
||||
window.components.init(clone);
|
||||
window.$components.init(clone);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +49,4 @@ class AddRemoveRows {
|
||||
elem.name = elem.name.split('randrowid').join(rowId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AddRemoveRows;
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* AjaxDelete
|
||||
* @extends {Component}
|
||||
*/
|
||||
import {onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
class AjaxDeleteRow {
|
||||
export class AjaxDeleteRow extends Component {
|
||||
setup() {
|
||||
this.row = this.$el;
|
||||
this.url = this.$opts.url;
|
||||
@@ -27,6 +24,4 @@ class AjaxDeleteRow {
|
||||
this.row.style.pointerEvents = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default AjaxDeleteRow;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {onEnterPress, onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Ajax Form
|
||||
@@ -8,10 +9,8 @@ import {onEnterPress, onSelect} from "../services/dom";
|
||||
*
|
||||
* Will handle a real form if that's what the component is added to
|
||||
* otherwise will act as a fake form element.
|
||||
*
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AjaxForm {
|
||||
export class AjaxForm extends Component {
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.responseContainer = this.container;
|
||||
@@ -72,11 +71,9 @@ class AjaxForm {
|
||||
this.responseContainer.innerHTML = err.data;
|
||||
}
|
||||
|
||||
window.components.init(this.responseContainer);
|
||||
window.$components.init(this.responseContainer);
|
||||
this.responseContainer.style.opacity = null;
|
||||
this.responseContainer.style.pointerEvents = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default AjaxForm;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Attachments List
|
||||
* Adds '?open=true' query to file attachment links
|
||||
* when ctrl/cmd is pressed down.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AttachmentsList {
|
||||
export class AttachmentsList extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
@@ -42,6 +43,4 @@ class AttachmentsList {
|
||||
link.removeAttribute('target');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AttachmentsList;
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Attachments
|
||||
* @extends {Component}
|
||||
*/
|
||||
import {showLoading} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
class Attachments {
|
||||
export class Attachments extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
@@ -46,10 +43,12 @@ class Attachments {
|
||||
|
||||
reloadList() {
|
||||
this.stopEdit();
|
||||
this.mainTabs.components.tabs.show('items');
|
||||
/** @var {Tabs} */
|
||||
const tabs = window.$components.firstOnElement(this.mainTabs, 'tabs');
|
||||
tabs.show('items');
|
||||
window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => {
|
||||
this.list.innerHTML = resp.data;
|
||||
window.components.init(this.list);
|
||||
window.$components.init(this.list);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,7 +65,7 @@ class Attachments {
|
||||
showLoading(this.editContainer);
|
||||
const resp = await window.$http.get(`/attachments/edit/${id}`);
|
||||
this.editContainer.innerHTML = resp.data;
|
||||
window.components.init(this.editContainer);
|
||||
window.$components.init(this.editContainer);
|
||||
}
|
||||
|
||||
stopEdit() {
|
||||
@@ -74,6 +73,4 @@ class Attachments {
|
||||
this.listContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Attachments;
|
||||
}
|
||||
11
resources/js/components/auto-submit.js
Normal file
11
resources/js/components/auto-submit.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import {Component} from "./component";
|
||||
|
||||
export class AutoSubmit extends Component {
|
||||
|
||||
setup() {
|
||||
this.form = this.$el;
|
||||
|
||||
this.form.submit();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import {escapeHtml} from "../services/util";
|
||||
import {onChildEvent} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
const ajaxCache = {};
|
||||
|
||||
/**
|
||||
* AutoSuggest
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AutoSuggest {
|
||||
export class AutoSuggest extends Component {
|
||||
setup() {
|
||||
this.parent = this.$el.parentElement;
|
||||
this.container = this.$el;
|
||||
@@ -148,6 +148,4 @@ class AutoSuggest {
|
||||
this.hideSuggestions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AutoSuggest;
|
||||
}
|
||||
@@ -1,34 +1,35 @@
|
||||
import {Component} from "./component";
|
||||
|
||||
class BackToTop {
|
||||
export class BackToTop extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
setup() {
|
||||
this.button = this.$el;
|
||||
this.targetElem = document.getElementById('header');
|
||||
this.showing = false;
|
||||
this.breakPoint = 1200;
|
||||
|
||||
if (document.body.classList.contains('flexbox')) {
|
||||
this.elem.style.display = 'none';
|
||||
this.button.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
this.elem.addEventListener('click', this.scrollToTop.bind(this));
|
||||
this.button.addEventListener('click', this.scrollToTop.bind(this));
|
||||
window.addEventListener('scroll', this.onPageScroll.bind(this));
|
||||
}
|
||||
|
||||
onPageScroll() {
|
||||
let scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0;
|
||||
if (!this.showing && scrollTopPos > this.breakPoint) {
|
||||
this.elem.style.display = 'block';
|
||||
this.button.style.display = 'block';
|
||||
this.showing = true;
|
||||
setTimeout(() => {
|
||||
this.elem.style.opacity = 0.4;
|
||||
this.button.style.opacity = 0.4;
|
||||
}, 1);
|
||||
} else if (this.showing && scrollTopPos < this.breakPoint) {
|
||||
this.elem.style.opacity = 0;
|
||||
this.button.style.opacity = 0;
|
||||
this.showing = false;
|
||||
setTimeout(() => {
|
||||
this.elem.style.display = 'none';
|
||||
this.button.style.display = 'none';
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +55,4 @@ class BackToTop {
|
||||
requestAnimationFrame(setPos.bind(this));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default BackToTop;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import Sortable from "sortablejs";
|
||||
import {Component} from "./component";
|
||||
import {htmlToDom} from "../services/dom";
|
||||
|
||||
// Auto sort control
|
||||
const sortOperations = {
|
||||
@@ -35,14 +37,14 @@ const sortOperations = {
|
||||
},
|
||||
};
|
||||
|
||||
class BookSort {
|
||||
export class BookSort extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.sortContainer = elem.querySelector('[book-sort-boxes]');
|
||||
this.input = elem.querySelector('[book-sort-input]');
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.sortContainer = this.$refs.sortContainer;
|
||||
this.input = this.$refs.input;
|
||||
|
||||
const initialSortBox = elem.querySelector('.sort-box');
|
||||
const initialSortBox = this.container.querySelector('.sort-box');
|
||||
this.setupBookSortable(initialSortBox);
|
||||
this.setupSortPresets();
|
||||
|
||||
@@ -90,14 +92,12 @@ class BookSort {
|
||||
* @param {Object} entityInfo
|
||||
*/
|
||||
bookSelect(entityInfo) {
|
||||
const alreadyAdded = this.elem.querySelector(`[data-type="book"][data-id="${entityInfo.id}"]`) !== null;
|
||||
const alreadyAdded = this.container.querySelector(`[data-type="book"][data-id="${entityInfo.id}"]`) !== null;
|
||||
if (alreadyAdded) return;
|
||||
|
||||
const entitySortItemUrl = entityInfo.link + '/sort-item';
|
||||
window.$http.get(entitySortItemUrl).then(resp => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.innerHTML = resp.data;
|
||||
const newBookContainer = wrap.children[0];
|
||||
const newBookContainer = htmlToDom(resp.data);
|
||||
this.sortContainer.append(newBookContainer);
|
||||
this.setupBookSortable(newBookContainer);
|
||||
});
|
||||
@@ -155,7 +155,7 @@ class BookSort {
|
||||
*/
|
||||
buildEntityMap() {
|
||||
const entityMap = [];
|
||||
const lists = this.elem.querySelectorAll('.sort-list');
|
||||
const lists = this.container.querySelectorAll('.sort-list');
|
||||
|
||||
for (let list of lists) {
|
||||
const bookId = list.closest('[data-type="book"]').getAttribute('data-id');
|
||||
@@ -202,6 +202,4 @@ class BookSort {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default BookSort;
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import {slideUp, slideDown} from "../services/animations";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* @extends {Component}
|
||||
*/
|
||||
class ChapterContents {
|
||||
export class ChapterContents extends Component {
|
||||
|
||||
setup() {
|
||||
this.list = this.$refs.list;
|
||||
@@ -31,7 +29,4 @@ class ChapterContents {
|
||||
event.preventDefault();
|
||||
this.isOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ChapterContents;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {onChildEvent, onEnterPress, onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Code Editor
|
||||
* @extends {Component}
|
||||
*/
|
||||
class CodeEditor {
|
||||
|
||||
export class CodeEditor extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$refs.container;
|
||||
@@ -73,7 +71,7 @@ class CodeEditor {
|
||||
isFavorite ? this.favourites.add(language) : this.favourites.delete(language);
|
||||
button.setAttribute('data-favourite', isFavorite ? 'true' : 'false');
|
||||
|
||||
window.$http.patch('/settings/users/update-code-language-favourite', {
|
||||
window.$http.patch('/preferences/update-code-language-favourite', {
|
||||
language: language,
|
||||
active: isFavorite
|
||||
});
|
||||
@@ -128,7 +126,7 @@ class CodeEditor {
|
||||
}
|
||||
|
||||
this.loadHistory();
|
||||
this.popup.components.popup.show(() => {
|
||||
this.getPopup().show(() => {
|
||||
Code.updateLayout(this.editor);
|
||||
this.editor.focus();
|
||||
}, () => {
|
||||
@@ -137,10 +135,17 @@ class CodeEditor {
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.popup.components.popup.hide();
|
||||
this.getPopup().hide();
|
||||
this.addHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Popup}
|
||||
*/
|
||||
getPopup() {
|
||||
return window.$components.firstOnElement(this.popup, 'popup');
|
||||
}
|
||||
|
||||
async updateEditorMode(language) {
|
||||
const Code = await window.importVersioned('code');
|
||||
Code.setMode(this.editor, language, this.editor.getValue());
|
||||
@@ -184,6 +189,4 @@ class CodeEditor {
|
||||
window.sessionStorage.setItem(this.historyKey, historyString);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default CodeEditor;
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
class CodeHighlighter {
|
||||
import {Component} from "./component";
|
||||
|
||||
constructor(elem) {
|
||||
const codeBlocks = elem.querySelectorAll('pre');
|
||||
export class CodeHighlighter extends Component{
|
||||
|
||||
setup() {
|
||||
const container = this.$el;
|
||||
|
||||
const codeBlocks = container.querySelectorAll('pre');
|
||||
if (codeBlocks.length > 0) {
|
||||
window.importVersioned('code').then(Code => {
|
||||
Code.highlightWithin(elem);
|
||||
Code.highlightWithin(container);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default CodeHighlighter;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* A simple component to render a code editor within the textarea
|
||||
* this exists upon.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class CodeTextarea {
|
||||
import {Component} from "./component";
|
||||
|
||||
export class CodeTextarea extends Component {
|
||||
|
||||
async setup() {
|
||||
const mode = this.$opts.mode;
|
||||
@@ -11,6 +12,4 @@ class CodeTextarea {
|
||||
Code.inlineEditor(this.$el, mode);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default CodeTextarea;
|
||||
}
|
||||
@@ -1,35 +1,37 @@
|
||||
import {slideDown, slideUp} from "../services/animations";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Collapsible
|
||||
* Provides some simple logic to allow collapsible sections.
|
||||
*/
|
||||
class Collapsible {
|
||||
export class Collapsible extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.trigger = elem.querySelector('[collapsible-trigger]');
|
||||
this.content = elem.querySelector('[collapsible-content]');
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.trigger = this.$refs.trigger;
|
||||
this.content = this.$refs.content;
|
||||
|
||||
if (!this.trigger) return;
|
||||
this.trigger.addEventListener('click', this.toggle.bind(this));
|
||||
this.openIfContainsError();
|
||||
if (this.trigger) {
|
||||
this.trigger.addEventListener('click', this.toggle.bind(this));
|
||||
this.openIfContainsError();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.elem.classList.add('open');
|
||||
this.container.classList.add('open');
|
||||
this.trigger.setAttribute('aria-expanded', 'true');
|
||||
slideDown(this.content, 300);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.elem.classList.remove('open');
|
||||
this.container.classList.remove('open');
|
||||
this.trigger.setAttribute('aria-expanded', 'false');
|
||||
slideUp(this.content, 300);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.elem.classList.contains('open')) {
|
||||
if (this.container.classList.contains('open')) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
@@ -43,6 +45,4 @@ class Collapsible {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Collapsible;
|
||||
}
|
||||
58
resources/js/components/component.js
Normal file
58
resources/js/components/component.js
Normal file
@@ -0,0 +1,58 @@
|
||||
export class Component {
|
||||
|
||||
/**
|
||||
* The registered name of the component.
|
||||
* @type {string}
|
||||
*/
|
||||
$name = '';
|
||||
|
||||
/**
|
||||
* The element that the component is registered upon.
|
||||
* @type {Element}
|
||||
*/
|
||||
$el = null;
|
||||
|
||||
/**
|
||||
* Mapping of referenced elements within the component.
|
||||
* @type {Object<string, Element>}
|
||||
*/
|
||||
$refs = {};
|
||||
|
||||
/**
|
||||
* Mapping of arrays of referenced elements within the component so multiple
|
||||
* references, sharing the same name, can be fetched.
|
||||
* @type {Object<string, Element[]>}
|
||||
*/
|
||||
$manyRefs = {};
|
||||
|
||||
/**
|
||||
* Options passed into this component.
|
||||
* @type {Object<String, String>}
|
||||
*/
|
||||
$opts = {};
|
||||
|
||||
/**
|
||||
* Component-specific setup methods.
|
||||
* Use this to assign local variables and run any initial setup or actions.
|
||||
*/
|
||||
setup() {
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an 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.
|
||||
* @param {String} eventName
|
||||
* @param {Object} data
|
||||
*/
|
||||
$emit(eventName, data = {}) {
|
||||
data.from = this;
|
||||
const componentName = this.$name;
|
||||
const event = new CustomEvent(`${componentName}-${eventName}`, {
|
||||
bubbles: true,
|
||||
detail: data
|
||||
});
|
||||
this.$el.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import {onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Custom equivalent of window.confirm() using our popup component.
|
||||
* Is promise based so can be used like so:
|
||||
* `const result = await dialog.show()`
|
||||
* @extends {Component}
|
||||
*/
|
||||
class ConfirmDialog {
|
||||
export class ConfirmDialog extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
@@ -34,7 +34,7 @@ class ConfirmDialog {
|
||||
* @returns {Popup}
|
||||
*/
|
||||
getPopup() {
|
||||
return this.container.components.popup;
|
||||
return window.$components.firstOnElement(this.container, 'popup');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +47,4 @@ class ConfirmDialog {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ConfirmDialog;
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import {Component} from "./component";
|
||||
|
||||
class CustomCheckbox {
|
||||
export class CustomCheckbox extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.checkbox = elem.querySelector('input[type=checkbox]');
|
||||
this.display = elem.querySelector('[role="checkbox"]');
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.checkbox = this.container.querySelector('input[type=checkbox]');
|
||||
this.display = this.container.querySelector('[role="checkbox"]');
|
||||
|
||||
this.checkbox.addEventListener('change', this.stateChange.bind(this));
|
||||
this.elem.addEventListener('keydown', this.onKeyDown.bind(this));
|
||||
this.container.addEventListener('keydown', this.onKeyDown.bind(this));
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
const isEnterOrPress = event.keyCode === 32 || event.keyCode === 13;
|
||||
if (isEnterOrPress) {
|
||||
const isEnterOrSpace = event.key === ' ' || event.key === 'Enter';
|
||||
if (isEnterOrSpace) {
|
||||
event.preventDefault();
|
||||
this.toggle();
|
||||
}
|
||||
@@ -29,6 +30,4 @@ class CustomCheckbox {
|
||||
this.display.setAttribute('aria-checked', checked);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default CustomCheckbox;
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
class DetailsHighlighter {
|
||||
import {Component} from "./component";
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
export class DetailsHighlighter extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.dealtWith = false;
|
||||
elem.addEventListener('toggle', this.onToggle.bind(this));
|
||||
|
||||
this.container.addEventListener('toggle', this.onToggle.bind(this));
|
||||
}
|
||||
|
||||
onToggle() {
|
||||
if (this.dealtWith) return;
|
||||
|
||||
if (this.elem.querySelector('pre')) {
|
||||
if (this.container.querySelector('pre')) {
|
||||
window.importVersioned('code').then(Code => {
|
||||
Code.highlightWithin(this.elem);
|
||||
Code.highlightWithin(this.container);
|
||||
});
|
||||
}
|
||||
this.dealtWith = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default DetailsHighlighter;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import {debounce} from "../services/util";
|
||||
import {transitionHeight} from "../services/animations";
|
||||
import {Component} from "./component";
|
||||
|
||||
class DropdownSearch {
|
||||
export class DropdownSearch extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
@@ -78,6 +79,4 @@ class DropdownSearch {
|
||||
this.loadingElem.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default DropdownSearch;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import {onSelect} from "../services/dom";
|
||||
import {KeyboardNavigationHandler} from "../services/keyboard-navigation";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Dropdown
|
||||
* Provides some simple logic to create simple dropdown menus.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class DropDown {
|
||||
export class Dropdown extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
@@ -17,8 +18,9 @@ class DropDown {
|
||||
this.direction = (document.dir === 'rtl') ? 'right' : 'left';
|
||||
this.body = document.body;
|
||||
this.showing = false;
|
||||
this.setupListeners();
|
||||
|
||||
this.hide = this.hide.bind(this);
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
show(event = null) {
|
||||
@@ -52,7 +54,7 @@ class DropDown {
|
||||
}
|
||||
|
||||
// Set listener to hide on mouse leave or window click
|
||||
this.menu.addEventListener('mouseleave', this.hide.bind(this));
|
||||
this.menu.addEventListener('mouseleave', this.hide);
|
||||
window.addEventListener('click', event => {
|
||||
if (!this.menu.contains(event.target)) {
|
||||
this.hide();
|
||||
@@ -74,7 +76,7 @@ class DropDown {
|
||||
}
|
||||
|
||||
hideAll() {
|
||||
for (let dropdown of window.components.dropdown) {
|
||||
for (let dropdown of window.$components.get('dropdown')) {
|
||||
dropdown.hide();
|
||||
}
|
||||
}
|
||||
@@ -97,33 +99,25 @@ class DropDown {
|
||||
this.showing = false;
|
||||
}
|
||||
|
||||
getFocusable() {
|
||||
return Array.from(this.menu.querySelectorAll('[tabindex]:not([tabindex="-1"]),[href],button,input:not([type=hidden])'));
|
||||
}
|
||||
|
||||
focusNext() {
|
||||
const focusable = this.getFocusable();
|
||||
const currentIndex = focusable.indexOf(document.activeElement);
|
||||
let newIndex = currentIndex + 1;
|
||||
if (newIndex >= focusable.length) {
|
||||
newIndex = 0;
|
||||
}
|
||||
|
||||
focusable[newIndex].focus();
|
||||
}
|
||||
|
||||
focusPrevious() {
|
||||
const focusable = this.getFocusable();
|
||||
const currentIndex = focusable.indexOf(document.activeElement);
|
||||
let newIndex = currentIndex - 1;
|
||||
if (newIndex < 0) {
|
||||
newIndex = focusable.length - 1;
|
||||
}
|
||||
|
||||
focusable[newIndex].focus();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
const keyboardNavHandler = new KeyboardNavigationHandler(this.container, (event) => {
|
||||
this.hide();
|
||||
this.toggle.focus();
|
||||
if (!this.bubbleEscapes) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}, (event) => {
|
||||
if (event.target.nodeName === 'INPUT') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.hide();
|
||||
});
|
||||
|
||||
if (this.moveMenu) {
|
||||
keyboardNavHandler.shareHandlingToEl(this.menu);
|
||||
}
|
||||
|
||||
// Hide menu on option click
|
||||
this.container.addEventListener('click', event => {
|
||||
const possibleChildren = Array.from(this.menu.querySelectorAll('a'));
|
||||
@@ -136,41 +130,9 @@ class DropDown {
|
||||
event.stopPropagation();
|
||||
this.show(event);
|
||||
if (event instanceof KeyboardEvent) {
|
||||
this.focusNext();
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
const keyboardNavigation = event => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
this.focusNext();
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
this.focusPrevious();
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'Escape') {
|
||||
this.hide();
|
||||
this.toggle.focus();
|
||||
if (!this.bubbleEscapes) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
};
|
||||
this.container.addEventListener('keydown', keyboardNavigation);
|
||||
if (this.moveMenu) {
|
||||
this.menu.addEventListener('keydown', keyboardNavigation);
|
||||
}
|
||||
|
||||
// Hide menu on enter press or escape
|
||||
this.menu.addEventListener('keydown ', event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.hide();
|
||||
keyboardNavHandler.focusNext();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default DropDown;
|
||||
@@ -1,11 +1,8 @@
|
||||
import DropZoneLib from "dropzone";
|
||||
import {fadeOut} from "../services/animations";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Dropzone
|
||||
* @extends {Component}
|
||||
*/
|
||||
class Dropzone {
|
||||
export class Dropzone extends Component {
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.url = this.$opts.url;
|
||||
@@ -73,6 +70,4 @@ class Dropzone {
|
||||
removeAll() {
|
||||
this.dz.removeAllFiles(true);
|
||||
}
|
||||
}
|
||||
|
||||
export default Dropzone;
|
||||
}
|
||||
@@ -1,51 +1,58 @@
|
||||
class EditorToolbox {
|
||||
import {Component} from "./component";
|
||||
|
||||
constructor(elem) {
|
||||
export class EditorToolbox extends Component {
|
||||
|
||||
setup() {
|
||||
// Elements
|
||||
this.elem = elem;
|
||||
this.buttons = elem.querySelectorAll('[toolbox-tab-button]');
|
||||
this.contentElements = elem.querySelectorAll('[toolbox-tab-content]');
|
||||
this.toggleButton = elem.querySelector('[toolbox-toggle]');
|
||||
this.container = this.$el;
|
||||
this.buttons = this.$manyRefs.tabButton;
|
||||
this.contentElements = this.$manyRefs.tabContent;
|
||||
this.toggleButton = this.$refs.toggle;
|
||||
|
||||
// Toolbox toggle button click
|
||||
this.toggleButton.addEventListener('click', this.toggle.bind(this));
|
||||
// Tab button click
|
||||
this.elem.addEventListener('click', event => {
|
||||
let button = event.target.closest('[toolbox-tab-button]');
|
||||
if (button === null) return;
|
||||
let name = button.getAttribute('toolbox-tab-button');
|
||||
this.setActiveTab(name, true);
|
||||
});
|
||||
this.setupListeners();
|
||||
|
||||
// Set the first tab as active on load
|
||||
this.setActiveTab(this.contentElements[0].getAttribute('toolbox-tab-content'));
|
||||
this.setActiveTab(this.contentElements[0].dataset.tabContent);
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
// Toolbox toggle button click
|
||||
this.toggleButton.addEventListener('click', () => this.toggle());
|
||||
// Tab button click
|
||||
this.container.addEventListener('click', event => {
|
||||
const button = event.target.closest('button');
|
||||
if (this.buttons.includes(button)) {
|
||||
const name = button.dataset.tab;
|
||||
this.setActiveTab(name, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.elem.classList.toggle('open');
|
||||
const expanded = this.elem.classList.contains('open') ? 'true' : 'false';
|
||||
this.container.classList.toggle('open');
|
||||
const expanded = this.container.classList.contains('open') ? 'true' : 'false';
|
||||
this.toggleButton.setAttribute('aria-expanded', expanded);
|
||||
}
|
||||
|
||||
setActiveTab(tabName, openToolbox = false) {
|
||||
|
||||
// Set button visibility
|
||||
for (let i = 0, len = this.buttons.length; i < len; i++) {
|
||||
this.buttons[i].classList.remove('active');
|
||||
let bName = this.buttons[i].getAttribute('toolbox-tab-button');
|
||||
if (bName === tabName) this.buttons[i].classList.add('active');
|
||||
}
|
||||
// Set content visibility
|
||||
for (let i = 0, len = this.contentElements.length; i < len; i++) {
|
||||
this.contentElements[i].style.display = 'none';
|
||||
let cName = this.contentElements[i].getAttribute('toolbox-tab-content');
|
||||
if (cName === tabName) this.contentElements[i].style.display = 'block';
|
||||
for (const button of this.buttons) {
|
||||
button.classList.remove('active');
|
||||
const bName = button.dataset.tab;
|
||||
if (bName === tabName) button.classList.add('active');
|
||||
}
|
||||
|
||||
if (openToolbox && !this.elem.classList.contains('open')) {
|
||||
// Set content visibility
|
||||
for (const contentEl of this.contentElements) {
|
||||
contentEl.style.display = 'none';
|
||||
const cName = contentEl.dataset.tabContent;
|
||||
if (cName === tabName) contentEl.style.display = 'block';
|
||||
}
|
||||
|
||||
if (openToolbox && !this.container.classList.contains('open')) {
|
||||
this.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default EditorToolbox;
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* @extends {Component}
|
||||
*/
|
||||
import {htmlToDom} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
class EntityPermissions {
|
||||
export class EntityPermissions extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
@@ -12,6 +10,8 @@ class EntityPermissions {
|
||||
this.everyoneInheritToggle = this.$refs.everyoneInherit;
|
||||
this.roleSelect = this.$refs.roleSelect;
|
||||
this.roleContainer = this.$refs.roleContainer;
|
||||
this.userContainer = this.$refs.userContainer;
|
||||
this.userSelectContainer = this.$refs.userSelectContainer;
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class EntityPermissions {
|
||||
// "Everyone Else" inherit toggle
|
||||
this.everyoneInheritToggle.addEventListener('change', event => {
|
||||
const inherit = event.target.checked;
|
||||
const permissions = document.querySelectorAll('input[name^="permissions[0]["]');
|
||||
const permissions = document.querySelectorAll('input[name^="permissions[fallback]"]');
|
||||
for (const permission of permissions) {
|
||||
permission.disabled = inherit;
|
||||
permission.checked = false;
|
||||
@@ -30,7 +30,7 @@ class EntityPermissions {
|
||||
// Remove role row button click
|
||||
this.container.addEventListener('click', event => {
|
||||
const button = event.target.closest('button');
|
||||
if (button && button.dataset.roleId) {
|
||||
if (button && button.dataset.modelType) {
|
||||
this.removeRowOnButtonClick(button)
|
||||
}
|
||||
});
|
||||
@@ -42,6 +42,14 @@ class EntityPermissions {
|
||||
this.addRoleRow(roleId);
|
||||
}
|
||||
});
|
||||
|
||||
// User select change
|
||||
this.userSelectContainer.querySelector('input[name="user_select"]').addEventListener('change', event => {
|
||||
const userId = event.target.value;
|
||||
if (userId) {
|
||||
this.addUserRow(userId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async addRoleRow(roleId) {
|
||||
@@ -54,26 +62,51 @@ class EntityPermissions {
|
||||
}
|
||||
|
||||
// Get and insert new row
|
||||
const resp = await window.$http.get(`/permissions/form-row/${this.entityType}/${roleId}`);
|
||||
const resp = await window.$http.get(`/permissions/role-form-row/${this.entityType}/${roleId}`);
|
||||
const row = htmlToDom(resp.data);
|
||||
this.roleContainer.append(row);
|
||||
|
||||
this.roleSelect.disabled = false;
|
||||
}
|
||||
|
||||
async addUserRow(userId) {
|
||||
const exists = this.userContainer.querySelector(`[name^="permissions[user][${userId}]"]`) !== null;
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = this.userSelectContainer.querySelector('.dropdown-search-toggle-select');
|
||||
toggle.classList.add('disabled');
|
||||
this.userContainer.style.pointerEvents = 'none';
|
||||
|
||||
// Get and insert new row
|
||||
const resp = await window.$http.get(`/permissions/user-form-row/${this.entityType}/${userId}`);
|
||||
const row = htmlToDom(resp.data);
|
||||
this.userContainer.append(row);
|
||||
|
||||
toggle.classList.remove('disabled');
|
||||
this.userContainer.style.pointerEvents = null;
|
||||
|
||||
/** @var {UserSelect} **/
|
||||
const userSelect = window.$components.firstOnElement(this.userSelectContainer.querySelector('.dropdown-search'), 'user-select');
|
||||
userSelect.reset();
|
||||
}
|
||||
|
||||
removeRowOnButtonClick(button) {
|
||||
const row = button.closest('.content-permissions-row');
|
||||
const roleId = button.dataset.roleId;
|
||||
const roleName = button.dataset.roleName;
|
||||
const row = button.closest('.item-list-row');
|
||||
const modelId = button.dataset.modelId;
|
||||
const modelName = button.dataset.modelName;
|
||||
const modelType = button.dataset.modelType;
|
||||
|
||||
const option = document.createElement('option');
|
||||
option.value = roleId;
|
||||
option.textContent = roleName;
|
||||
option.value = modelId;
|
||||
option.textContent = modelName;
|
||||
|
||||
if (modelType === 'role') {
|
||||
this.roleSelect.append(option);
|
||||
}
|
||||
|
||||
this.roleSelect.append(option);
|
||||
row.remove();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default EntityPermissions;
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import {onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Class EntitySearch
|
||||
* @extends {Component}
|
||||
*/
|
||||
class EntitySearch {
|
||||
export class EntitySearch extends Component {
|
||||
setup() {
|
||||
this.entityId = this.$opts.entityId;
|
||||
this.entityType = this.$opts.entityType;
|
||||
@@ -54,6 +51,4 @@ class EntitySearch {
|
||||
this.loadingBlock.classList.add('hidden');
|
||||
this.searchInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
export default EntitySearch;
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
/**
|
||||
* Entity Selector Popup
|
||||
* @extends {Component}
|
||||
*/
|
||||
class EntitySelectorPopup {
|
||||
import {Component} from "./component";
|
||||
|
||||
export class EntitySelectorPopup extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
this.container = this.$el;
|
||||
this.selectButton = this.$refs.select;
|
||||
|
||||
window.EntitySelectorPopup = this;
|
||||
this.selectorEl = this.$refs.selector;
|
||||
|
||||
this.callback = null;
|
||||
@@ -21,16 +17,26 @@ class EntitySelectorPopup {
|
||||
|
||||
show(callback) {
|
||||
this.callback = callback;
|
||||
this.elem.components.popup.show();
|
||||
this.getPopup().show();
|
||||
this.getSelector().focusSearch();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.elem.components.popup.hide();
|
||||
this.getPopup().hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Popup}
|
||||
*/
|
||||
getPopup() {
|
||||
return window.$components.firstOnElement(this.container, 'popup');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {EntitySelector}
|
||||
*/
|
||||
getSelector() {
|
||||
return this.selectorEl.components['entity-selector'];
|
||||
return window.$components.firstOnElement(this.selectorEl, 'entity-selector');
|
||||
}
|
||||
|
||||
onSelectButtonClick() {
|
||||
@@ -51,6 +57,4 @@ class EntitySelectorPopup {
|
||||
this.getSelector().reset();
|
||||
if (this.callback && entity) this.callback(entity);
|
||||
}
|
||||
}
|
||||
|
||||
export default EntitySelectorPopup;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import {onChildEvent} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Entity Selector
|
||||
* @extends {Component}
|
||||
*/
|
||||
class EntitySelector {
|
||||
export class EntitySelector extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
@@ -115,7 +115,7 @@ class EntitySelector {
|
||||
}
|
||||
|
||||
searchUrl() {
|
||||
return `/ajax/search/entities?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
|
||||
return `/search/entity-selector?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
|
||||
}
|
||||
|
||||
searchEntities(searchTerm) {
|
||||
@@ -185,6 +185,4 @@ class EntitySelector {
|
||||
this.selectedItemData = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default EntitySelector;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* EventEmitSelect
|
||||
@@ -10,10 +11,8 @@ import {onSelect} from "../services/dom";
|
||||
*
|
||||
* All options will be set as the "detail" of the event with
|
||||
* their values included.
|
||||
*
|
||||
* @extends {Component}
|
||||
*/
|
||||
class EventEmitSelect {
|
||||
export class EventEmitSelect extends Component{
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.name = this.$opts.name;
|
||||
@@ -24,6 +23,4 @@ class EventEmitSelect {
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default EventEmitSelect;
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import {slideUp, slideDown} from "../services/animations";
|
||||
import {Component} from "./component";
|
||||
|
||||
class ExpandToggle {
|
||||
export class ExpandToggle extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
|
||||
// Component state
|
||||
this.isOpen = elem.getAttribute('expand-toggle-is-open') === 'yes';
|
||||
this.updateEndpoint = elem.getAttribute('expand-toggle-update-endpoint');
|
||||
this.selector = elem.getAttribute('expand-toggle');
|
||||
setup(elem) {
|
||||
this.targetSelector = this.$opts.targetSelector;
|
||||
this.isOpen = this.$opts.isOpen === 'true';
|
||||
this.updateEndpoint = this.$opts.updateEndpoint;
|
||||
|
||||
// Listener setup
|
||||
elem.addEventListener('click', this.click.bind(this));
|
||||
this.$el.addEventListener('click', this.click.bind(this));
|
||||
}
|
||||
|
||||
open(elemToToggle) {
|
||||
@@ -25,7 +23,7 @@ class ExpandToggle {
|
||||
click(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const matchingElems = document.querySelectorAll(this.selector);
|
||||
const matchingElems = document.querySelectorAll(this.targetSelector);
|
||||
for (let match of matchingElems) {
|
||||
this.isOpen ? this.close(match) : this.open(match);
|
||||
}
|
||||
@@ -40,6 +38,4 @@ class ExpandToggle {
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ExpandToggle;
|
||||
}
|
||||
82
resources/js/components/global-search.js
Normal file
82
resources/js/components/global-search.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import {htmlToDom} from "../services/dom";
|
||||
import {debounce} from "../services/util";
|
||||
import {KeyboardNavigationHandler} from "../services/keyboard-navigation";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Global (header) search box handling.
|
||||
* Mainly to show live results preview.
|
||||
*/
|
||||
export class GlobalSearch extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.input = this.$refs.input;
|
||||
this.suggestions = this.$refs.suggestions;
|
||||
this.suggestionResultsWrap = this.$refs.suggestionResults;
|
||||
this.loadingWrap = this.$refs.loading;
|
||||
this.button = this.$refs.button;
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
const updateSuggestionsDebounced = debounce(this.updateSuggestions.bind(this), 200, false);
|
||||
|
||||
// Handle search input changes
|
||||
this.input.addEventListener('input', () => {
|
||||
const value = this.input.value;
|
||||
if (value.length > 0) {
|
||||
this.loadingWrap.style.display = 'block';
|
||||
this.suggestionResultsWrap.style.opacity = '0.5';
|
||||
updateSuggestionsDebounced(value);
|
||||
} else {
|
||||
this.hideSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
// Allow double click to show auto-click suggestions
|
||||
this.input.addEventListener('dblclick', () => {
|
||||
this.input.setAttribute('autocomplete', 'on');
|
||||
this.button.focus();
|
||||
this.input.focus();
|
||||
});
|
||||
|
||||
new KeyboardNavigationHandler(this.container, () => {
|
||||
this.hideSuggestions();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} search
|
||||
*/
|
||||
async updateSuggestions(search) {
|
||||
const {data: results} = await window.$http.get('/search/suggest', {term: search});
|
||||
if (!this.input.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resultDom = htmlToDom(results);
|
||||
|
||||
this.suggestionResultsWrap.innerHTML = '';
|
||||
this.suggestionResultsWrap.style.opacity = '1';
|
||||
this.loadingWrap.style.display = 'none';
|
||||
this.suggestionResultsWrap.append(resultDom);
|
||||
if (!this.container.classList.contains('search-active')) {
|
||||
this.showSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
showSuggestions() {
|
||||
this.container.classList.add('search-active');
|
||||
window.requestAnimationFrame(() => {
|
||||
this.suggestions.classList.add('search-suggestions-animation');
|
||||
})
|
||||
}
|
||||
|
||||
hideSuggestions() {
|
||||
this.container.classList.remove('search-active');
|
||||
this.suggestions.classList.remove('search-suggestions-animation');
|
||||
this.suggestionResultsWrap.innerHTML = '';
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Component} from "./component";
|
||||
|
||||
class HeaderMobileToggle {
|
||||
export class HeaderMobileToggle extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
@@ -36,6 +37,4 @@ class HeaderMobileToggle {
|
||||
this.onToggle(event);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default HeaderMobileToggle;
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import {onChildEvent, onSelect, removeLoading, showLoading} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* ImageManager
|
||||
* @extends {Component}
|
||||
*/
|
||||
class ImageManager {
|
||||
export class ImageManager extends Component {
|
||||
|
||||
setup() {
|
||||
|
||||
// Options
|
||||
this.uploadedTo = this.$opts.uploadedTo;
|
||||
|
||||
@@ -36,8 +32,6 @@ class ImageManager {
|
||||
this.resetState();
|
||||
|
||||
this.setupListeners();
|
||||
|
||||
window.ImageManager = this;
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
@@ -100,7 +94,7 @@ class ImageManager {
|
||||
|
||||
this.callback = callback;
|
||||
this.type = type;
|
||||
this.popupEl.components.popup.show();
|
||||
this.getPopup().show();
|
||||
this.dropzoneContainer.classList.toggle('hidden', type !== 'gallery');
|
||||
|
||||
if (!this.hasData) {
|
||||
@@ -110,7 +104,14 @@ class ImageManager {
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.popupEl.components.popup.hide();
|
||||
this.getPopup().hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Popup}
|
||||
*/
|
||||
getPopup() {
|
||||
return window.$components.firstOnElement(this.popupEl, 'popup');
|
||||
}
|
||||
|
||||
async loadGallery() {
|
||||
@@ -132,7 +133,7 @@ class ImageManager {
|
||||
addReturnedHtmlElementsToList(html) {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = html;
|
||||
window.components.init(el);
|
||||
window.$components.init(el);
|
||||
for (const child of [...el.children]) {
|
||||
this.listContainer.appendChild(child);
|
||||
}
|
||||
@@ -207,9 +208,7 @@ class ImageManager {
|
||||
const params = requestDelete ? {delete: true} : {};
|
||||
const {data: formHtml} = await window.$http.get(`/images/edit/${imageId}`, params);
|
||||
this.formContainer.innerHTML = formHtml;
|
||||
window.components.init(this.formContainer);
|
||||
window.$components.init(this.formContainer);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ImageManager;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user