mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 16:49:47 +03:00
Compare commits
226 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6a6af683 | ||
|
|
914790fd99 | ||
|
|
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 | ||
|
|
edb0c6a9e8 | ||
|
|
84049de696 | ||
|
|
a37bdffcd9 | ||
|
|
e95ab36f76 | ||
|
|
f809bd3a62 | ||
|
|
d4e71e431b | ||
|
|
de807f8538 | ||
|
|
80d2889217 | ||
|
|
9e8516c2df | ||
|
|
09f2bc28d2 | ||
|
|
be320c5501 | ||
|
|
2bbf7b2194 | ||
|
|
ab184c01d8 | ||
|
|
2c114e1a4a | ||
|
|
ec4cbbd004 | ||
|
|
f75091a1c5 | ||
|
|
98b59a1024 | ||
|
|
0ef06fd298 | ||
|
|
986346a0e9 | ||
|
|
2a65331573 | ||
|
|
45d0860448 | ||
|
|
da0531e63b | ||
|
|
421dc75f4e | ||
|
|
ea6eacb400 | ||
|
|
8ae91df038 | ||
|
|
64b41dd626 | ||
|
|
103649887f | ||
|
|
7b2fd515da | ||
|
|
3f61bfc43c | ||
|
|
905d339572 | ||
|
|
5d37a814fd | ||
|
|
f9c0edbd0c | ||
|
|
d084f225a0 | ||
|
|
ff3fb2ebb9 | ||
|
|
725ff5a328 | ||
|
|
f0ac454be1 | ||
|
|
0269f5122e | ||
|
|
6adc642d2f | ||
|
|
22a91c955d | ||
|
|
6951aa3d39 | ||
|
|
bd412ddbf9 | ||
|
|
7792da99ce | ||
|
|
98c6422fa6 | ||
|
|
25708542ff | ||
|
|
0fae807713 | ||
|
|
0f68be608d | ||
|
|
63056dbef4 | ||
|
|
803934d020 | ||
|
|
ffd6a1002e | ||
|
|
bf591765c1 | ||
|
|
06a7f1b54a | ||
|
|
3839bf6bf1 | ||
|
|
aee0e16194 | ||
|
|
1d3dbd6f6e | ||
|
|
1df9ec9647 | ||
|
|
d4143c3101 | ||
|
|
a03245e427 | ||
|
|
a090720241 | ||
|
|
b8b0afa0df | ||
|
|
f19bad8903 | ||
|
|
953402f2eb | ||
|
|
8c945034b9 | ||
|
|
900e853b15 | ||
|
|
b56f7355aa | ||
|
|
068a8a068c | ||
|
|
0e94fd44a8 | ||
|
|
ccbc68b560 | ||
|
|
f79b7bc799 | ||
|
|
60171b3522 | ||
|
|
8f3430d386 | ||
|
|
1ac1cf0c78 | ||
|
|
6dd89ba956 | ||
|
|
bf56254077 | ||
|
|
d933fe5dce | ||
|
|
391fb2cc62 | ||
|
|
af11e7dd54 | ||
|
|
af434d0216 | ||
|
|
931641ed2c | ||
|
|
b716fd2b8b | ||
|
|
a6a78d2ab5 | ||
|
|
67d7534d4f | ||
|
|
f21669c0c9 | ||
|
|
e18033ec1a | ||
|
|
5c5ea64228 | ||
|
|
90b4257889 | ||
|
|
f4388d5e4a | ||
|
|
7165481075 | ||
|
|
ebd6e4d3a2 | ||
|
|
80374aea5c | ||
|
|
aec772c5eb | ||
|
|
2e4d29e062 | ||
|
|
dce6a82954 | ||
|
|
050d69ea27 | ||
|
|
0cc68b7665 | ||
|
|
75d6b56072 | ||
|
|
ac27b5aebb | ||
|
|
ecbc7344fc | ||
|
|
8a749c6acf | ||
|
|
2ac9efae7d | ||
|
|
a11d565ba4 | ||
|
|
d0dc5e5c5d | ||
|
|
e4642257a6 | ||
|
|
f7418d0600 | ||
|
|
98aed794cc | ||
|
|
623ccd4cfa | ||
|
|
d8672944a5 | ||
|
|
6955b2fd5a | ||
|
|
24f82749ff | ||
|
|
b9941e8e61 | ||
|
|
7101ce3050 | ||
|
|
fbef0d06f2 | ||
|
|
b698bb0e07 | ||
|
|
2d7552aa09 | ||
|
|
ee1e936660 | ||
|
|
50214d5fe6 | ||
|
|
2fe261e207 | ||
|
|
9158a66bff | ||
|
|
7f8b3eff5a | ||
|
|
5736919836 | ||
|
|
c76b5e2ec4 | ||
|
|
092b6d6378 | ||
|
|
f88330202b | ||
|
|
f28ed0ef0b | ||
|
|
27ac122502 | ||
|
|
9da3130a12 | ||
|
|
1afc915aed | ||
|
|
34c63e1c30 | ||
|
|
f092c97748 | ||
|
|
9153be963d | ||
|
|
1cc7c649dc | ||
|
|
e537d0c4e8 | ||
|
|
961e418cb7 | ||
|
|
6edf2c155d | ||
|
|
401c156687 | ||
|
|
760eff397f | ||
|
|
d134639eca | ||
|
|
b86ee6d252 | ||
|
|
0dbf08453f | ||
|
|
26ccb7b644 | ||
|
|
f634b4ea57 | ||
|
|
d198332d3c | ||
|
|
d5465726e2 | ||
|
|
bbe504c559 | ||
|
|
3290ab3ac9 | ||
|
|
5d29d0cc7b | ||
|
|
344b3a3615 | ||
|
|
837fd74bf6 | ||
|
|
2b06e86d53 | ||
|
|
9041e25476 | ||
|
|
1fdf854ea7 | ||
|
|
e9c9792cb9 | ||
|
|
d6235bcf92 | ||
|
|
6a3f4f5e79 | ||
|
|
7b100ef361 | ||
|
|
443415ea0d | ||
|
|
e02bd5e57e | ||
|
|
5f7cd735ea | ||
|
|
89ff0d43bb | ||
|
|
375abca1ee | ||
|
|
031c67ba58 | ||
|
|
764489e30b | ||
|
|
16eedc8264 | ||
|
|
b987bea37a |
@@ -263,7 +263,11 @@ OIDC_ISSUER_DISCOVER=false
|
||||
OIDC_PUBLIC_KEY=null
|
||||
OIDC_AUTH_ENDPOINT=null
|
||||
OIDC_TOKEN_ENDPOINT=null
|
||||
OIDC_ADDITIONAL_SCOPES=null
|
||||
OIDC_DUMP_USER_DETAILS=false
|
||||
OIDC_USER_TO_GROUPS=false
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
OIDC_REMOVE_FROM_GROUPS=false
|
||||
|
||||
# Disable default third-party services such as Gravatar and Draw.IO
|
||||
# Service-specific options will override this option
|
||||
@@ -295,7 +299,7 @@ APP_DEFAULT_DARK_MODE=false
|
||||
# Page revision limit
|
||||
# Number of page revisions to keep in the system before deleting old revisions.
|
||||
# If set to 'false' a limit will not be enforced.
|
||||
REVISION_LIMIT=50
|
||||
REVISION_LIMIT=100
|
||||
|
||||
# Recycle Bin Lifetime
|
||||
# The number of days that content will remain in the recycle bin before
|
||||
|
||||
34
.github/translators.txt
vendored
34
.github/translators.txt
vendored
@@ -56,6 +56,7 @@ Name :: Languages
|
||||
@arcoai :: Spanish
|
||||
@Jokuna :: Korean
|
||||
@smartshogu :: German; German Informal
|
||||
@samadha56 :: Persian
|
||||
cipi1965 :: Italian
|
||||
Mykola Ronik (Mantikor) :: Ukrainian
|
||||
furkanoyk :: Turkish
|
||||
@@ -137,7 +138,7 @@ Xiphoseer :: German
|
||||
MerlinSVK (merlinsvk) :: Slovak
|
||||
Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
|
||||
MatthieuParis :: French
|
||||
Douradinho :: Portuguese, Brazilian
|
||||
Douradinho :: Portuguese, Brazilian; Portuguese
|
||||
Gaku Yaguchi (tama11) :: Japanese
|
||||
johnroyer :: Chinese Traditional
|
||||
jackaaa :: Chinese Traditional
|
||||
@@ -175,7 +176,7 @@ Alexander Predl (Harveyhase68) :: German
|
||||
Rem (Rem9000) :: Dutch
|
||||
Michał Stelmach (stelmach-web) :: Polish
|
||||
arniom :: French
|
||||
REMOVED_USER :: Dutch; Turkish
|
||||
REMOVED_USER :: ; Dutch; Turkish
|
||||
林祖年 (contagion) :: Chinese Traditional
|
||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||
@@ -268,3 +269,32 @@ mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional
|
||||
Nanang Setia Budi (sefidananang) :: Indonesian
|
||||
Андрей Павлов (andrei.pavlov) :: Russian
|
||||
Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian
|
||||
Ji-Hyeon Gim (PotatoGim) :: Korean
|
||||
Mihai Ochian (soulstorm19) :: Romanian
|
||||
HeartCore :: German Informal; German
|
||||
simon.pct :: French
|
||||
okaeiz :: Persian
|
||||
Naoto Ishikawa (na3shkw) :: Japanese
|
||||
sdhadi :: Persian
|
||||
DerLinkman (derlinkman) :: German; German Informal
|
||||
TurnArabic :: Arabic
|
||||
Martin Sebek (sebekmartin) :: Czech
|
||||
Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
|
||||
digilady :: Greek
|
||||
Linus (LinusOP) :: Swedish
|
||||
Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian
|
||||
RandomUser0815 :: German
|
||||
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
|
||||
|
||||
@@ -1,36 +1,34 @@
|
||||
name: phpstan
|
||||
name: analyse-php
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4']
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
php-version: 8.1
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||
|
||||
- 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@v1
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
key: ${{ runner.os }}-composer-8.1
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --ansi
|
||||
|
||||
- name: Run PHPStan
|
||||
run: php${{ matrix.php }} ./vendor/bin/phpstan analyse --memory-limit=2G
|
||||
- name: Run static analysis check
|
||||
run: composer check-static
|
||||
19
.github/workflows/lint-php.yml
vendored
Normal file
19
.github/workflows/lint-php.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: lint-php
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
tools: phpcs
|
||||
|
||||
- name: Run formatting check
|
||||
run: composer lint
|
||||
9
.github/workflows/test-migrations.yml
vendored
9
.github/workflows/test-migrations.yml
vendored
@@ -5,10 +5,10 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-20.04
|
||||
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,13 +21,14 @@ 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@v1
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Start MySQL
|
||||
run: |
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
name: phpunit
|
||||
name: test-php
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-20.04
|
||||
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,13 +21,14 @@ 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@v1
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Start Database
|
||||
run: |
|
||||
@@ -48,5 +49,5 @@ jobs:
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||
php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
|
||||
|
||||
- name: phpunit
|
||||
- name: Run PHP tests
|
||||
run: php${{ matrix.php }} ./vendor/bin/phpunit
|
||||
3
LICENSE
3
LICENSE
@@ -1,7 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present, Dan Brown and the BookStack Project contributors
|
||||
https://github.com/BookStackApp/BookStack/graphs/contributors
|
||||
Copyright (c) 2015-2022, Dan Brown and the BookStack Project contributors.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
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,8 +21,14 @@ 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';
|
||||
}
|
||||
|
||||
$query = Tag::query()
|
||||
->select([
|
||||
'name',
|
||||
@@ -32,7 +39,7 @@ class TagRepo
|
||||
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
|
||||
])
|
||||
->orderBy($nameFilter ? 'value' : 'name');
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
if ($nameFilter) {
|
||||
$query->where('name', '=', $nameFilter);
|
||||
@@ -57,21 +64,21 @@ class TagRepo
|
||||
* Get tag name suggestions from scanning existing tag names.
|
||||
* If no search term is given the 50 most popular tag names are provided.
|
||||
*/
|
||||
public function getNameSuggestions(?string $searchTerm): Collection
|
||||
public function getNameSuggestions(string $searchTerm): Collection
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select('*', DB::raw('count(*) as count'))
|
||||
->groupBy('name');
|
||||
|
||||
if ($searchTerm) {
|
||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'asc');
|
||||
} else {
|
||||
$query = $query->orderBy('count', 'desc')->take(50);
|
||||
}
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
|
||||
return $query->get(['name'])->pluck('name');
|
||||
return $query->pluck('name');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +86,7 @@ class TagRepo
|
||||
* If no search is given the 50 most popular values are provided.
|
||||
* Passing a tagName will only find values for a tags with a particular name.
|
||||
*/
|
||||
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
|
||||
public function getValueSuggestions(string $searchTerm, string $tagName): Collection
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select('*', DB::raw('count(*) as count'))
|
||||
@@ -97,7 +104,7 @@ class TagRepo
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
|
||||
return $query->get(['value'])->pluck('value');
|
||||
return $query->pluck('value');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,10 +22,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
*/
|
||||
class Webhook extends Model implements Loggable
|
||||
{
|
||||
protected $fillable = ['name', 'endpoint', 'timeout'];
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'endpoint', 'timeout'];
|
||||
|
||||
protected $casts = [
|
||||
'last_called_at' => 'datetime',
|
||||
'last_errored_at' => 'datetime',
|
||||
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class WebhookTrackedEvent extends Model
|
||||
{
|
||||
protected $fillable = ['event'];
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['event'];
|
||||
}
|
||||
|
||||
107
app/Api/ApiEntityListFormatter.php
Normal file
107
app/Api/ApiEntityListFormatter.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class ApiEntityListFormatter
|
||||
{
|
||||
/**
|
||||
* The list to be formatted.
|
||||
* @var Entity[]
|
||||
*/
|
||||
protected $list = [];
|
||||
|
||||
/**
|
||||
* The fields to show in the formatted data.
|
||||
* Can be a plain string array item for a direct model field (If existing on model).
|
||||
* If the key is a string, with a callable value, the return value of the callable
|
||||
* will be used for the resultant value. A null return value will omit the property.
|
||||
* @var array<string|int, string|callable>
|
||||
*/
|
||||
protected $fields = [
|
||||
'id', 'name', 'slug', 'book_id', 'chapter_id',
|
||||
'draft', 'template', 'created_at', 'updated_at',
|
||||
];
|
||||
|
||||
public function __construct(array $list)
|
||||
{
|
||||
$this->list = $list;
|
||||
|
||||
// Default dynamic fields
|
||||
$this->withField('url', fn(Entity $entity) => $entity->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a field to be used in the formatter, with the property using the given
|
||||
* name and value being the return type of the given callback.
|
||||
*/
|
||||
public function withField(string $property, callable $callback): self
|
||||
{
|
||||
$this->fields[$property] = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the 'type' property in the response reflecting the entity type.
|
||||
* EG: page, chapter, bookshelf, book
|
||||
* To be included in results with non-pre-determined types.
|
||||
*/
|
||||
public function withType(): self
|
||||
{
|
||||
$this->withField('type', fn(Entity $entity) => $entity->getType());
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Include tags in the formatted data.
|
||||
*/
|
||||
public function withTags(): self
|
||||
{
|
||||
$this->withField('tags', fn(Entity $entity) => $entity->tags);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the data and return an array of formatted content.
|
||||
* @return array[]
|
||||
*/
|
||||
public function format(): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($this->list as $item) {
|
||||
$results[] = $this->formatSingle($item);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single entity item to a plain array.
|
||||
*/
|
||||
protected function formatSingle(Entity $entity): array
|
||||
{
|
||||
$result = [];
|
||||
$values = (clone $entity)->toArray();
|
||||
|
||||
foreach ($this->fields as $field => $callback) {
|
||||
if (is_string($callback)) {
|
||||
$field = $callback;
|
||||
if (!isset($values[$field])) {
|
||||
continue;
|
||||
}
|
||||
$value = $values[$field];
|
||||
} else {
|
||||
$value = $callback($entity);
|
||||
if (is_null($value)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$result[$field] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,31 @@
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Model;
|
||||
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' => '>',
|
||||
@@ -63,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;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ class LdapService
|
||||
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
|
||||
'dn' => $user['dn'],
|
||||
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
|
||||
'avatar'=> $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
||||
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
||||
];
|
||||
|
||||
if ($this->config['dump_user_details']) {
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Auth\Access;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\Mfa\MfaSession;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
@@ -149,6 +150,7 @@ class LoginService
|
||||
* May interrupt the flow if extra authentication requirements are imposed.
|
||||
*
|
||||
* @throws StoppedAuthenticationException
|
||||
* @throws LoginAttemptException
|
||||
*/
|
||||
public function attempt(array $credentials, string $method, bool $remember = false): bool
|
||||
{
|
||||
|
||||
@@ -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']}");
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
*/
|
||||
protected $tokenEndpoint;
|
||||
|
||||
/**
|
||||
* Scopes to use for the OIDC authorization call.
|
||||
*/
|
||||
protected array $scopes = ['openid', 'profile', 'email'];
|
||||
|
||||
/**
|
||||
* Returns the base URL for authorizing a client.
|
||||
*/
|
||||
@@ -54,6 +59,15 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an additional scope to this provider upon the default.
|
||||
*/
|
||||
public function addScope(string $scope): void
|
||||
{
|
||||
$this->scopes[] = $scope;
|
||||
$this->scopes = array_unique($this->scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default scopes used by this provider.
|
||||
*
|
||||
@@ -62,7 +76,7 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
*/
|
||||
protected function getDefaultScopes(): array
|
||||
{
|
||||
return ['openid', 'profile', 'email'];
|
||||
return $this->scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,20 +2,18 @@
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use function auth;
|
||||
use BookStack\Auth\Access\GroupSyncService;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use function config;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
||||
use Psr\Http\Client\ClientInterface as HttpClient;
|
||||
use function trans;
|
||||
use function url;
|
||||
|
||||
/**
|
||||
* Class OpenIdConnectService
|
||||
@@ -26,15 +24,21 @@ class OidcService
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
protected HttpClient $httpClient;
|
||||
protected GroupSyncService $groupService;
|
||||
|
||||
/**
|
||||
* OpenIdService constructor.
|
||||
*/
|
||||
public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
|
||||
{
|
||||
public function __construct(
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService,
|
||||
HttpClient $httpClient,
|
||||
GroupSyncService $groupService
|
||||
) {
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
$this->httpClient = $httpClient;
|
||||
$this->groupService = $groupService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +52,6 @@ class OidcService
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
return [
|
||||
'url' => $provider->getAuthorizationUrl(),
|
||||
'state' => $provider->getState(),
|
||||
@@ -117,10 +120,31 @@ class OidcService
|
||||
*/
|
||||
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
|
||||
{
|
||||
return new OidcOAuthProvider($settings->arrayForProvider(), [
|
||||
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
|
||||
'httpClient' => $this->httpClient,
|
||||
'optionProvider' => new HttpBasicAuthOptionProvider(),
|
||||
]);
|
||||
|
||||
foreach ($this->getAdditionalScopes() as $scope) {
|
||||
$provider->addScope($scope);
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get any user-defined addition/custom scopes to apply to the authentication request.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function getAdditionalScopes(): array
|
||||
{
|
||||
$scopeConfig = $this->config()['additional_scopes'] ?: '';
|
||||
|
||||
$scopeArr = explode(',', $scopeConfig);
|
||||
$scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr);
|
||||
|
||||
return array_filter($scopeArr);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,10 +169,32 @@ class OidcService
|
||||
return implode(' ', $displayName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the assigned groups from the id token.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function getUserGroups(OidcIdToken $token): array
|
||||
{
|
||||
$groupsAttr = $this->config()['groups_claim'];
|
||||
if (empty($groupsAttr)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
|
||||
if (!is_array($groupsList)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($groupsList, function ($val) {
|
||||
return is_string($val);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the details of a user from an ID token.
|
||||
*
|
||||
* @return array{name: string, email: string, external_id: string}
|
||||
* @return array{name: string, email: string, external_id: string, groups: string[]}
|
||||
*/
|
||||
protected function getUserDetails(OidcIdToken $token): array
|
||||
{
|
||||
@@ -158,6 +204,7 @@ class OidcService
|
||||
'external_id' => $id,
|
||||
'email' => $token->getClaim('email'),
|
||||
'name' => $this->getUserDisplayName($token, $id),
|
||||
'groups' => $this->getUserGroups($token),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -209,6 +256,12 @@ class OidcService
|
||||
throw new OidcException($exception->getMessage());
|
||||
}
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$groups = $userDetails['groups'];
|
||||
$detachExisting = $this->config()['remove_from_groups'];
|
||||
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
|
||||
}
|
||||
|
||||
$this->loginService->login($user, 'oidc');
|
||||
|
||||
return $user;
|
||||
@@ -221,4 +274,12 @@ class OidcService
|
||||
{
|
||||
return config('oidc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if groups should be synced.
|
||||
*/
|
||||
protected function shouldSyncGroups(): bool
|
||||
{
|
||||
return $this->config()['user_to_groups'] !== false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,11 @@ use OneLogin\Saml2\ValidationError;
|
||||
*/
|
||||
class Saml2Service
|
||||
{
|
||||
protected $config;
|
||||
protected $registrationService;
|
||||
protected $loginService;
|
||||
protected $groupSyncService;
|
||||
protected array $config;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
protected GroupSyncService $groupSyncService;
|
||||
|
||||
/**
|
||||
* Saml2Service constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService,
|
||||
@@ -109,9 +106,10 @@ class Saml2Service
|
||||
$errors = $toolkit->getErrors();
|
||||
|
||||
if (!empty($errors)) {
|
||||
throw new Error(
|
||||
'Invalid ACS Response: ' . implode(', ', $errors)
|
||||
);
|
||||
$reason = $toolkit->getLastErrorReason();
|
||||
$message = 'Invalid ACS Response; Errors: ' . implode(', ', $errors);
|
||||
$message .= $reason ? "; Reason: {$reason}" : '';
|
||||
throw new Error($message);
|
||||
}
|
||||
|
||||
if (!$toolkit->isAuthenticated()) {
|
||||
@@ -168,7 +166,7 @@ class Saml2Service
|
||||
*/
|
||||
public function metadata(): string
|
||||
{
|
||||
$toolKit = $this->getToolkit();
|
||||
$toolKit = $this->getToolkit(true);
|
||||
$settings = $toolKit->getSettings();
|
||||
$metadata = $settings->getSPMetadata();
|
||||
$errors = $settings->validateMetadata($metadata);
|
||||
@@ -189,7 +187,7 @@ class Saml2Service
|
||||
* @throws Error
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function getToolkit(): Auth
|
||||
protected function getToolkit(bool $spOnly = false): Auth
|
||||
{
|
||||
$settings = $this->config['onelogin'];
|
||||
$overrides = $this->config['onelogin_overrides'] ?? [];
|
||||
@@ -199,14 +197,14 @@ class Saml2Service
|
||||
}
|
||||
|
||||
$metaDataSettings = [];
|
||||
if ($this->config['autoload_from_metadata']) {
|
||||
if (!$spOnly && $this->config['autoload_from_metadata']) {
|
||||
$metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
|
||||
}
|
||||
|
||||
$spSettings = $this->loadOneloginServiceProviderDetails();
|
||||
$settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
|
||||
|
||||
return new Auth($settings);
|
||||
return new Auth($settings, $spOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,20 +2,41 @@
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $role_id
|
||||
* @property int $entity_id
|
||||
* @property string $entity_type
|
||||
* @property boolean $view
|
||||
* @property boolean $create
|
||||
* @property boolean $update
|
||||
* @property boolean $delete
|
||||
*/
|
||||
class EntityPermission extends Model
|
||||
{
|
||||
protected $fillable = ['role_id', 'action'];
|
||||
public const PERMISSIONS = ['view', 'create', 'update', 'delete'];
|
||||
|
||||
protected $fillable = ['role_id', 'view', 'create', 'update', 'delete'];
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Get all this restriction's attached entity.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
* Get this restriction's attached entity.
|
||||
*/
|
||||
public function restrictable()
|
||||
public function restrictable(): MorphTo
|
||||
{
|
||||
return $this->morphTo('restrictable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role assigned to this entity permission.
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class JointPermissionBuilder
|
||||
/**
|
||||
* @var array<string, array<int, SimpleEntityData>>
|
||||
*/
|
||||
protected $entityCache;
|
||||
protected array $entityCache;
|
||||
|
||||
/**
|
||||
* Re-generate all entity permission from scratch.
|
||||
@@ -40,7 +40,7 @@ class JointPermissionBuilder
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
|
||||
Bookshelf::query()->withTrashed()->select(['id', 'owned_by'])
|
||||
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
|
||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||
});
|
||||
@@ -92,7 +92,7 @@ class JointPermissionBuilder
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
|
||||
Bookshelf::query()->select(['id', 'owned_by'])
|
||||
->chunk(50, function ($shelves) use ($roles) {
|
||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||
});
|
||||
@@ -138,12 +138,12 @@ class JointPermissionBuilder
|
||||
protected function bookFetchQuery(): Builder
|
||||
{
|
||||
return Book::query()->withTrashed()
|
||||
->select(['id', 'restricted', 'owned_by'])->with([
|
||||
->select(['id', 'owned_by'])->with([
|
||||
'chapters' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
|
||||
$query->withTrashed()->select(['id', 'owned_by', 'book_id']);
|
||||
},
|
||||
'pages' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
|
||||
$query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -218,7 +218,6 @@ class JointPermissionBuilder
|
||||
$simple = new SimpleEntityData();
|
||||
$simple->id = $attrs['id'];
|
||||
$simple->type = $entity->getMorphClass();
|
||||
$simple->restricted = boolval($attrs['restricted'] ?? 0);
|
||||
$simple->owned_by = $attrs['owned_by'] ?? 0;
|
||||
$simple->book_id = $attrs['book_id'] ?? null;
|
||||
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
||||
@@ -231,7 +230,7 @@ class JointPermissionBuilder
|
||||
/**
|
||||
* Create & Save entity jointPermissions for many entities and roles.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
* @param Entity[] $originalEntities
|
||||
* @param Role[] $roles
|
||||
*/
|
||||
protected function createManyJointPermissions(array $originalEntities, array $roles)
|
||||
@@ -240,21 +239,14 @@ class JointPermissionBuilder
|
||||
$this->readyEntityCache($entities);
|
||||
$jointPermissions = [];
|
||||
|
||||
// Create a mapping of entity restricted statuses
|
||||
$entityRestrictedMap = [];
|
||||
foreach ($entities as $entity) {
|
||||
$entityRestrictedMap[$entity->type . ':' . $entity->id] = $entity->restricted;
|
||||
}
|
||||
|
||||
// Fetch related entity permissions
|
||||
$permissions = $this->getEntityPermissionsForEntities($entities);
|
||||
|
||||
// Create a mapping of explicit entity permissions
|
||||
$permissionMap = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id;
|
||||
$isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
|
||||
$permissionMap[$key] = $isRestricted;
|
||||
$key = $permission->entity_type . ':' . $permission->entity_id . ':' . $permission->role_id;
|
||||
$permissionMap[$key] = $permission->view;
|
||||
}
|
||||
|
||||
// Create a mapping of role permissions
|
||||
@@ -319,11 +311,10 @@ class JointPermissionBuilder
|
||||
{
|
||||
$idsByType = $this->entitiesToTypeIdMap($entities);
|
||||
$permissionFetch = EntityPermission::query()
|
||||
->where('action', '=', 'view')
|
||||
->where(function (Builder $query) use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$query->orWhere(function (Builder $query) use ($type, $ids) {
|
||||
$query->where('restrictable_type', '=', $type)->whereIn('restrictable_id', $ids);
|
||||
$query->where('entity_type', '=', $type)->whereIn('entity_id', $ids);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -345,7 +336,7 @@ class JointPermissionBuilder
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, true, true);
|
||||
}
|
||||
|
||||
if ($entity->restricted) {
|
||||
if ($this->entityPermissionsActiveForRole($permissionMap, $entity, $roleId)) {
|
||||
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId);
|
||||
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess);
|
||||
@@ -358,13 +349,14 @@ class JointPermissionBuilder
|
||||
// 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 = !$book->restricted;
|
||||
$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);
|
||||
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
|
||||
if ($chapter->restricted) {
|
||||
$chapterRestricted = $this->entityPermissionsActiveForRole($permissionMap, $chapter, $roleId);
|
||||
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapterRestricted;
|
||||
if ($chapterRestricted) {
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
|
||||
}
|
||||
}
|
||||
@@ -377,14 +369,25 @@ class JointPermissionBuilder
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$key = $entity->type . ':' . $entity->id . ':' . $roleId;
|
||||
$roleKey = $entity->type . ':' . $entity->id . ':' . $roleId;
|
||||
$defaultKey = $entity->type . ':' . $entity->id . ':0';
|
||||
|
||||
return $entityMap[$key] ?? false;
|
||||
return $entityMap[$roleKey] ?? $entityMap[$defaultKey] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,13 @@ class PermissionApplicator
|
||||
$ownRolePermission = $user->can($fullPermission . '-own');
|
||||
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
|
||||
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
|
||||
$isOwner = $user->id === $ownable->getAttribute($ownerField);
|
||||
$ownableFieldVal = $ownable->getAttribute($ownerField);
|
||||
|
||||
if (is_null($ownableFieldVal)) {
|
||||
throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
|
||||
}
|
||||
|
||||
$isOwner = $user->id === $ownableFieldVal;
|
||||
$hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
|
||||
|
||||
// Handle non entity specific jointPermissions
|
||||
@@ -53,11 +59,15 @@ class PermissionApplicator
|
||||
*/
|
||||
protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
|
||||
{
|
||||
$this->ensureValidEntityAction($action);
|
||||
|
||||
$adminRoleId = Role::getSystemRole('admin')->id;
|
||||
if (in_array($adminRoleId, $userRoleIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The chain order here is very important due to the fact we walk up the chain
|
||||
// in the loop below. Earlier items in the chain have higher priority.
|
||||
$chain = [$entity];
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$chain[] = $entity->chapter;
|
||||
@@ -68,12 +78,26 @@ class PermissionApplicator
|
||||
}
|
||||
|
||||
foreach ($chain as $currentEntity) {
|
||||
if ($currentEntity->restricted) {
|
||||
return $currentEntity->permissions()
|
||||
->whereIn('role_id', $userRoleIds)
|
||||
->where('action', '=', $action)
|
||||
->count() > 0;
|
||||
$allowedByRoleId = $currentEntity->permissions()
|
||||
->whereIn('role_id', [0, ...$userRoleIds])
|
||||
->pluck($action, 'role_id');
|
||||
|
||||
// Continue up the chain if no applicable entity permission overrides.
|
||||
if ($allowedByRoleId->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we have user-role-specific permissions set, allow if any of those
|
||||
// role permissions allow access.
|
||||
$hasDefault = $allowedByRoleId->has(0);
|
||||
if (!$hasDefault || $allowedByRoleId->count() > 1) {
|
||||
return $allowedByRoleId->search(function (bool $allowed, int $roleId) {
|
||||
return $roleId !== 0 && $allowed;
|
||||
}) !== false;
|
||||
}
|
||||
|
||||
// Otherwise, return the default "Other roles" fallback value.
|
||||
return $allowedByRoleId->get(0);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -85,18 +109,16 @@ class PermissionApplicator
|
||||
*/
|
||||
public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
|
||||
{
|
||||
if (strpos($action, '-') !== false) {
|
||||
throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
|
||||
}
|
||||
$this->ensureValidEntityAction($action);
|
||||
|
||||
$permissionQuery = EntityPermission::query()
|
||||
->where('action', '=', $action)
|
||||
->where($action, '=', true)
|
||||
->whereIn('role_id', $this->getCurrentUserRoleIds());
|
||||
|
||||
if (!empty($entityClass)) {
|
||||
/** @var Entity $entityInstance */
|
||||
$entityInstance = app()->make($entityClass);
|
||||
$permissionQuery = $permissionQuery->where('restrictable_type', '=', $entityInstance->getMorphClass());
|
||||
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
|
||||
}
|
||||
|
||||
$hasPermission = $permissionQuery->count() > 0;
|
||||
@@ -245,4 +267,16 @@ class PermissionApplicator
|
||||
|
||||
return $this->currentUser()->roles->pluck('id')->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the given action is a valid and expected entity action.
|
||||
* Throws an exception if invalid otherwise does nothing.
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function ensureValidEntityAction(string $action): void
|
||||
{
|
||||
if (!in_array($action, EntityPermission::PERMISSIONS)) {
|
||||
throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
app/Auth/Permissions/PermissionFormData.php
Normal file
68
app/Auth/Permissions/PermissionFormData.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class PermissionFormData
|
||||
{
|
||||
protected Entity $entity;
|
||||
|
||||
public function __construct(Entity $entity)
|
||||
{
|
||||
$this->entity = $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permissions with assigned roles.
|
||||
*/
|
||||
public function permissionsWithRoles(): array
|
||||
{
|
||||
return $this->entity->permissions()
|
||||
->with('role')
|
||||
->where('role_id', '!=', 0)
|
||||
->get()
|
||||
->sortBy('role.display_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');
|
||||
return Role::query()
|
||||
->where('system_name', '!=', 'admin')
|
||||
->whereNotIn('id', $assigned)
|
||||
->orderBy('display_name', 'asc')
|
||||
->get()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permission for the "Everyone Else" option.
|
||||
*/
|
||||
public function everyoneElseEntityPermission(): EntityPermission
|
||||
{
|
||||
/** @var ?EntityPermission $permission */
|
||||
$permission = $this->entity->permissions()
|
||||
->where('role_id', '=', 0)
|
||||
->first();
|
||||
return $permission ?? (new EntityPermission());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the "Everyone Else" role entry.
|
||||
*/
|
||||
public function everyoneElseRole(): Role
|
||||
{
|
||||
return (new Role())->forceFill([
|
||||
'id' => 0,
|
||||
'display_name' => trans('entities.permissions_role_everyone_else'),
|
||||
'description' => trans('entities.permissions_role_everyone_else_desc'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,7 @@ class PermissionsRepo
|
||||
}
|
||||
}
|
||||
|
||||
$role->entityPermissions()->delete();
|
||||
$role->jointPermissions()->delete();
|
||||
Activity::add(ActivityType::ROLE_DELETE, $role);
|
||||
$role->delete();
|
||||
|
||||
@@ -6,7 +6,6 @@ class SimpleEntityData
|
||||
{
|
||||
public int $id;
|
||||
public string $type;
|
||||
public bool $restricted;
|
||||
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,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\RolePermission;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
@@ -54,6 +55,14 @@ class Role extends Model implements Loggable
|
||||
return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions assigned to this role.
|
||||
*/
|
||||
public function entityPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntityPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this role has a permission.
|
||||
*/
|
||||
@@ -101,25 +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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles that can be restricted.
|
||||
*/
|
||||
public static function restrictable(): Collection
|
||||
{
|
||||
return static::query()
|
||||
->where('system_name', '!=', 'admin')
|
||||
->orderBy('display_name', 'asc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
||||
@@ -80,6 +80,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
*/
|
||||
protected ?Collection $permissions;
|
||||
|
||||
/**
|
||||
* This holds the user's avatar URL when loaded to prevent re-calculating within the same request.
|
||||
*/
|
||||
protected string $avatarUrl = '';
|
||||
|
||||
/**
|
||||
* This holds the default user when loaded.
|
||||
*
|
||||
@@ -233,12 +238,18 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (!empty($this->avatarUrl)) {
|
||||
return $this->avatarUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
|
||||
} catch (Exception $err) {
|
||||
$avatar = $default;
|
||||
}
|
||||
|
||||
$this->avatarUrl = $avatar;
|
||||
|
||||
return $avatar;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\UserAvatars;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -61,7 +62,7 @@ class UserRepo
|
||||
$user = new User();
|
||||
$user->name = $data['name'];
|
||||
$user->email = $data['email'];
|
||||
$user->password = bcrypt(empty($data['password']) ? Str::random(32) : $data['password']);
|
||||
$user->password = Hash::make(empty($data['password']) ? Str::random(32) : $data['password']);
|
||||
$user->email_confirmed = $emailConfirmed;
|
||||
$user->external_auth_id = $data['external_auth_id'] ?? '';
|
||||
|
||||
@@ -126,7 +127,7 @@ class UserRepo
|
||||
}
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
$user->password = bcrypt($data['password']);
|
||||
$user->password = Hash::make($data['password']);
|
||||
}
|
||||
|
||||
if (!empty($data['language'])) {
|
||||
@@ -157,6 +158,9 @@ class UserRepo
|
||||
// Delete user profile images
|
||||
$this->userAvatar->destroyAllForUser($user);
|
||||
|
||||
// Delete related activities
|
||||
setting()->deleteUserSettings($user->id);
|
||||
|
||||
if (!empty($newOwnerId)) {
|
||||
$newOwner = User::query()->find($newOwnerId);
|
||||
if (!is_null($newOwner)) {
|
||||
|
||||
@@ -22,7 +22,7 @@ return [
|
||||
// The number of revisions to keep in the database.
|
||||
// Once this limit is reached older revisions will be deleted.
|
||||
// If set to false then a limit will not be enforced.
|
||||
'revision_limit' => env('REVISION_LIMIT', 50),
|
||||
'revision_limit' => env('REVISION_LIMIT', 100),
|
||||
|
||||
// The number of days that content will remain in the recycle bin before
|
||||
// being considered for auto-removal. It is not a guarantee that content will
|
||||
@@ -75,7 +75,7 @@ return [
|
||||
'locale' => env('APP_LANG', 'en'),
|
||||
|
||||
// Locales available
|
||||
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', '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',
|
||||
@@ -114,6 +114,8 @@ return [
|
||||
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
|
||||
Illuminate\Hashing\HashServiceProvider::class,
|
||||
Illuminate\Mail\MailServiceProvider::class,
|
||||
Illuminate\Notifications\NotificationServiceProvider::class,
|
||||
Illuminate\Pagination\PaginationServiceProvider::class,
|
||||
Illuminate\Pipeline\PipelineServiceProvider::class,
|
||||
Illuminate\Queue\QueueServiceProvider::class,
|
||||
Illuminate\Redis\RedisServiceProvider::class,
|
||||
@@ -121,27 +123,22 @@ return [
|
||||
Illuminate\Session\SessionServiceProvider::class,
|
||||
Illuminate\Validation\ValidationServiceProvider::class,
|
||||
Illuminate\View\ViewServiceProvider::class,
|
||||
Illuminate\Notifications\NotificationServiceProvider::class,
|
||||
SocialiteProviders\Manager\ServiceProvider::class,
|
||||
|
||||
// Third party service providers
|
||||
Intervention\Image\ImageServiceProvider::class,
|
||||
Barryvdh\DomPDF\ServiceProvider::class,
|
||||
Barryvdh\Snappy\ServiceProvider::class,
|
||||
|
||||
// BookStack replacement service providers (Extends Laravel)
|
||||
BookStack\Providers\PaginationServiceProvider::class,
|
||||
BookStack\Providers\TranslationServiceProvider::class,
|
||||
Intervention\Image\ImageServiceProvider::class,
|
||||
SocialiteProviders\Manager\ServiceProvider::class,
|
||||
|
||||
// BookStack custom service providers
|
||||
BookStack\Providers\ThemeServiceProvider::class,
|
||||
BookStack\Providers\AuthServiceProvider::class,
|
||||
BookStack\Providers\AppServiceProvider::class,
|
||||
BookStack\Providers\BroadcastServiceProvider::class,
|
||||
BookStack\Providers\AuthServiceProvider::class,
|
||||
BookStack\Providers\EventServiceProvider::class,
|
||||
BookStack\Providers\RouteServiceProvider::class,
|
||||
BookStack\Providers\CustomFacadeProvider::class,
|
||||
BookStack\Providers\CustomValidationServiceProvider::class,
|
||||
BookStack\Providers\TranslationServiceProvider::class,
|
||||
BookStack\Providers\ValidationRuleServiceProvider::class,
|
||||
BookStack\Providers\ViewTweaksServiceProvider::class,
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* Configuration should be altered via the `.env` file or environment variables.
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
|
||||
$dompdfPaperSizeMap = [
|
||||
'a4' => 'a4',
|
||||
'letter' => 'letter',
|
||||
|
||||
@@ -32,4 +32,16 @@ return [
|
||||
// OAuth2 endpoints.
|
||||
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
|
||||
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
|
||||
|
||||
// Add extra scopes, upon those required, to the OIDC authentication request
|
||||
// Multiple values can be provided comma seperated.
|
||||
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
|
||||
|
||||
// Group sync options
|
||||
// Enable syncing, upon login, of OIDC groups to BookStack roles
|
||||
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
|
||||
// Attribute, within a OIDC ID token, to find group names within
|
||||
'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'),
|
||||
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
|
||||
'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
|
||||
];
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* Configuration should be altered via the `.env` file or environment variables.
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
|
||||
$snappyPaperSizeMap = [
|
||||
'a4' => 'A4',
|
||||
'letter' => 'Letter',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CopyShelfPermissions extends Command
|
||||
@@ -25,19 +25,16 @@ class CopyShelfPermissions extends Command
|
||||
*/
|
||||
protected $description = 'Copy shelf permissions to all child books';
|
||||
|
||||
/**
|
||||
* @var BookshelfRepo
|
||||
*/
|
||||
protected $bookshelfRepo;
|
||||
protected PermissionsUpdater $permissionsUpdater;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(BookshelfRepo $repo)
|
||||
public function __construct(PermissionsUpdater $permissionsUpdater)
|
||||
{
|
||||
$this->bookshelfRepo = $repo;
|
||||
$this->permissionsUpdater = $permissionsUpdater;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -69,18 +66,18 @@ class CopyShelfPermissions extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
$shelves = Bookshelf::query()->get(['id', 'restricted']);
|
||||
$shelves = Bookshelf::query()->get(['id']);
|
||||
}
|
||||
|
||||
if ($shelfSlug) {
|
||||
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id', 'restricted']);
|
||||
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id']);
|
||||
if ($shelves->count() === 0) {
|
||||
$this->info('No shelves found with the given slug.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($shelves as $shelf) {
|
||||
$this->bookshelfRepo->copyDownPermissions($shelf, false);
|
||||
$this->permissionsUpdater->updateBookPermissionsFromShelf($shelf, false);
|
||||
$this->info('Copied permissions for shelf [' . $shelf->id . ']');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Console\Commands;
|
||||
use BookStack\Actions\Comment;
|
||||
use BookStack\Actions\CommentRepo;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegenerateCommentContent extends Command
|
||||
{
|
||||
@@ -43,9 +44,9 @@ class RegenerateCommentContent extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$connection = \DB::getDefaultConnection();
|
||||
$connection = DB::getDefaultConnection();
|
||||
if ($this->option('database') !== null) {
|
||||
\DB::setDefaultConnection($this->option('database'));
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
Comment::query()->chunk(100, function ($comments) {
|
||||
@@ -55,7 +56,9 @@ class RegenerateCommentContent extends Command
|
||||
}
|
||||
});
|
||||
|
||||
\DB::setDefaultConnection($connection);
|
||||
DB::setDefaultConnection($connection);
|
||||
$this->comment('Comment HTML content has been regenerated');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,5 +50,7 @@ class RegeneratePermissions extends Command
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
$this->comment('Permissions regenerated');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
59
app/Console/Commands/RegenerateReferences.php
Normal file
59
app/Console/Commands/RegenerateReferences.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\References\ReferenceStore;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegenerateReferences extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:regenerate-references {--database= : The database connection to use.}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Regenerate all the cross-item model reference index';
|
||||
|
||||
protected ReferenceStore $references;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(ReferenceStore $references)
|
||||
{
|
||||
$this->references = $references;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$connection = DB::getDefaultConnection();
|
||||
|
||||
if ($this->option('database')) {
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
$this->references->updateForAllPages();
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
|
||||
$this->comment('References have been regenerated');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Tools\SearchIndex;
|
||||
use BookStack\Search\SearchIndex;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use Illuminate\Support\Collection;
|
||||
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
||||
* @property \Illuminate\Database\Eloquent\Collection $pages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $directPages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $shelves
|
||||
*/
|
||||
class Book extends Entity implements HasCoverImage
|
||||
{
|
||||
@@ -27,7 +28,7 @@ class Book extends Entity implements HasCoverImage
|
||||
public $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description'];
|
||||
protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];
|
||||
protected $hidden = ['pivot', 'image_id', 'deleted_at'];
|
||||
|
||||
/**
|
||||
* Get the url for this book.
|
||||
@@ -119,4 +120,13 @@ class Book extends Entity implements HasCoverImage
|
||||
|
||||
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible book by its slug.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlug(string $slug): self
|
||||
{
|
||||
return static::visible()->where('slug', '=', $slug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
@@ -57,11 +58,16 @@ abstract class BookChild extends Entity
|
||||
*/
|
||||
public function changeBook(int $newBookId): Entity
|
||||
{
|
||||
$oldUrl = $this->getUrl();
|
||||
$this->book_id = $newBookId;
|
||||
$this->refreshSlug();
|
||||
$this->save();
|
||||
$this->refresh();
|
||||
|
||||
if ($oldUrl !== $this->getUrl()) {
|
||||
app()->make(ReferenceUpdater::class)->updateEntityPageReferences($this, $oldUrl);
|
||||
}
|
||||
|
||||
// Update all child pages if a chapter
|
||||
if ($this instanceof Chapter) {
|
||||
foreach ($this->pages()->withTrashed()->get() as $page) {
|
||||
|
||||
@@ -17,7 +17,7 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
|
||||
protected $fillable = ['name', 'description', 'image_id'];
|
||||
|
||||
protected $hidden = ['restricted', 'image_id', 'deleted_at'];
|
||||
protected $hidden = ['image_id', 'deleted_at'];
|
||||
|
||||
/**
|
||||
* Get the books in this shelf.
|
||||
@@ -86,7 +86,7 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
*/
|
||||
public function coverImageTypeKey(): string
|
||||
{
|
||||
return 'cover_shelf';
|
||||
return 'cover_bookshelf';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,4 +109,13 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
$maxOrder = $this->books()->max('order');
|
||||
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible shelf by its slug.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlug(string $slug): self
|
||||
{
|
||||
return static::visible()->where('slug', '=', $slug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class Chapter extends BookChild
|
||||
public $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'priority'];
|
||||
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
|
||||
protected $hidden = ['pivot', 'deleted_at'];
|
||||
|
||||
/**
|
||||
* Get the pages that this chapter contains.
|
||||
@@ -58,4 +58,13 @@ class Chapter extends BookChild
|
||||
->orderBy('priority', 'asc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible chapter by its book and page slugs.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlugs(string $bookSlug, string $chapterSlug): self
|
||||
{
|
||||
return static::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\JointPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\Tools\SearchIndex;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Interfaces\Deletable;
|
||||
use BookStack\Interfaces\Favouritable;
|
||||
@@ -19,6 +18,9 @@ use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Interfaces\Sluggable;
|
||||
use BookStack\Interfaces\Viewable;
|
||||
use BookStack\Model;
|
||||
use BookStack\References\Reference;
|
||||
use BookStack\Search\SearchIndex;
|
||||
use BookStack\Search\SearchTerm;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use BookStack\Traits\HasOwner;
|
||||
use Carbon\Carbon;
|
||||
@@ -40,7 +42,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property Carbon $deleted_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property bool $restricted
|
||||
* @property Collection $tags
|
||||
*
|
||||
* @method static Entity|Builder visible()
|
||||
@@ -174,16 +175,15 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function permissions(): MorphMany
|
||||
{
|
||||
return $this->morphMany(EntityPermission::class, 'restrictable');
|
||||
return $this->morphMany(EntityPermission::class, 'entity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this entity has a specific restriction set against it.
|
||||
*/
|
||||
public function hasRestriction(int $role_id, string $action): bool
|
||||
public function hasPermissions(): bool
|
||||
{
|
||||
return $this->permissions()->where('role_id', '=', $role_id)
|
||||
->where('action', '=', $action)->count() > 0;
|
||||
return $this->permissions()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,6 +202,22 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
return $this->morphMany(Deletion::class, 'deletable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the references pointing from this entity to other items.
|
||||
*/
|
||||
public function referencesFrom(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Reference::class, 'from');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the references pointing to this entity from other items.
|
||||
*/
|
||||
public function referencesTo(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Reference::class, 'to');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this instance or class is a certain type of entity.
|
||||
* Examples of $type are 'page', 'book', 'chapter'.
|
||||
|
||||
@@ -39,7 +39,7 @@ class Page extends BookChild
|
||||
|
||||
public $textField = 'text';
|
||||
|
||||
protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot', 'deleted_at'];
|
||||
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
|
||||
|
||||
protected $casts = [
|
||||
'draft' => 'boolean',
|
||||
@@ -88,8 +88,6 @@ class Page extends BookChild
|
||||
|
||||
/**
|
||||
* Get the current revision for the page if existing.
|
||||
*
|
||||
* @return PageRevision|null
|
||||
*/
|
||||
public function currentRevision(): HasOne
|
||||
{
|
||||
@@ -145,4 +143,13 @@ class Page extends BookChild
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible page by its book and page slugs.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlugs(string $bookSlug, string $pageSlug): self
|
||||
{
|
||||
return static::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
class PageRevision extends Model implements Loggable
|
||||
{
|
||||
protected $fillable = ['name', 'text', 'summary'];
|
||||
protected $hidden = ['html', 'markdown', 'restricted', 'text'];
|
||||
protected $hidden = ['html', 'markdown', 'text'];
|
||||
|
||||
/**
|
||||
* Get the user that created the page revision.
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Actions\TagRepo;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HasCoverImage;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
@@ -13,11 +14,13 @@ class BaseRepo
|
||||
{
|
||||
protected TagRepo $tagRepo;
|
||||
protected ImageRepo $imageRepo;
|
||||
protected ReferenceUpdater $referenceUpdater;
|
||||
|
||||
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
|
||||
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater)
|
||||
{
|
||||
$this->tagRepo = $tagRepo;
|
||||
$this->imageRepo = $imageRepo;
|
||||
$this->referenceUpdater = $referenceUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +41,7 @@ class BaseRepo
|
||||
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
|
||||
}
|
||||
|
||||
$entity->refresh();
|
||||
$entity->rebuildPermissions();
|
||||
$entity->indexForSearch();
|
||||
}
|
||||
@@ -47,10 +51,12 @@ class BaseRepo
|
||||
*/
|
||||
public function update(Entity $entity, array $input)
|
||||
{
|
||||
$oldUrl = $entity->getUrl();
|
||||
|
||||
$entity->fill($input);
|
||||
$entity->updated_by = user()->id;
|
||||
|
||||
if ($entity->isDirty('name')) {
|
||||
if ($entity->isDirty('name') || empty($entity->slug)) {
|
||||
$entity->refreshSlug();
|
||||
}
|
||||
|
||||
@@ -63,6 +69,10 @@ class BaseRepo
|
||||
|
||||
$entity->rebuildPermissions();
|
||||
$entity->indexForSearch();
|
||||
|
||||
if ($oldUrl !== $entity->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,14 +86,15 @@ class BaseRepo
|
||||
public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
{
|
||||
if ($coverImage) {
|
||||
$this->imageRepo->destroyImage($entity->cover);
|
||||
$image = $this->imageRepo->saveNew($coverImage, 'cover_book', $entity->id, 512, 512, true);
|
||||
$imageType = $entity->coverImageTypeKey();
|
||||
$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();
|
||||
}
|
||||
|
||||
@@ -134,31 +134,6 @@ class BookshelfRepo
|
||||
$shelf->books()->sync($syncData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy down the permissions of the given shelf to all child books.
|
||||
*/
|
||||
public function copyDownPermissions(Bookshelf $shelf, $checkUserPermissions = true): int
|
||||
{
|
||||
$shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
|
||||
$shelfBooks = $shelf->books()->get(['id', 'restricted']);
|
||||
$updatedBookCount = 0;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($shelfBooks as $book) {
|
||||
if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
|
||||
continue;
|
||||
}
|
||||
$book->permissions()->delete();
|
||||
$book->restricted = $shelf->restricted;
|
||||
$book->permissions()->createMany($shelfPermissions);
|
||||
$book->save();
|
||||
$book->rebuildPermissions();
|
||||
$updatedBookCount++;
|
||||
}
|
||||
|
||||
return $updatedBookCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a bookshelf from the system.
|
||||
*
|
||||
|
||||
@@ -16,20 +16,31 @@ use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\References\ReferenceStore;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class PageRepo
|
||||
{
|
||||
protected $baseRepo;
|
||||
protected BaseRepo $baseRepo;
|
||||
protected RevisionRepo $revisionRepo;
|
||||
protected ReferenceStore $referenceStore;
|
||||
protected ReferenceUpdater $referenceUpdater;
|
||||
|
||||
/**
|
||||
* PageRepo constructor.
|
||||
*/
|
||||
public function __construct(BaseRepo $baseRepo)
|
||||
{
|
||||
public function __construct(
|
||||
BaseRepo $baseRepo,
|
||||
RevisionRepo $revisionRepo,
|
||||
ReferenceStore $referenceStore,
|
||||
ReferenceUpdater $referenceUpdater
|
||||
) {
|
||||
$this->baseRepo = $baseRepo;
|
||||
$this->revisionRepo = $revisionRepo;
|
||||
$this->referenceStore = $referenceStore;
|
||||
$this->referenceUpdater = $referenceUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +50,7 @@ class PageRepo
|
||||
*/
|
||||
public function getById(int $id, array $relations = ['book']): Page
|
||||
{
|
||||
/** @var Page $page */
|
||||
$page = Page::visible()->with($relations)->find($id);
|
||||
|
||||
if (!$page) {
|
||||
@@ -70,17 +82,7 @@ class PageRepo
|
||||
*/
|
||||
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
|
||||
{
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = PageRevision::query()
|
||||
->whereHas('page', function (Builder $query) {
|
||||
$query->scopes('visible');
|
||||
})
|
||||
->where('slug', '=', $pageSlug)
|
||||
->where('type', '=', 'version')
|
||||
->where('book_slug', '=', $bookSlug)
|
||||
->orderBy('created_at', 'desc')
|
||||
->with('page')
|
||||
->first();
|
||||
$revision = $this->revisionRepo->getBySlugs($bookSlug, $pageSlug);
|
||||
|
||||
return $revision->page ?? null;
|
||||
}
|
||||
@@ -112,7 +114,7 @@ class PageRepo
|
||||
public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity
|
||||
{
|
||||
if ($chapterSlug !== null) {
|
||||
return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
||||
return Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
||||
}
|
||||
|
||||
return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
|
||||
@@ -123,9 +125,7 @@ class PageRepo
|
||||
*/
|
||||
public function getUserDraft(Page $page): ?PageRevision
|
||||
{
|
||||
$revision = $this->getUserDraftQuery($page)->first();
|
||||
|
||||
return $revision;
|
||||
return $this->revisionRepo->getLatestDraftForCurrentUser($page);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,11 +165,10 @@ class PageRepo
|
||||
$draft->draft = false;
|
||||
$draft->revision_count = 1;
|
||||
$draft->priority = $this->getNewPriority($draft);
|
||||
$draft->refreshSlug();
|
||||
$draft->save();
|
||||
|
||||
$this->savePageRevision($draft, trans('entities.pages_initial_revision'));
|
||||
$draft->indexForSearch();
|
||||
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
|
||||
$this->referenceStore->updateForPage($draft);
|
||||
$draft->refresh();
|
||||
|
||||
Activity::add(ActivityType::PAGE_CREATE, $draft);
|
||||
@@ -189,13 +188,14 @@ class PageRepo
|
||||
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$this->baseRepo->update($page, $input);
|
||||
$this->referenceStore->updateForPage($page);
|
||||
|
||||
// Update with new details
|
||||
$page->revision_count++;
|
||||
$page->save();
|
||||
|
||||
// Remove all update drafts for this user & page.
|
||||
$this->getUserDraftQuery($page)->delete();
|
||||
$this->revisionRepo->deleteDraftsForCurrentUser($page);
|
||||
|
||||
// Save a revision after updating
|
||||
$summary = trim($input['summary'] ?? '');
|
||||
@@ -203,7 +203,7 @@ class PageRepo
|
||||
$nameChanged = isset($input['name']) && $input['name'] !== $oldName;
|
||||
$markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown;
|
||||
if ($htmlChanged || $nameChanged || $markdownChanged || $summary) {
|
||||
$this->savePageRevision($page, $summary);
|
||||
$this->revisionRepo->storeNewForPage($page, $summary);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::PAGE_UPDATE, $page);
|
||||
@@ -239,32 +239,6 @@ class PageRepo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a page revision into the system.
|
||||
*/
|
||||
protected function savePageRevision(Page $page, string $summary = null): PageRevision
|
||||
{
|
||||
$revision = new PageRevision();
|
||||
|
||||
$revision->name = $page->name;
|
||||
$revision->html = $page->html;
|
||||
$revision->markdown = $page->markdown;
|
||||
$revision->text = $page->text;
|
||||
$revision->page_id = $page->id;
|
||||
$revision->slug = $page->slug;
|
||||
$revision->book_slug = $page->book->slug;
|
||||
$revision->created_by = user()->id;
|
||||
$revision->created_at = $page->updated_at;
|
||||
$revision->type = 'version';
|
||||
$revision->summary = $summary;
|
||||
$revision->revision_number = $page->revision_count;
|
||||
$revision->save();
|
||||
|
||||
$this->deleteOldRevisions($page);
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a page update draft.
|
||||
*/
|
||||
@@ -280,7 +254,7 @@ class PageRepo
|
||||
}
|
||||
|
||||
// Otherwise, save the data to a revision
|
||||
$draft = $this->getPageRevisionToUpdate($page);
|
||||
$draft = $this->revisionRepo->getNewDraftForCurrentUser($page);
|
||||
$draft->fill($input);
|
||||
|
||||
if (!empty($input['markdown'])) {
|
||||
@@ -314,6 +288,7 @@ class PageRepo
|
||||
*/
|
||||
public function restoreRevision(Page $page, int $revisionId): Page
|
||||
{
|
||||
$oldUrl = $page->getUrl();
|
||||
$page->revision_count++;
|
||||
|
||||
/** @var PageRevision $revision */
|
||||
@@ -332,9 +307,14 @@ class PageRepo
|
||||
$page->refreshSlug();
|
||||
$page->save();
|
||||
$page->indexForSearch();
|
||||
$this->referenceStore->updateForPage($page);
|
||||
|
||||
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
|
||||
$this->savePageRevision($page, $summary);
|
||||
$this->revisionRepo->storeNewForPage($page, $summary);
|
||||
|
||||
if ($oldUrl !== $page->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityPageReferences($page, $oldUrl);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::PAGE_RESTORE, $page);
|
||||
Activity::add(ActivityType::REVISION_RESTORE, $revision);
|
||||
@@ -393,48 +373,6 @@ class PageRepo
|
||||
return $parentClass::visible()->where('id', '=', $entityId)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a page revision to update for the given page.
|
||||
* Checks for an existing revisions before providing a fresh one.
|
||||
*/
|
||||
protected function getPageRevisionToUpdate(Page $page): PageRevision
|
||||
{
|
||||
$drafts = $this->getUserDraftQuery($page)->get();
|
||||
if ($drafts->count() > 0) {
|
||||
return $drafts->first();
|
||||
}
|
||||
|
||||
$draft = new PageRevision();
|
||||
$draft->page_id = $page->id;
|
||||
$draft->slug = $page->slug;
|
||||
$draft->book_slug = $page->book->slug;
|
||||
$draft->created_by = user()->id;
|
||||
$draft->type = 'update_draft';
|
||||
|
||||
return $draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old revisions, for the given page, from the system.
|
||||
*/
|
||||
protected function deleteOldRevisions(Page $page)
|
||||
{
|
||||
$revisionLimit = config('app.revision_limit');
|
||||
if ($revisionLimit === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$revisionsToDelete = PageRevision::query()
|
||||
->where('page_id', '=', $page->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->skip(intval($revisionLimit))
|
||||
->take(10)
|
||||
->get(['id']);
|
||||
if ($revisionsToDelete->count() > 0) {
|
||||
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new priority for a page.
|
||||
*/
|
||||
@@ -450,15 +388,4 @@ class PageRepo
|
||||
|
||||
return (new BookContents($page->book))->getLastPriority() + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the query to find the user's draft copies of the given page.
|
||||
*/
|
||||
protected function getUserDraftQuery(Page $page)
|
||||
{
|
||||
return PageRevision::query()->where('created_by', '=', user()->id)
|
||||
->where('type', 'update_draft')
|
||||
->where('page_id', '=', $page->id)
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
|
||||
131
app/Entities/Repos/RevisionRepo.php
Normal file
131
app/Entities/Repos/RevisionRepo.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\PageRevision;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RevisionRepo
|
||||
{
|
||||
/**
|
||||
* Get a revision by its stored book and page slug values.
|
||||
*/
|
||||
public function getBySlugs(string $bookSlug, string $pageSlug): ?PageRevision
|
||||
{
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = PageRevision::query()
|
||||
->whereHas('page', function (Builder $query) {
|
||||
$query->scopes('visible');
|
||||
})
|
||||
->where('slug', '=', $pageSlug)
|
||||
->where('type', '=', 'version')
|
||||
->where('book_slug', '=', $bookSlug)
|
||||
->orderBy('created_at', 'desc')
|
||||
->with('page')
|
||||
->first();
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest draft revision, for the given page, belonging to the current user.
|
||||
*/
|
||||
public function getLatestDraftForCurrentUser(Page $page): ?PageRevision
|
||||
{
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $this->queryForCurrentUserDraft($page->id)->first();
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all drafts revisions, for the given page, belonging to the current user.
|
||||
*/
|
||||
public function deleteDraftsForCurrentUser(Page $page): void
|
||||
{
|
||||
$this->queryForCurrentUserDraft($page->id)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user update_draft page revision to update for the given page.
|
||||
* Checks for an existing revisions before providing a fresh one.
|
||||
*/
|
||||
public function getNewDraftForCurrentUser(Page $page): PageRevision
|
||||
{
|
||||
$draft = $this->getLatestDraftForCurrentUser($page);
|
||||
|
||||
if ($draft) {
|
||||
return $draft;
|
||||
}
|
||||
|
||||
$draft = new PageRevision();
|
||||
$draft->page_id = $page->id;
|
||||
$draft->slug = $page->slug;
|
||||
$draft->book_slug = $page->book->slug;
|
||||
$draft->created_by = user()->id;
|
||||
$draft->type = 'update_draft';
|
||||
|
||||
return $draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new revision in the system for the given page.
|
||||
*/
|
||||
public function storeNewForPage(Page $page, string $summary = null): PageRevision
|
||||
{
|
||||
$revision = new PageRevision();
|
||||
|
||||
$revision->name = $page->name;
|
||||
$revision->html = $page->html;
|
||||
$revision->markdown = $page->markdown;
|
||||
$revision->text = $page->text;
|
||||
$revision->page_id = $page->id;
|
||||
$revision->slug = $page->slug;
|
||||
$revision->book_slug = $page->book->slug;
|
||||
$revision->created_by = user()->id;
|
||||
$revision->created_at = $page->updated_at;
|
||||
$revision->type = 'version';
|
||||
$revision->summary = $summary;
|
||||
$revision->revision_number = $page->revision_count;
|
||||
$revision->save();
|
||||
|
||||
$this->deleteOldRevisions($page);
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old revisions, for the given page, from the system.
|
||||
*/
|
||||
protected function deleteOldRevisions(Page $page)
|
||||
{
|
||||
$revisionLimit = config('app.revision_limit');
|
||||
if ($revisionLimit === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$revisionsToDelete = PageRevision::query()
|
||||
->where('page_id', '=', $page->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->skip(intval($revisionLimit))
|
||||
->take(10)
|
||||
->get(['id']);
|
||||
|
||||
if ($revisionsToDelete->count() > 0) {
|
||||
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query update draft revisions for the current user.
|
||||
*/
|
||||
protected function queryForCurrentUserDraft(int $pageId): Builder
|
||||
{
|
||||
return PageRevision::query()
|
||||
->where('created_by', '=', user()->id)
|
||||
->where('type', 'update_draft')
|
||||
->where('page_id', '=', $pageId)
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -11,22 +11,15 @@ use Illuminate\Support\Collection;
|
||||
|
||||
class BookContents
|
||||
{
|
||||
/**
|
||||
* @var Book
|
||||
*/
|
||||
protected $book;
|
||||
protected Book $book;
|
||||
|
||||
/**
|
||||
* BookContents constructor.
|
||||
*/
|
||||
public function __construct(Book $book)
|
||||
{
|
||||
$this->book = $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current priority of the last item
|
||||
* at the top-level of the book.
|
||||
* Get the current priority of the last item at the top-level of the book.
|
||||
*/
|
||||
public function getLastPriority(): int
|
||||
{
|
||||
@@ -188,7 +181,7 @@ class BookContents
|
||||
$model->changeBook($newBook->id);
|
||||
}
|
||||
|
||||
if ($chapterChanged) {
|
||||
if ($model instanceof Page && $chapterChanged) {
|
||||
$model->chapter_id = $newChapter->id ?? 0;
|
||||
}
|
||||
|
||||
@@ -242,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);
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\Tag;
|
||||
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;
|
||||
@@ -71,8 +73,10 @@ class Cloner
|
||||
$bookDetails = $this->entityToInputData($original);
|
||||
$bookDetails['name'] = $newName;
|
||||
|
||||
// Clone book
|
||||
$copyBook = $this->bookRepo->create($bookDetails);
|
||||
|
||||
// Clone contents
|
||||
$directChildren = $original->getDirectChildren();
|
||||
foreach ($directChildren as $child) {
|
||||
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
|
||||
@@ -84,6 +88,14 @@ class Cloner
|
||||
}
|
||||
}
|
||||
|
||||
// Clone bookshelf relationships
|
||||
/** @var Bookshelf $shelf */
|
||||
foreach ($original->shelves as $shelf) {
|
||||
if (userCan('bookshelf-update', $shelf)) {
|
||||
$shelf->appendBook($copyBook);
|
||||
}
|
||||
}
|
||||
|
||||
return $copyBook;
|
||||
}
|
||||
|
||||
@@ -98,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;
|
||||
@@ -111,8 +125,7 @@ class Cloner
|
||||
*/
|
||||
public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void
|
||||
{
|
||||
$targetEntity->restricted = $sourceEntity->restricted;
|
||||
$permissions = $sourceEntity->permissions()->get(['role_id', 'action'])->toArray();
|
||||
$permissions = $sourceEntity->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray();
|
||||
$targetEntity->permissions()->delete();
|
||||
$targetEntity->permissions()->createMany($permissions);
|
||||
$targetEntity->rebuildPermissions();
|
||||
|
||||
@@ -235,7 +235,7 @@ class ExportFormatter
|
||||
$linksOutput = [];
|
||||
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
|
||||
|
||||
// Replace image src with base64 encoded image strings
|
||||
// Update relative links to be absolute, with instance url
|
||||
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
|
||||
foreach ($linksOutput[0] as $index => $linkMatch) {
|
||||
$oldLinkString = $linkMatch;
|
||||
@@ -248,7 +248,6 @@ class ExportFormatter
|
||||
}
|
||||
}
|
||||
|
||||
// Replace any relative links with system domain
|
||||
return $htmlContent;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ class HierarchyTransformer
|
||||
foreach ($book->chapters as $index => $chapter) {
|
||||
$newBook = $this->transformChapterToBook($chapter);
|
||||
$shelfBookSyncData[$newBook->id] = ['order' => $index];
|
||||
if (!$newBook->restricted) {
|
||||
if (!$newBook->hasPermissions()) {
|
||||
$this->cloner->copyEntityPermissions($shelf, $newBook);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace BookStack\Entities\Tools;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
@@ -372,23 +374,30 @@ class PageContent
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find page and skip this if page not found
|
||||
// Find page to use, and default replacement to empty string for non-matches.
|
||||
/** @var ?Page $matchedPage */
|
||||
$matchedPage = Page::visible()->find($pageId);
|
||||
if ($matchedPage === null) {
|
||||
$html = str_replace($fullMatch, '', $html);
|
||||
continue;
|
||||
$replacement = '';
|
||||
|
||||
if ($matchedPage && count($splitInclude) === 1) {
|
||||
// If we only have page id, just insert all page html and continue.
|
||||
$replacement = $matchedPage->html;
|
||||
} elseif ($matchedPage && count($splitInclude) > 1) {
|
||||
// Otherwise, if our include tag defines a section, load that specific content
|
||||
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
|
||||
$replacement = trim($innerContent);
|
||||
}
|
||||
|
||||
// If we only have page id, just insert all page html and continue.
|
||||
if (count($splitInclude) === 1) {
|
||||
$html = str_replace($fullMatch, $matchedPage->html, $html);
|
||||
continue;
|
||||
}
|
||||
$themeReplacement = Theme::dispatch(
|
||||
ThemeEvents::PAGE_INCLUDE_PARSE,
|
||||
$includeId,
|
||||
$replacement,
|
||||
clone $this->page,
|
||||
$matchedPage ? (clone $matchedPage) : null,
|
||||
);
|
||||
|
||||
// Create and load HTML into a document
|
||||
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
|
||||
$html = str_replace($fullMatch, trim($innerContent), $html);
|
||||
// Perform the content replacement
|
||||
$html = str_replace($fullMatch, $themeReplacement ?? $replacement, $html);
|
||||
}
|
||||
|
||||
return $html;
|
||||
|
||||
@@ -42,7 +42,7 @@ class PageEditActivity
|
||||
$userMessage = trans('entities.pages_draft_edit_active.start_b', ['userName' => $firstDraft->createdBy->name ?? '']);
|
||||
}
|
||||
|
||||
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
|
||||
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount' => 60]);
|
||||
|
||||
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Activity;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -16,11 +19,9 @@ class PermissionsUpdater
|
||||
*/
|
||||
public function updateFromPermissionsForm(Entity $entity, Request $request)
|
||||
{
|
||||
$restricted = $request->get('restricted') === 'true';
|
||||
$permissions = $request->get('restrictions', null);
|
||||
$permissions = $request->get('permissions', null);
|
||||
$ownerId = $request->get('owned_by', null);
|
||||
|
||||
$entity->restricted = $restricted;
|
||||
$entity->permissions()->delete();
|
||||
|
||||
if (!is_null($permissions)) {
|
||||
@@ -52,18 +53,43 @@ class PermissionsUpdater
|
||||
}
|
||||
|
||||
/**
|
||||
* Format permissions provided from a permission form to be
|
||||
* EntityPermission data.
|
||||
* Format permissions provided from a permission form to be EntityPermission data.
|
||||
*/
|
||||
protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): Collection
|
||||
protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): array
|
||||
{
|
||||
return collect($permissions)->flatMap(function ($restrictions, $roleId) {
|
||||
return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
|
||||
return [
|
||||
'role_id' => $roleId,
|
||||
'action' => strtolower($action),
|
||||
];
|
||||
});
|
||||
});
|
||||
$formatted = [];
|
||||
|
||||
foreach ($permissions as $roleId => $info) {
|
||||
$entityPermissionData = ['role_id' => $roleId];
|
||||
foreach (EntityPermission::PERMISSIONS as $permission) {
|
||||
$entityPermissionData[$permission] = (($info[$permission] ?? false) === "true");
|
||||
}
|
||||
$formatted[] = $entityPermissionData;
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy down the permissions of the given shelf to all child books.
|
||||
*/
|
||||
public function updateBookPermissionsFromShelf(Bookshelf $shelf, $checkUserPermissions = true): int
|
||||
{
|
||||
$shelfPermissions = $shelf->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray();
|
||||
$shelfBooks = $shelf->books()->get(['id', 'owned_by']);
|
||||
$updatedBookCount = 0;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($shelfBooks as $book) {
|
||||
if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
|
||||
continue;
|
||||
}
|
||||
$book->permissions()->delete();
|
||||
$book->permissions()->createMany($shelfPermissions);
|
||||
$book->rebuildPermissions();
|
||||
$updatedBookCount++;
|
||||
}
|
||||
|
||||
return $updatedBookCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +376,8 @@ class TrashCan
|
||||
$entity->searchTerms()->delete();
|
||||
$entity->deletions()->delete();
|
||||
$entity->favourites()->delete();
|
||||
$entity->referencesTo()->delete();
|
||||
$entity->referencesFrom()->delete();
|
||||
|
||||
if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
|
||||
$imageService = app()->make(ImageService::class);
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Api\ApiEntityListFormatter;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BookApiController extends ApiController
|
||||
{
|
||||
protected $bookRepo;
|
||||
protected BookRepo $bookRepo;
|
||||
|
||||
public function __construct(BookRepo $bookRepo)
|
||||
{
|
||||
@@ -47,11 +51,25 @@ class BookApiController extends ApiController
|
||||
|
||||
/**
|
||||
* View the details of a single book.
|
||||
* The response data will contain 'content' property listing the chapter and pages directly within, in
|
||||
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
|
||||
* contents will have a 'type' property to distinguish between pages & chapters.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
|
||||
|
||||
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||
->withType()
|
||||
->withField('pages', function (Entity $entity) {
|
||||
if ($entity instanceof Chapter) {
|
||||
return (new ApiEntityListFormatter($entity->pages->all()))->format();
|
||||
}
|
||||
return null;
|
||||
})->format();
|
||||
$book->setAttribute('contents', $contentsApiData);
|
||||
|
||||
return response()->json($book);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,6 @@ class BookshelfApiController extends ApiController
|
||||
{
|
||||
protected BookshelfRepo $bookshelfRepo;
|
||||
|
||||
/**
|
||||
* BookshelfApiController constructor.
|
||||
*/
|
||||
public function __construct(BookshelfRepo $bookshelfRepo)
|
||||
{
|
||||
$this->bookshelfRepo = $bookshelfRepo;
|
||||
|
||||
@@ -86,6 +86,9 @@ class PageApiController extends ApiController
|
||||
*
|
||||
* Pages will always have HTML content. They may have markdown content
|
||||
* if the markdown editor was used to last update the page.
|
||||
*
|
||||
* See the "Content Security" section of these docs for security considerations when using
|
||||
* the page content returned from this endpoint.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Api\ApiEntityListFormatter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Tools\SearchOptions;
|
||||
use BookStack\Entities\Tools\SearchResultsFormatter;
|
||||
use BookStack\Entities\Tools\SearchRunner;
|
||||
use BookStack\Search\SearchOptions;
|
||||
use BookStack\Search\SearchResultsFormatter;
|
||||
use BookStack\Search\SearchRunner;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchApiController extends ApiController
|
||||
{
|
||||
protected $searchRunner;
|
||||
protected $resultsFormatter;
|
||||
protected SearchRunner $searchRunner;
|
||||
protected SearchResultsFormatter $resultsFormatter;
|
||||
|
||||
protected $rules = [
|
||||
'all' => [
|
||||
@@ -50,24 +51,17 @@ class SearchApiController extends ApiController
|
||||
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
|
||||
$this->resultsFormatter->format($results['results']->all(), $options);
|
||||
|
||||
/** @var Entity $result */
|
||||
foreach ($results['results'] as $result) {
|
||||
$result->setVisible([
|
||||
'id', 'name', 'slug', 'book_id',
|
||||
'chapter_id', 'draft', 'template',
|
||||
'created_at', 'updated_at',
|
||||
'tags', 'type', 'preview_html', 'url',
|
||||
]);
|
||||
$result->setAttribute('type', $result->getType());
|
||||
$result->setAttribute('url', $result->getUrl());
|
||||
$result->setAttribute('preview_html', [
|
||||
'name' => (string) $result->getAttribute('preview_name'),
|
||||
'content' => (string) $result->getAttribute('preview_content'),
|
||||
]);
|
||||
}
|
||||
$data = (new ApiEntityListFormatter($results['results']->all()))
|
||||
->withType()->withTags()
|
||||
->withField('preview_html', function (Entity $entity) {
|
||||
return [
|
||||
'name' => (string) $entity->getAttribute('preview_name'),
|
||||
'content' => (string) $entity->getAttribute('preview_content'),
|
||||
];
|
||||
})->format();
|
||||
|
||||
return response()->json([
|
||||
'data' => $results['results'],
|
||||
'data' => $data,
|
||||
'total' => $results['total'],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ use Illuminate\Http\Request;
|
||||
|
||||
class ConfirmEmailController extends Controller
|
||||
{
|
||||
protected $emailConfirmationService;
|
||||
protected $loginService;
|
||||
protected $userRepo;
|
||||
protected EmailConfirmationService $emailConfirmationService;
|
||||
protected LoginService $loginService;
|
||||
protected UserRepo $userRepo;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,25 +4,11 @@ namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
|
||||
class ForgotPasswordController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Reset Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller is responsible for handling password reset emails and
|
||||
| includes a trait which assists in sending these notifications from
|
||||
| your application to your users. Feel free to explore this trait.
|
||||
|
|
||||
*/
|
||||
|
||||
use SendsPasswordResetEmails;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
@@ -34,6 +20,14 @@ class ForgotPasswordController extends Controller
|
||||
$this->middleware('guard:standard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the form to request a password reset link.
|
||||
*/
|
||||
public function showLinkRequestForm()
|
||||
{
|
||||
return view('auth.passwords.email');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reset link to the given user.
|
||||
*
|
||||
@@ -50,7 +44,7 @@ class ForgotPasswordController extends Controller
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$response = $this->broker()->sendResetLink(
|
||||
$response = Password::broker()->sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
|
||||
@@ -8,30 +8,14 @@ use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Login Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller handles authenticating users for the application and
|
||||
| redirecting them to your home screen. The controller uses a trait
|
||||
| to conveniently provide its functionality to your applications.
|
||||
|
|
||||
*/
|
||||
|
||||
use AuthenticatesUsers { logout as traitLogout; }
|
||||
|
||||
/**
|
||||
* Redirection paths.
|
||||
*/
|
||||
protected $redirectTo = '/';
|
||||
protected $redirectPath = '/';
|
||||
use ThrottlesLogins;
|
||||
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected LoginService $loginService;
|
||||
@@ -47,21 +31,6 @@ class LoginController extends Controller
|
||||
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->loginService = $loginService;
|
||||
|
||||
$this->redirectPath = url('/');
|
||||
}
|
||||
|
||||
public function username()
|
||||
{
|
||||
return config('auth.method') === 'standard' ? 'email' : 'username';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the needed authorization credentials from the request.
|
||||
*/
|
||||
protected function credentials(Request $request)
|
||||
{
|
||||
return $request->only('username', 'email', 'password');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,27 +66,15 @@ class LoginController extends Controller
|
||||
|
||||
/**
|
||||
* Handle a login request to the application.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function login(Request $request)
|
||||
{
|
||||
$this->validateLogin($request);
|
||||
$username = $request->get($this->username());
|
||||
|
||||
// If the class is using the ThrottlesLogins trait, we can automatically throttle
|
||||
// the login attempts for this application. We'll key this by the username and
|
||||
// the IP address of the client making these requests into this application.
|
||||
if (method_exists($this, 'hasTooManyLoginAttempts') &&
|
||||
$this->hasTooManyLoginAttempts($request)) {
|
||||
$this->fireLockoutEvent($request);
|
||||
|
||||
// Check login throttling attempts to see if they've gone over the limit
|
||||
if ($this->hasTooManyLoginAttempts($request)) {
|
||||
Activity::logFailedLogin($username);
|
||||
|
||||
return $this->sendLockoutResponse($request);
|
||||
}
|
||||
|
||||
@@ -131,24 +88,62 @@ class LoginController extends Controller
|
||||
return $this->sendLoginAttemptExceptionResponse($exception, $request);
|
||||
}
|
||||
|
||||
// If the login attempt was unsuccessful we will increment the number of attempts
|
||||
// to login and redirect the user back to the login form. Of course, when this
|
||||
// user surpasses their maximum number of attempts they will get locked out.
|
||||
// On unsuccessful login attempt, Increment login attempts for throttling and log failed login.
|
||||
$this->incrementLoginAttempts($request);
|
||||
|
||||
Activity::logFailedLogin($username);
|
||||
|
||||
return $this->sendFailedLoginResponse($request);
|
||||
// Throw validation failure for failed login
|
||||
throw ValidationException::withMessages([
|
||||
$this->username() => [trans('auth.failed')],
|
||||
])->redirectTo('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user and perform subsequent redirect.
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::guard()->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
|
||||
|
||||
return redirect($redirectUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected username input based upon the current auth method.
|
||||
*/
|
||||
protected function username(): string
|
||||
{
|
||||
return config('auth.method') === 'standard' ? 'email' : 'username';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the needed authorization credentials from the request.
|
||||
*/
|
||||
protected function credentials(Request $request): array
|
||||
{
|
||||
return $request->only('username', 'email', 'password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the response after the user was authenticated.
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
protected function sendLoginResponse(Request $request)
|
||||
{
|
||||
$request->session()->regenerate();
|
||||
$this->clearLoginAttempts($request);
|
||||
|
||||
return redirect()->intended('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to log the user into the application.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function attemptLogin(Request $request)
|
||||
protected function attemptLogin(Request $request): bool
|
||||
{
|
||||
return $this->loginService->attempt(
|
||||
$this->credentials($request),
|
||||
@@ -157,29 +152,12 @@ class LoginController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user has been authenticated.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param mixed $user
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
protected function authenticated(Request $request, $user)
|
||||
{
|
||||
return redirect()->intended($this->redirectPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the user login request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*
|
||||
* @return void
|
||||
* @throws ValidationException
|
||||
*/
|
||||
protected function validateLogin(Request $request)
|
||||
protected function validateLogin(Request $request): void
|
||||
{
|
||||
$rules = ['password' => ['required', 'string']];
|
||||
$authMethod = config('auth.method');
|
||||
@@ -213,22 +191,6 @@ class LoginController extends Controller
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the failed login response instance.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
protected function sendFailedLoginResponse(Request $request)
|
||||
{
|
||||
throw ValidationException::withMessages([
|
||||
$this->username() => [trans('auth.failed')],
|
||||
])->redirectTo('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the intended URL location from their previous URL.
|
||||
* Ignores if not from the current app instance or if from certain
|
||||
@@ -268,20 +230,4 @@ class LoginController extends Controller
|
||||
|
||||
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user and perform subsequent redirect.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$this->traitLogout($request);
|
||||
|
||||
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
|
||||
|
||||
return redirect($redirectUri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,43 +5,20 @@ namespace BookStack\Http\Controllers\Auth;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\RegistersUsers;
|
||||
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Register Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller handles the registration of new users as well as their
|
||||
| validation and creation. By default this controller uses a trait to
|
||||
| provide this functionality without requiring any additional code.
|
||||
|
|
||||
*/
|
||||
|
||||
use RegistersUsers;
|
||||
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
|
||||
/**
|
||||
* Where to redirect users after login / registration.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $redirectTo = '/';
|
||||
protected $redirectPath = '/';
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
@@ -56,23 +33,6 @@ class RegisterController extends Controller
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
|
||||
$this->redirectTo = url('/');
|
||||
$this->redirectPath = url('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a validator for an incoming registration request.
|
||||
*
|
||||
* @return \Illuminate\Contracts\Validation\Validator
|
||||
*/
|
||||
protected function validator(array $data)
|
||||
{
|
||||
return Validator::make($data, [
|
||||
'name' => ['required', 'min:2', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,22 +75,18 @@ class RegisterController extends Controller
|
||||
|
||||
$this->showSuccessNotification(trans('auth.register_success'));
|
||||
|
||||
return redirect($this->redirectPath());
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user instance after a valid registration.
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return User
|
||||
* Get a validator for an incoming registration request.
|
||||
*/
|
||||
protected function create(array $data)
|
||||
protected function validator(array $data): ValidatorContract
|
||||
{
|
||||
return User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => Hash::make($data['password']),
|
||||
return Validator::make($data, [
|
||||
'name' => ['required', 'min:2', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,66 +3,87 @@
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\ResetsPasswords;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
|
||||
class ResetPasswordController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Reset Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller is responsible for handling password reset requests
|
||||
| and uses a simple trait to include this behavior. You're free to
|
||||
| explore this trait and override any methods you wish to tweak.
|
||||
|
|
||||
*/
|
||||
protected LoginService $loginService;
|
||||
|
||||
use ResetsPasswords;
|
||||
|
||||
protected $redirectTo = '/';
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
public function __construct(LoginService $loginService)
|
||||
{
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the password reset view for the given token.
|
||||
* If no token is present, display the link request form.
|
||||
*/
|
||||
public function showResetForm(Request $request)
|
||||
{
|
||||
$token = $request->route()->parameter('token');
|
||||
|
||||
return view('auth.passwords.reset')->with(
|
||||
['token' => $token, 'email' => $request->email]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the given user's password.
|
||||
*/
|
||||
public function reset(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'token' => 'required',
|
||||
'email' => 'required|email',
|
||||
'password' => ['required', 'confirmed', PasswordRule::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
|
||||
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
|
||||
$user->password = Hash::make($password);
|
||||
$user->setRememberToken(Str::random(60));
|
||||
$user->save();
|
||||
|
||||
$this->loginService->login($user, auth()->getDefaultDriver());
|
||||
});
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $response === Password::PASSWORD_RESET
|
||||
? $this->sendResetResponse()
|
||||
: $this->sendResetFailedResponse($request, $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the response for a successful password reset.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param string $response
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
protected function sendResetResponse(Request $request, $response)
|
||||
protected function sendResetResponse(): RedirectResponse
|
||||
{
|
||||
$message = trans('auth.reset_password_success');
|
||||
$this->showSuccessNotification($message);
|
||||
$this->showSuccessNotification(trans('auth.reset_password_success'));
|
||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user());
|
||||
|
||||
return redirect($this->redirectPath())
|
||||
->with('status', trans($response));
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the response for a failed password reset.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param string $response
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
protected function sendResetFailedResponse(Request $request, $response)
|
||||
protected function sendResetFailedResponse(Request $request, string $response): RedirectResponse
|
||||
{
|
||||
// We show invalid users as invalid tokens as to not leak what
|
||||
// users may exist in the system.
|
||||
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Support\Str;
|
||||
|
||||
class Saml2Controller extends Controller
|
||||
{
|
||||
protected $samlService;
|
||||
protected Saml2Service $samlService;
|
||||
|
||||
/**
|
||||
* Saml2Controller constructor.
|
||||
|
||||
@@ -16,9 +16,9 @@ use Laravel\Socialite\Contracts\User as SocialUser;
|
||||
|
||||
class SocialController extends Controller
|
||||
{
|
||||
protected $socialAuthService;
|
||||
protected $registrationService;
|
||||
protected $loginService;
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
|
||||
/**
|
||||
* SocialController constructor.
|
||||
@@ -28,7 +28,7 @@ class SocialController extends Controller
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService
|
||||
) {
|
||||
$this->middleware('guest')->only(['getRegister', 'postRegister']);
|
||||
$this->middleware('guest')->only(['register']);
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
|
||||
92
app/Http/Controllers/Auth/ThrottlesLogins.php
Normal file
92
app/Http/Controllers/Auth/ThrottlesLogins.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use Illuminate\Cache\RateLimiter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
trait ThrottlesLogins
|
||||
{
|
||||
/**
|
||||
* Determine if the user has too many failed login attempts.
|
||||
*/
|
||||
protected function hasTooManyLoginAttempts(Request $request): bool
|
||||
{
|
||||
return $this->limiter()->tooManyAttempts(
|
||||
$this->throttleKey($request),
|
||||
$this->maxAttempts()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the login attempts for the user.
|
||||
*/
|
||||
protected function incrementLoginAttempts(Request $request): void
|
||||
{
|
||||
$this->limiter()->hit(
|
||||
$this->throttleKey($request),
|
||||
$this->decayMinutes() * 60
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the user after determining they are locked out.
|
||||
* @throws ValidationException
|
||||
*/
|
||||
protected function sendLockoutResponse(Request $request): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$seconds = $this->limiter()->availableIn(
|
||||
$this->throttleKey($request)
|
||||
);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
$this->username() => [trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
])],
|
||||
])->status(Response::HTTP_TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the login locks for the given user credentials.
|
||||
*/
|
||||
protected function clearLoginAttempts(Request $request): void
|
||||
{
|
||||
$this->limiter()->clear($this->throttleKey($request));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the throttle key for the given request.
|
||||
*/
|
||||
protected function throttleKey(Request $request): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($request->input($this->username())) . '|' . $request->ip());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiter instance.
|
||||
*/
|
||||
protected function limiter(): RateLimiter
|
||||
{
|
||||
return app(RateLimiter::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum number of attempts to allow.
|
||||
*/
|
||||
public function maxAttempts(): int
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of minutes to throttle for.
|
||||
*/
|
||||
public function decayMinutes(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,13 @@ use Exception;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class UserInviteController extends Controller
|
||||
{
|
||||
protected $inviteService;
|
||||
protected $userRepo;
|
||||
protected UserInviteService $inviteService;
|
||||
protected UserRepo $userRepo;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
@@ -66,7 +67,7 @@ class UserInviteController extends Controller
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getById($userId);
|
||||
$user->password = bcrypt($request->get('password'));
|
||||
$user->password = Hash::make($request->get('password'));
|
||||
$user->email_confirmed = true;
|
||||
$user->save();
|
||||
|
||||
|
||||
@@ -10,41 +10,47 @@ use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
use BookStack\Entities\Tools\HierarchyTransformer;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Entities\Tools\ShelfContext;
|
||||
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;
|
||||
|
||||
class BookController extends Controller
|
||||
{
|
||||
protected $bookRepo;
|
||||
protected $entityContextManager;
|
||||
protected BookRepo $bookRepo;
|
||||
protected ShelfContext $shelfContext;
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo)
|
||||
public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo, ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->entityContextManager = $entityContextManager;
|
||||
$this->shelfContext = $entityContextManager;
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
$this->entityContextManager->clearShelfContext();
|
||||
$this->shelfContext->clearShelfContext();
|
||||
|
||||
$this->setPageTitle(trans('entities.books'));
|
||||
|
||||
@@ -54,8 +60,7 @@ class BookController extends Controller
|
||||
'popular' => $popular,
|
||||
'new' => $new,
|
||||
'view' => $view,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -122,7 +127,7 @@ class BookController extends Controller
|
||||
|
||||
View::incrementFor($book);
|
||||
if ($request->has('shelf')) {
|
||||
$this->entityContextManager->setShelfContext(intval($request->get('shelf')));
|
||||
$this->shelfContext->setShelfContext(intval($request->get('shelf')));
|
||||
}
|
||||
|
||||
$this->setPageTitle($book->getShortName());
|
||||
@@ -133,6 +138,7 @@ class BookController extends Controller
|
||||
'bookChildren' => $bookChildren,
|
||||
'bookParentShelves' => $bookParentShelves,
|
||||
'activity' => $activities->entityActivity($book, 20, 1),
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -143,7 +149,7 @@ class BookController extends Controller
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
$this->setPageTitle(trans('entities.books_edit_named', ['bookName'=>$book->getShortName()]));
|
||||
$this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()]));
|
||||
|
||||
return view('books.edit', ['book' => $book, 'current' => $book]);
|
||||
}
|
||||
@@ -205,36 +211,6 @@ class BookController extends Controller
|
||||
return redirect('/books');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the permissions view.
|
||||
*/
|
||||
public function showPermissions(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $book);
|
||||
|
||||
return view('books.permissions', [
|
||||
'book' => $book,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the restrictions for this book.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $book);
|
||||
|
||||
$permissionsUpdater->updateFromPermissionsForm($book, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.books_permissions_updated'));
|
||||
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to copy a book.
|
||||
*
|
||||
|
||||
@@ -28,7 +28,7 @@ class BookSortController extends Controller
|
||||
|
||||
$bookChildren = (new BookContents($book))->getTree(false);
|
||||
|
||||
$this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()]));
|
||||
$this->setPageTitle(trans('entities.books_sort_named', ['bookName' => $book->getShortName()]));
|
||||
|
||||
return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Actions\View;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
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;
|
||||
@@ -18,28 +19,28 @@ class BookshelfController extends Controller
|
||||
{
|
||||
protected BookshelfRepo $shelfRepo;
|
||||
protected ShelfContext $shelfContext;
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext)
|
||||
public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext, ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->shelfRepo = $shelfRepo;
|
||||
$this->shelfContext = $shelfContext;
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -53,9 +54,7 @@ class BookshelfController extends Controller
|
||||
'popular' => $popular,
|
||||
'new' => $new,
|
||||
'view' => $view,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'sortOptions' => $sortOptions,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -98,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();
|
||||
|
||||
@@ -122,8 +126,8 @@ 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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -203,46 +207,4 @@ class BookshelfController extends Controller
|
||||
|
||||
return redirect('/shelves');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the permissions view.
|
||||
*/
|
||||
public function showPermissions(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||
|
||||
return view('shelves.permissions', [
|
||||
'shelf' => $shelf,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the permissions for this bookshelf.
|
||||
*/
|
||||
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||
|
||||
$permissionsUpdater->updateFromPermissionsForm($shelf, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the permissions of a bookshelf to the child books.
|
||||
*/
|
||||
public function copyPermissions(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||
|
||||
$updateCount = $this->shelfRepo->copyDownPermissions($shelf);
|
||||
$this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,24 +9,23 @@ use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
use BookStack\Entities\Tools\HierarchyTransformer;
|
||||
use BookStack\Entities\Tools\NextPreviousContentLocator;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class ChapterController extends Controller
|
||||
{
|
||||
protected $chapterRepo;
|
||||
protected ChapterRepo $chapterRepo;
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
/**
|
||||
* ChapterController constructor.
|
||||
*/
|
||||
public function __construct(ChapterRepo $chapterRepo)
|
||||
public function __construct(ChapterRepo $chapterRepo, ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,13 +76,14 @@ class ChapterController extends Controller
|
||||
$this->setPageTitle($chapter->getShortName());
|
||||
|
||||
return view('chapters.show', [
|
||||
'book' => $chapter->book,
|
||||
'chapter' => $chapter,
|
||||
'current' => $chapter,
|
||||
'sidebarTree' => $sidebarTree,
|
||||
'pages' => $pages,
|
||||
'next' => $nextPreviousLocator->getNext(),
|
||||
'previous' => $nextPreviousLocator->getPrevious(),
|
||||
'book' => $chapter->book,
|
||||
'chapter' => $chapter,
|
||||
'current' => $chapter,
|
||||
'sidebarTree' => $sidebarTree,
|
||||
'pages' => $pages,
|
||||
'next' => $nextPreviousLocator->getNext(),
|
||||
'previous' => $nextPreviousLocator->getPrevious(),
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($chapter),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -242,38 +242,6 @@ class ChapterController extends Controller
|
||||
return redirect($chapterCopy->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Restrictions view.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function showPermissions(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $chapter);
|
||||
|
||||
return view('chapters.permissions', [
|
||||
'chapter' => $chapter,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the restrictions for this chapter.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $chapter);
|
||||
|
||||
$permissionsUpdater->updateFromPermissionsForm($chapter, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.chapters_permissions_success'));
|
||||
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the chapter to a book.
|
||||
*/
|
||||
|
||||
@@ -87,7 +87,7 @@ class FavouriteController extends Controller
|
||||
|
||||
$modelInstance = $model->newQuery()
|
||||
->where('id', '=', $modelInfo['id'])
|
||||
->first(['id', 'name']);
|
||||
->first(['id', 'name', 'owned_by']);
|
||||
|
||||
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
|
||||
if (is_null($modelInstance) || $inaccessibleEntity) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,12 +14,9 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ImageController extends Controller
|
||||
{
|
||||
protected $imageRepo;
|
||||
protected $imageService;
|
||||
protected ImageRepo $imageRepo;
|
||||
protected ImageService $imageService;
|
||||
|
||||
/**
|
||||
* ImageController constructor.
|
||||
*/
|
||||
public function __construct(ImageRepo $imageRepo, ImageService $imageService)
|
||||
{
|
||||
$this->imageRepo = $imageRepo;
|
||||
@@ -33,7 +30,7 @@ class ImageController extends Controller
|
||||
*/
|
||||
public function showImage(string $path)
|
||||
{
|
||||
if (!$this->imageService->pathExistsInLocalSecure($path)) {
|
||||
if (!$this->imageService->pathAccessibleInLocalSecure($path)) {
|
||||
throw (new NotFoundException(trans('errors.image_not_found')))
|
||||
->setSubtitle(trans('errors.image_not_found_subtitle'))
|
||||
->setDetails(trans('errors.image_not_found_details'));
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Http\Controllers;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Notifications\TestEmail;
|
||||
use BookStack\References\ReferenceStore;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -74,6 +75,24 @@ class MaintenanceController extends Controller
|
||||
$this->showErrorNotification($errorMessage);
|
||||
}
|
||||
|
||||
return redirect('/settings/maintenance#image-cleanup')->withInput();
|
||||
return redirect('/settings/maintenance#image-cleanup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to regenerate the reference index in the system.
|
||||
*/
|
||||
public function regenerateReferences(ReferenceStore $referenceStore)
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'regenerate-references');
|
||||
|
||||
try {
|
||||
$referenceStore->updateForAllPages();
|
||||
$this->showSuccessNotification(trans('settings.maint_regen_references_success'));
|
||||
} catch (\Exception $exception) {
|
||||
$this->showErrorNotification($exception->getMessage());
|
||||
}
|
||||
|
||||
return redirect('/settings/maintenance#regenerate-references');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ use BookStack\Entities\Tools\NextPreviousContentLocator;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditActivity;
|
||||
use BookStack\Entities\Tools\PageEditorData;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -23,13 +23,15 @@ use Throwable;
|
||||
class PageController extends Controller
|
||||
{
|
||||
protected PageRepo $pageRepo;
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
/**
|
||||
* PageController constructor.
|
||||
*/
|
||||
public function __construct(PageRepo $pageRepo)
|
||||
public function __construct(PageRepo $pageRepo, ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->pageRepo = $pageRepo;
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,6 +162,7 @@ class PageController extends Controller
|
||||
'pageNav' => $pageNav,
|
||||
'next' => $nextPreviousLocator->getNext(),
|
||||
'previous' => $nextPreviousLocator->getPrevious(),
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($page),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -448,37 +451,4 @@ class PageController extends Controller
|
||||
|
||||
return redirect($pageCopy->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Permissions view.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function showPermissions(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $page);
|
||||
|
||||
return view('pages.permissions', [
|
||||
'page' => $page,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the permissions for this page.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $page);
|
||||
|
||||
$permissionsUpdater->updateFromPermissionsForm($page, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.pages_permissions_success'));
|
||||
|
||||
return redirect($page->getUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
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
|
||||
{
|
||||
protected $pageRepo;
|
||||
protected PageRepo $pageRepo;
|
||||
|
||||
/**
|
||||
* PageRevisionController constructor.
|
||||
*/
|
||||
public function __construct(PageRepo $pageRepo)
|
||||
{
|
||||
$this->pageRepo = $pageRepo;
|
||||
@@ -26,14 +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);
|
||||
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName'=>$page->getShortName()]));
|
||||
$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',
|
||||
])
|
||||
->selectRaw("IF(markdown = '', false, true) as is_markdown")
|
||||
->with(['page.book', 'createdBy'])
|
||||
->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', [
|
||||
'page' => $page,
|
||||
'current' => $page,
|
||||
'revisions' => $revisions,
|
||||
'page' => $page,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -45,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();
|
||||
@@ -73,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();
|
||||
@@ -86,7 +103,7 @@ class PageRevisionController extends Controller
|
||||
// TODO - Refactor PageContent so we don't need to juggle this
|
||||
$page->html = $revision->html;
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
|
||||
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
|
||||
|
||||
return view('pages.revision', [
|
||||
'page' => $page,
|
||||
|
||||
174
app/Http/Controllers/PermissionsController.php
Normal file
174
app/Http/Controllers/PermissionsController.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\PermissionFormData;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PermissionsController extends Controller
|
||||
{
|
||||
protected PermissionsUpdater $permissionsUpdater;
|
||||
|
||||
public function __construct(PermissionsUpdater $permissionsUpdater)
|
||||
{
|
||||
$this->permissionsUpdater = $permissionsUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Permissions view for a page.
|
||||
*/
|
||||
public function showForPage(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = Page::getBySlugs($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $page);
|
||||
|
||||
$this->setPageTitle(trans('entities.pages_permissions'));
|
||||
return view('pages.permissions', [
|
||||
'page' => $page,
|
||||
'data' => new PermissionFormData($page),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the permissions for a page.
|
||||
*/
|
||||
public function updateForPage(Request $request, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = Page::getBySlugs($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $page);
|
||||
|
||||
$this->permissionsUpdater->updateFromPermissionsForm($page, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.pages_permissions_success'));
|
||||
|
||||
return redirect($page->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Restrictions view for a chapter.
|
||||
*/
|
||||
public function showForChapter(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $chapter);
|
||||
|
||||
$this->setPageTitle(trans('entities.chapters_permissions'));
|
||||
return view('chapters.permissions', [
|
||||
'chapter' => $chapter,
|
||||
'data' => new PermissionFormData($chapter),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the restrictions for a chapter.
|
||||
*/
|
||||
public function updateForChapter(Request $request, string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $chapter);
|
||||
|
||||
$this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.chapters_permissions_success'));
|
||||
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the permissions view for a book.
|
||||
*/
|
||||
public function showForBook(string $slug)
|
||||
{
|
||||
$book = Book::getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $book);
|
||||
|
||||
$this->setPageTitle(trans('entities.books_permissions'));
|
||||
return view('books.permissions', [
|
||||
'book' => $book,
|
||||
'data' => new PermissionFormData($book),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the restrictions for a book.
|
||||
*/
|
||||
public function updateForBook(Request $request, string $slug)
|
||||
{
|
||||
$book = Book::getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $book);
|
||||
|
||||
$this->permissionsUpdater->updateFromPermissionsForm($book, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.books_permissions_updated'));
|
||||
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the permissions view for a shelf.
|
||||
*/
|
||||
public function showForShelf(string $slug)
|
||||
{
|
||||
$shelf = Bookshelf::getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||
|
||||
$this->setPageTitle(trans('entities.shelves_permissions'));
|
||||
return view('shelves.permissions', [
|
||||
'shelf' => $shelf,
|
||||
'data' => new PermissionFormData($shelf),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the permissions for a shelf.
|
||||
*/
|
||||
public function updateForShelf(Request $request, string $slug)
|
||||
{
|
||||
$shelf = Bookshelf::getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||
|
||||
$this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the permissions of a bookshelf to the child books.
|
||||
*/
|
||||
public function copyShelfPermissionsToBooks(string $slug)
|
||||
{
|
||||
$shelf = Bookshelf::getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||
|
||||
$updateCount = $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf);
|
||||
$this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an empty entity permissions form row for the given role.
|
||||
*/
|
||||
public function formRowForRole(string $entityType, string $roleId)
|
||||
{
|
||||
$this->checkPermissionOr('restrictions-manage-all', fn() => userCan('restrictions-manage-own'));
|
||||
|
||||
$role = Role::query()->findOrFail($roleId);
|
||||
|
||||
return view('form.entity-permissions-row', [
|
||||
'role' => $role,
|
||||
'permission' => new EntityPermission(),
|
||||
'entityType' => $entityType,
|
||||
'inheriting' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/ReferenceController.php
Normal file
75
app/Http/Controllers/ReferenceController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
|
||||
class ReferenceController extends Controller
|
||||
{
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
public function __construct(ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the references to a given page.
|
||||
*/
|
||||
public function page(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = Page::getBySlugs($bookSlug, $pageSlug);
|
||||
$references = $this->referenceFetcher->getPageReferencesToEntity($page);
|
||||
|
||||
return view('pages.references', [
|
||||
'page' => $page,
|
||||
'references' => $references,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the references to a given chapter.
|
||||
*/
|
||||
public function chapter(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
|
||||
$references = $this->referenceFetcher->getPageReferencesToEntity($chapter);
|
||||
|
||||
return view('chapters.references', [
|
||||
'chapter' => $chapter,
|
||||
'references' => $references,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the references to a given book.
|
||||
*/
|
||||
public function book(string $slug)
|
||||
{
|
||||
$book = Book::getBySlug($slug);
|
||||
$references = $this->referenceFetcher->getPageReferencesToEntity($book);
|
||||
|
||||
return view('books.references', [
|
||||
'book' => $book,
|
||||
'references' => $references,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the references to a given shelf.
|
||||
*/
|
||||
public function shelf(string $slug)
|
||||
{
|
||||
$shelf = Bookshelf::getBySlug($slug);
|
||||
$references = $this->referenceFetcher->getPageReferencesToEntity($shelf);
|
||||
|
||||
return view('shelves.references', [
|
||||
'shelf' => $shelf,
|
||||
'references' => $references,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\Popular;
|
||||
use BookStack\Entities\Tools\SearchOptions;
|
||||
use BookStack\Entities\Tools\SearchResultsFormatter;
|
||||
use BookStack\Entities\Tools\SearchRunner;
|
||||
use BookStack\Entities\Tools\SiblingFetcher;
|
||||
use BookStack\Search\SearchOptions;
|
||||
use BookStack\Search\SearchResultsFormatter;
|
||||
use BookStack\Search\SearchRunner;
|
||||
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,15 +3,13 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\TagRepo;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TagController extends Controller
|
||||
{
|
||||
protected $tagRepo;
|
||||
protected TagRepo $tagRepo;
|
||||
|
||||
/**
|
||||
* TagController constructor.
|
||||
*/
|
||||
public function __construct(TagRepo $tagRepo)
|
||||
{
|
||||
$this->tagRepo = $tagRepo;
|
||||
@@ -22,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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -46,7 +47,7 @@ class TagController extends Controller
|
||||
*/
|
||||
public function getNameSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search', null);
|
||||
$searchTerm = $request->get('search', '');
|
||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
||||
|
||||
return response()->json($suggestions);
|
||||
@@ -57,8 +58,8 @@ class TagController extends Controller
|
||||
*/
|
||||
public function getValueSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search', null);
|
||||
$tagName = $request->get('name', null);
|
||||
$searchTerm = $request->get('search', '');
|
||||
$tagName = $request->get('name', '');
|
||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
||||
|
||||
return response()->json($suggestions);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,59 +2,18 @@
|
||||
|
||||
namespace BookStack\Http\Middleware;
|
||||
|
||||
use BookStack\Util\LanguageManager;
|
||||
use Carbon\Carbon;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class Localization
|
||||
{
|
||||
/**
|
||||
* Array of right-to-left locales.
|
||||
*/
|
||||
protected $rtlLocales = ['ar', 'fa', 'he'];
|
||||
protected LanguageManager $languageManager;
|
||||
|
||||
/**
|
||||
* Map of BookStack locale names to best-estimate system locale names.
|
||||
* Locales can often be found by running `locale -a` on a linux system.
|
||||
*/
|
||||
protected $localeMap = [
|
||||
'ar' => 'ar',
|
||||
'bg' => 'bg_BG',
|
||||
'bs' => 'bs_BA',
|
||||
'ca' => 'ca',
|
||||
'da' => 'da_DK',
|
||||
'de' => 'de_DE',
|
||||
'de_informal' => 'de_DE',
|
||||
'en' => 'en_GB',
|
||||
'es' => 'es_ES',
|
||||
'es_AR' => 'es_AR',
|
||||
'et' => 'et_EE',
|
||||
'eu' => 'eu_ES',
|
||||
'fa' => 'fa_IR',
|
||||
'fr' => 'fr_FR',
|
||||
'he' => 'he_IL',
|
||||
'hr' => 'hr_HR',
|
||||
'id' => 'id_ID',
|
||||
'it' => 'it_IT',
|
||||
'ja' => 'ja',
|
||||
'ko' => 'ko_KR',
|
||||
'lt' => 'lt_LT',
|
||||
'lv' => 'lv_LV',
|
||||
'nl' => 'nl_NL',
|
||||
'nb' => 'nb_NO',
|
||||
'pl' => 'pl_PL',
|
||||
'pt' => 'pt_PT',
|
||||
'pt_BR' => 'pt_BR',
|
||||
'ru' => 'ru',
|
||||
'sk' => 'sk_SK',
|
||||
'sl' => 'sl_SI',
|
||||
'sv' => 'sv_SE',
|
||||
'uk' => 'uk_UA',
|
||||
'vi' => 'vi_VN',
|
||||
'zh_CN' => 'zh_CN',
|
||||
'zh_TW' => 'zh_TW',
|
||||
'tr' => 'tr_TR',
|
||||
];
|
||||
public function __construct(LanguageManager $languageManager)
|
||||
{
|
||||
$this->languageManager = $languageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
@@ -66,76 +25,23 @@ class Localization
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
// Get and record the default language in the config
|
||||
$defaultLang = config('app.locale');
|
||||
config()->set('app.default_locale', $defaultLang);
|
||||
|
||||
$locale = $this->getUserLocale($request, $defaultLang);
|
||||
config()->set('app.lang', str_replace('_', '-', $this->getLocaleIso($locale)));
|
||||
// Get the user's language and record that in the config for use in views
|
||||
$userLang = $this->languageManager->getUserLanguage($request, $defaultLang);
|
||||
config()->set('app.lang', str_replace('_', '-', $this->languageManager->getIsoName($userLang)));
|
||||
|
||||
// Set text direction
|
||||
if (in_array($locale, $this->rtlLocales)) {
|
||||
if ($this->languageManager->isRTL($userLang)) {
|
||||
config()->set('app.rtl', true);
|
||||
}
|
||||
|
||||
app()->setLocale($locale);
|
||||
Carbon::setLocale($locale);
|
||||
$this->setSystemDateLocale($locale);
|
||||
app()->setLocale($userLang);
|
||||
Carbon::setLocale($userLang);
|
||||
$this->languageManager->setPhpDateTimeLocale($userLang);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the locale specifically for the currently logged in user if available.
|
||||
*/
|
||||
protected function getUserLocale(Request $request, string $default): string
|
||||
{
|
||||
try {
|
||||
$user = user();
|
||||
} catch (\Exception $exception) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if ($user->isDefault() && config('app.auto_detect_locale')) {
|
||||
return $this->autoDetectLocale($request, $default);
|
||||
}
|
||||
|
||||
return setting()->getUser($user, 'language', $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Autodetect the visitors locale by matching locales in their headers
|
||||
* against the locales supported by BookStack.
|
||||
*/
|
||||
protected function autoDetectLocale(Request $request, string $default): string
|
||||
{
|
||||
$availableLocales = config('app.locales');
|
||||
foreach ($request->getLanguages() as $lang) {
|
||||
if (in_array($lang, $availableLocales)) {
|
||||
return $lang;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ISO version of a BookStack language name.
|
||||
*/
|
||||
public function getLocaleIso(string $locale): string
|
||||
{
|
||||
return $this->localeMap[$locale] ?? $locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the system date locale for localized date formatting.
|
||||
* Will try both the standard locale name and the UTF8 variant.
|
||||
*/
|
||||
protected function setSystemDateLocale(string $locale)
|
||||
{
|
||||
$systemLocale = $this->getLocaleIso($locale);
|
||||
$set = setlocale(LC_TIME, $systemLocale);
|
||||
if ($set === false) {
|
||||
setlocale(LC_TIME, $systemLocale . '.utf8');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,32 +2,44 @@
|
||||
|
||||
namespace BookStack\Providers;
|
||||
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Actions\ActivityLogger;
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Entities\BreadcrumbsViewComposer;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\WhoopsBookStackPrettyHandler;
|
||||
use BookStack\Settings\Setting;
|
||||
use BookStack\Settings\SettingService;
|
||||
use BookStack\Util\CspService;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Contracts\Cache\Repository;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
|
||||
use Psr\Http\Client\ClientInterface as HttpClientInterface;
|
||||
use Whoops\Handler\HandlerInterface;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Custom container bindings to register.
|
||||
* @var string[]
|
||||
*/
|
||||
public $bindings = [
|
||||
HandlerInterface::class => WhoopsBookStackPrettyHandler::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom singleton bindings to register.
|
||||
* @var string[]
|
||||
*/
|
||||
public $singletons = [
|
||||
'activity' => ActivityLogger::class,
|
||||
SettingService::class => SettingService::class,
|
||||
SocialAuthService::class => SocialAuthService::class,
|
||||
CspService::class => CspService::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*
|
||||
@@ -43,11 +55,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
URL::forceScheme($isHttps ? 'https' : 'http');
|
||||
}
|
||||
|
||||
// Custom blade view directives
|
||||
Blade::directive('icon', function ($expression) {
|
||||
return "<?php echo icon($expression); ?>";
|
||||
});
|
||||
|
||||
// Allow longer string lengths after upgrade to utf8mb4
|
||||
Schema::defaultStringLength(191);
|
||||
|
||||
@@ -58,12 +65,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
'chapter' => Chapter::class,
|
||||
'page' => Page::class,
|
||||
]);
|
||||
|
||||
// View Composers
|
||||
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
|
||||
|
||||
// Set paginator to use bootstrap-style pagination
|
||||
Paginator::useBootstrap();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,22 +74,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->app->bind(HandlerInterface::class, function ($app) {
|
||||
return $app->make(WhoopsBookStackPrettyHandler::class);
|
||||
});
|
||||
|
||||
$this->app->singleton(SettingService::class, function ($app) {
|
||||
return new SettingService($app->make(Setting::class), $app->make(Repository::class));
|
||||
});
|
||||
|
||||
$this->app->singleton(SocialAuthService::class, function ($app) {
|
||||
return new SocialAuthService($app->make(SocialiteFactory::class), $app->make(LoginService::class));
|
||||
});
|
||||
|
||||
$this->app->singleton(CspService::class, function ($app) {
|
||||
return new CspService();
|
||||
});
|
||||
|
||||
$this->app->bind(HttpClientInterface::class, function ($app) {
|
||||
return new Client([
|
||||
'timeout' => 3,
|
||||
|
||||
@@ -24,9 +24,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
// Password Configuration
|
||||
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
|
||||
Password::defaults(function () {
|
||||
return Password::min(8);
|
||||
});
|
||||
Password::defaults(fn () => Password::min(8));
|
||||
|
||||
// Custom guards
|
||||
Auth::extend('api-token', function ($app, $name, array $config) {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class BroadcastServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
// Broadcast::routes();
|
||||
//
|
||||
// /*
|
||||
// * Authenticate the user's personal channel...
|
||||
// */
|
||||
// Broadcast::channel('BookStack.User.*', function ($user, $userId) {
|
||||
// return (int) $user->id === (int) $userId;
|
||||
// });
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Providers;
|
||||
|
||||
use BookStack\Actions\ActivityLogger;
|
||||
use BookStack\Theming\ThemeService;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class CustomFacadeProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap the application services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the application services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->app->singleton('activity', function () {
|
||||
return $this->app->make(ActivityLogger::class);
|
||||
});
|
||||
|
||||
$this->app->singleton('theme', function () {
|
||||
return $this->app->make(ThemeService::class);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ class EventServiceProvider extends ServiceProvider
|
||||
/**
|
||||
* The event listener mappings for the application.
|
||||
*
|
||||
* @var array
|
||||
* @var array<class-string, array<int, class-string>>
|
||||
*/
|
||||
protected $listen = [
|
||||
SocialiteWasCalled::class => [
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Providers;
|
||||
|
||||
use Illuminate\Pagination\PaginationServiceProvider as IlluminatePaginationServiceProvider;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
|
||||
class PaginationServiceProvider extends IlluminatePaginationServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register the service provider.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
Paginator::viewFactoryResolver(function () {
|
||||
return $this->app['view'];
|
||||
});
|
||||
|
||||
Paginator::currentPathResolver(function () {
|
||||
return url($this->app['request']->path());
|
||||
});
|
||||
|
||||
Paginator::currentPageResolver(function ($pageName = 'page') {
|
||||
$page = $this->app['request']->input($pageName);
|
||||
|
||||
if (filter_var($page, FILTER_VALIDATE_INT) !== false && (int) $page >= 1) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user