mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-07 19:06:05 +03:00
Compare commits
390 Commits
user_permi
...
v23.01.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce9b536b78 | ||
|
|
d9c50e5bc1 | ||
|
|
6e6f113336 | ||
|
|
f7441e2abc | ||
|
|
28c168145f | ||
|
|
c2115cab59 | ||
|
|
bf075f7dd8 | ||
|
|
a4fd673285 | ||
|
|
813d140213 | ||
|
|
3dc5942a85 | ||
|
|
03e2a9b200 | ||
|
|
8367a94e90 | ||
|
|
631546a68a | ||
|
|
7751022c66 | ||
|
|
f42ff59b43 | ||
|
|
104621841b | ||
|
|
c337439370 | ||
|
|
65ebdb7234 | ||
|
|
e708ce93ba | ||
|
|
1f69965c1e | ||
|
|
d7723b33f3 | ||
|
|
87e371ffde | ||
|
|
b649738718 | ||
|
|
022cbb9c00 | ||
|
|
40e112fc5b | ||
|
|
7cacbaadf0 | ||
|
|
a3e7e754b9 | ||
|
|
03ad288aaa | ||
|
|
811be3a36a | ||
|
|
3202f96181 | ||
|
|
f6a6b11ec5 | ||
|
|
48df8725d8 | ||
|
|
25bdd71477 | ||
|
|
deda331745 | ||
|
|
f6d3944b20 | ||
|
|
a50b0ea1e5 | ||
|
|
3c658e39ab | ||
|
|
d8354255e7 | ||
|
|
55b6a7842e | ||
|
|
0f113ec41f | ||
|
|
1fa5a31960 | ||
|
|
8be36455ab | ||
|
|
d1bd6d0e39 | ||
|
|
1660e72cc5 | ||
|
|
2d1f1abce4 | ||
|
|
7d74575eb8 | ||
|
|
91e613fe60 | ||
|
|
f3f2a0c1d5 | ||
|
|
1c2ae7bff6 | ||
|
|
78ebcb6f38 | ||
|
|
28dda39260 | ||
|
|
e2a72d16aa | ||
|
|
c724bfe4d3 | ||
|
|
6070d804f8 | ||
|
|
e794c977bc | ||
|
|
0b088ef1d3 | ||
|
|
5393465ea7 | ||
|
|
f5df811b15 | ||
|
|
a521f41838 | ||
|
|
0123d83fb2 | ||
|
|
559e392f1b | ||
|
|
8468b632a1 | ||
|
|
7053a8669f | ||
|
|
2c0a7346b1 | ||
|
|
bf6a6af683 | ||
|
|
914790fd99 | ||
|
|
d505642336 | ||
|
|
edb0c6a9e8 | ||
|
|
84049de696 | ||
|
|
da0531e63b | ||
|
|
421dc75f4e | ||
|
|
8ae91df038 | ||
|
|
64b41dd626 | ||
|
|
ebd6e4d3a2 | ||
|
|
80374aea5c | ||
|
|
2ac9efae7d | ||
|
|
a11d565ba4 | ||
|
|
1fdf854ea7 | ||
|
|
e9c9792cb9 | ||
|
|
5ae524c25a | ||
|
|
0d7287fc8b | ||
|
|
e77c96f6b7 | ||
|
|
9b8a10dd3a | ||
|
|
49200ca5ce | ||
|
|
34aa4dbf10 | ||
|
|
5ee79d16c9 | ||
|
|
a1ea4006e0 | ||
|
|
9078188939 | ||
|
|
ed0aad1a7a | ||
|
|
5c59cfb020 | ||
|
|
3ca15ad68a | ||
|
|
60014989f5 | ||
|
|
57b10f195e | ||
|
|
b1e95eb39f | ||
|
|
b3da77b8f9 | ||
|
|
1a345b74bb | ||
|
|
8ffc3a4abf | ||
|
|
7233c1c7b2 | ||
|
|
1309a01131 | ||
|
|
0333185b6d | ||
|
|
83f89f64e8 | ||
|
|
11a1a6fb16 | ||
|
|
882c609296 | ||
|
|
176a0dcd59 | ||
|
|
94b0f70bfa | ||
|
|
08b2a77d41 | ||
|
|
3e8e9a23cf | ||
|
|
58b83b64c8 | ||
|
|
dfe4cde6ee | ||
|
|
d11144d9e2 | ||
|
|
f96b0ea5f3 | ||
|
|
815f8d79ed | ||
|
|
b62dab32e0 | ||
|
|
262f863981 | ||
|
|
a4c94390a1 | ||
|
|
53f3cca85d | ||
|
|
ed08bbcecc | ||
|
|
de97ebf9b7 | ||
|
|
f492a660a8 | ||
|
|
09436836a5 | ||
|
|
bb455d7788 | ||
|
|
009212ab80 | ||
|
|
ba9cb591c8 | ||
|
|
d00ac2f34e | ||
|
|
bd4dc6d463 | ||
|
|
d91180a909 | ||
|
|
bc2913a5cb | ||
|
|
4802394562 | ||
|
|
1755556468 | ||
|
|
01cdbdb7ae | ||
|
|
fc8bbf3eab | ||
|
|
3cdab19319 | ||
|
|
5661d20e87 | ||
|
|
91f80123e8 | ||
|
|
7a0636d0f8 | ||
|
|
0fe5bdfbac | ||
|
|
f88687e977 | ||
|
|
68d437d05b | ||
|
|
1e56aaea04 | ||
|
|
dab170a6fe | ||
|
|
a8de717d9b | ||
|
|
78fe95b6fc | ||
|
|
e0c24e41aa | ||
|
|
fa8553839b | ||
|
|
b8fcefc794 | ||
|
|
88bcb68fcb | ||
|
|
7c000553ae | ||
|
|
391fa35c80 | ||
|
|
c6773a8c9f | ||
|
|
9b226e7d39 | ||
|
|
9865446267 | ||
|
|
926abbe776 | ||
|
|
4fabef3a57 | ||
|
|
5ef4cd80c3 | ||
|
|
e01f23583f | ||
|
|
7792cb3915 | ||
|
|
be26253a18 | ||
|
|
1bdd1f8189 | ||
|
|
fa62c79b17 | ||
|
|
d7d8fa1e5b | ||
|
|
18562f1e10 | ||
|
|
86090a694f | ||
|
|
1ee8287c73 | ||
|
|
8eb98cd591 | ||
|
|
0f9ba21b05 | ||
|
|
834f8e7046 | ||
|
|
32e3399334 | ||
|
|
2d8698a218 | ||
|
|
454fb883a2 | ||
|
|
6f4a6ab8ea | ||
|
|
9c4b6f36f1 | ||
|
|
78886b1e67 | ||
|
|
d9debaf032 | ||
|
|
d4360d6347 | ||
|
|
175b1785c0 | ||
|
|
c8740c0171 | ||
|
|
91ee895a74 | ||
|
|
a045e46571 | ||
|
|
44eaa65c3b | ||
|
|
0a22af7b14 | ||
|
|
b54702ab08 | ||
|
|
c4fdcfc5d1 | ||
|
|
cb8117e8df | ||
|
|
5a218d5056 | ||
|
|
8dbc5cf9c6 | ||
|
|
71e81615a3 | ||
|
|
611d37da04 | ||
|
|
0e799a3857 | ||
|
|
b91d6e2bfa | ||
|
|
ea16ad7e94 | ||
|
|
ba6eb54552 | ||
|
|
f705e7683b | ||
|
|
dc996adb20 | ||
|
|
a64c638ccc | ||
|
|
359c067279 | ||
|
|
66a746e297 | ||
|
|
a4d43ee24b | ||
|
|
f7793a70a9 | ||
|
|
ceba3d31fb | ||
|
|
eecc08edde | ||
|
|
eb19aadc75 | ||
|
|
06c81e69b9 | ||
|
|
3dc3d4a639 | ||
|
|
94c59c1e3d | ||
|
|
4d2205853a | ||
|
|
751772b87a | ||
|
|
76e30869e1 | ||
|
|
3edc9fe9eb | ||
|
|
616c62703e | ||
|
|
ecd56917e7 | ||
|
|
e22c9cae91 | ||
|
|
29ddb6e1b9 | ||
|
|
2ff90e2ff0 | ||
|
|
04ecc128a2 | ||
|
|
87d1d3423b | ||
|
|
4818192a2a | ||
|
|
965dd97f54 | ||
|
|
195b74926c | ||
|
|
2120db12b2 | ||
|
|
ed563fef28 | ||
|
|
0d31a8e3f1 | ||
|
|
b8354b974b | ||
|
|
034c1e289d | ||
|
|
f31605a3de | ||
|
|
e7cc75c74d | ||
|
|
4b79d5e4e8 | ||
|
|
34854915b3 | ||
|
|
af6f34b529 | ||
|
|
fb82a2b896 | ||
|
|
5b464938b6 | ||
|
|
81f954890d | ||
|
|
0e2bbcec62 | ||
|
|
fdd339f525 | ||
|
|
8cf7d6a83d | ||
|
|
58a5008718 | ||
|
|
c44a8df55d | ||
|
|
ff1494c519 | ||
|
|
b8ce8fd852 | ||
|
|
75e7454a5f | ||
|
|
2558ea8931 | ||
|
|
ac0f47a4b2 | ||
|
|
4f16129869 | ||
|
|
64a8037fdd | ||
|
|
7502ba1bc8 | ||
|
|
33a04697ef | ||
|
|
b70a5c0cdb | ||
|
|
9443ae9f40 | ||
|
|
220c2a4102 | ||
|
|
e9914eb301 | ||
|
|
934512d09c | ||
|
|
9102c90986 | ||
|
|
c3e74219c4 | ||
|
|
13c9d7bc2d | ||
|
|
119b539586 | ||
|
|
29a5c180f0 | ||
|
|
7906602291 | ||
|
|
6dafe773ff | ||
|
|
25bc28a1be | ||
|
|
4c561c7fa0 | ||
|
|
95b3e78573 | ||
|
|
63a345bc93 | ||
|
|
e093a172cb | ||
|
|
4b01f8934b | ||
|
|
bc116b45b5 | ||
|
|
a059960b9e | ||
|
|
7770966fed | ||
|
|
d7adcf6c69 | ||
|
|
04a364dcc3 | ||
|
|
db83ac7eaa | ||
|
|
3ca9dddf61 | ||
|
|
bf74f53ca7 | ||
|
|
9d67efb4a4 | ||
|
|
3a39b9f440 | ||
|
|
27f7aab375 | ||
|
|
337da0c467 | ||
|
|
f56b3560c4 | ||
|
|
02dfe11ce6 | ||
|
|
83d06beb70 | ||
|
|
a8cfc059c8 | ||
|
|
1614b2bab0 | ||
|
|
4bdec0d214 | ||
|
|
6a7d7e7c2b | ||
|
|
30d4674657 | ||
|
|
9f961f95f8 | ||
|
|
bab99a26ec | ||
|
|
9a7fecd269 | ||
|
|
a8dc0d449b | ||
|
|
a0381f76bf | ||
|
|
6102f66daa | ||
|
|
c6134d162d | ||
|
|
2046f9b9de | ||
|
|
ac3ba594a4 | ||
|
|
22df25a480 | ||
|
|
8b30c7f02e | ||
|
|
757cdddc7c | ||
|
|
df95e99680 | ||
|
|
5a6d544db7 | ||
|
|
16117d329c | ||
|
|
e90da18ada | ||
|
|
a08d80e1cc | ||
|
|
6258175922 | ||
|
|
15736777a0 | ||
|
|
75915e8a94 | ||
|
|
9bde0ae4ea | ||
|
|
0c802d1f86 | ||
|
|
b7a96c6466 | ||
|
|
4b645a82c7 | ||
|
|
d599b77b6f | ||
|
|
26e93dc8c1 | ||
|
|
a4c9a8491b | ||
|
|
70ee636d87 | ||
|
|
b35f6dbb03 | ||
|
|
67d9e24d8f | ||
|
|
3903fda6ca | ||
|
|
441e46ebaa | ||
|
|
1f4260f359 | ||
|
|
dc0bf8ad4e | ||
|
|
102e326e6a | ||
|
|
2b25bf6f3b | ||
|
|
f93280696d | ||
|
|
1787391b07 | ||
|
|
a74a8ee483 | ||
|
|
7fa5405cb7 | ||
|
|
6725ddcc41 | ||
|
|
bce941db3f | ||
|
|
6d926048ec | ||
|
|
5335c973b4 | ||
|
|
15c3e5c96e | ||
|
|
a5d5904969 | ||
|
|
598758b991 | ||
|
|
9926e23bc8 | ||
|
|
5d3264bc63 | ||
|
|
d71f819f95 | ||
|
|
ee13509760 | ||
|
|
82d7bb1f32 | ||
|
|
cdfda508d8 | ||
|
|
da941e584f | ||
|
|
65874d7b96 | ||
|
|
ac9b8f405c | ||
|
|
8d1419a12e | ||
|
|
04f7a7d301 | ||
|
|
c10d2a1493 | ||
|
|
97bbf79ffd | ||
|
|
f7b01ae53d | ||
|
|
d704e1dbba | ||
|
|
ef2ff5e093 | ||
|
|
7caed3b0db | ||
|
|
45641d0754 | ||
|
|
4b1d08ba99 | ||
|
|
160fa99ba4 | ||
|
|
d2a5ab49ed | ||
|
|
c6404d8917 | ||
|
|
7113807f12 | ||
|
|
be711215e8 | ||
|
|
7e3b404240 | ||
|
|
e86901ca20 | ||
|
|
bdfa61c8b2 | ||
|
|
2cc36787f5 | ||
|
|
448ac61b48 | ||
|
|
753f6394f7 | ||
|
|
b1faf65934 | ||
|
|
09f478bd74 | ||
|
|
a0497feddd | ||
|
|
789693bde9 | ||
|
|
1fe933e4ea | ||
|
|
724b4b5a70 | ||
|
|
1778a56146 | ||
|
|
744865fcb2 | ||
|
|
7f8c8b448d | ||
|
|
a67c53826d | ||
|
|
14b131e850 | ||
|
|
9b55a52b85 | ||
|
|
db1d10e80f | ||
|
|
1be576966f | ||
|
|
b97e792c5f | ||
|
|
8dec674cc3 | ||
|
|
f784c03746 | ||
|
|
148e172fe8 | ||
|
|
56ae86646f | ||
|
|
1d2b6fdfa2 | ||
|
|
4fc75beed4 | ||
|
|
3b3bc0c4bf | ||
|
|
910faab88e | ||
|
|
f184d763ad | ||
|
|
a91d42634d | ||
|
|
f517ef3616 | ||
|
|
e99507ddcf | ||
|
|
d2cacf1945 | ||
|
|
448ac1405b | ||
|
|
6ad21ce885 |
@@ -268,6 +268,7 @@ OIDC_DUMP_USER_DETAILS=false
|
||||
OIDC_USER_TO_GROUPS=false
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
OIDC_REMOVE_FROM_GROUPS=false
|
||||
OIDC_EXTERNAL_ID_CLAIM=sub
|
||||
|
||||
# Disable default third-party services such as Gravatar and Draw.IO
|
||||
# Service-specific options will override this option
|
||||
|
||||
16
.github/translators.txt
vendored
16
.github/translators.txt
vendored
@@ -176,7 +176,7 @@ Alexander Predl (Harveyhase68) :: German
|
||||
Rem (Rem9000) :: Dutch
|
||||
Michał Stelmach (stelmach-web) :: Polish
|
||||
arniom :: French
|
||||
REMOVED_USER :: ; Dutch; Turkish
|
||||
REMOVED_USER :: ; French; Dutch; Turkish
|
||||
林祖年 (contagion) :: Chinese Traditional
|
||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||
@@ -283,13 +283,13 @@ Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
|
||||
digilady :: Greek
|
||||
Linus (LinusOP) :: Swedish
|
||||
Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian
|
||||
RandomUser0815 :: German
|
||||
RandomUser0815 :: German Informal; German
|
||||
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
|
||||
구인회 (laskdjlaskdj12) :: Korean
|
||||
LiZerui (CNLiZerui) :: Chinese Traditional
|
||||
Fabrice Boyer (FabriceBoyer) :: French
|
||||
mikael (bitcanon) :: Swedish
|
||||
Matthias Mai (schnapsidee) :: German
|
||||
Matthias Mai (schnapsidee) :: German; German Informal
|
||||
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
|
||||
Jan Mitrof (jan.kachlik) :: Czech
|
||||
edwardsmirnov :: Russian
|
||||
@@ -298,3 +298,13 @@ shotu :: French
|
||||
Cesar_Lopez_Aguillon :: Spanish
|
||||
bdewoop :: German
|
||||
dina davoudi (dina.davoudi) :: Persian
|
||||
Angelos Chouvardas (achouvardas) :: Greek
|
||||
rndrss :: Portuguese, Brazilian
|
||||
rirac294 :: Russian
|
||||
David Furman (thefourCraft) :: Hebrew
|
||||
Pafzedog :: French
|
||||
Yllelder :: Spanish
|
||||
Adrian Ocneanu (aocneanu) :: Romanian
|
||||
Eduardo Castanho (EduardoCastanho) :: Portuguese
|
||||
VIET NAM VPS (vietnamvps) :: Vietnamese
|
||||
m4tthi4s :: French
|
||||
|
||||
2
.github/workflows/test-php.yml
vendored
2
.github/workflows/test-php.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -5,10 +5,10 @@ Homestead.yaml
|
||||
.idea
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/public/dist
|
||||
/public/dist/*.map
|
||||
/public/plugins
|
||||
/public/css
|
||||
/public/js
|
||||
/public/css/*.map
|
||||
/public/js/*.map
|
||||
/public/bower
|
||||
/public/build/
|
||||
/storage/images
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -40,6 +42,12 @@ class Activity extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
|
||||
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns text from the language files, Looks up by using the activity key.
|
||||
*/
|
||||
|
||||
@@ -15,8 +15,6 @@ class ActivityQueries
|
||||
{
|
||||
protected PermissionApplicator $permissions;
|
||||
|
||||
protected array $fieldsForLists = ['id', 'type', 'detail', 'activities.entity_type', 'activities.entity_id', 'user_id', 'created_at'];
|
||||
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
@@ -27,11 +25,9 @@ class ActivityQueries
|
||||
*/
|
||||
public function latest(int $count = 20, int $page = 0): array
|
||||
{
|
||||
$query = Activity::query()->select($this->fieldsForLists);
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery($query, 'activities', 'entity_id', 'entity_type')
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->whereNotNull('activities.entity_id')
|
||||
->with(['user', 'entity'])
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
@@ -82,12 +78,10 @@ class ActivityQueries
|
||||
*/
|
||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
$query = Activity::query()->select($this->fieldsForLists);
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery($query, 'activities', 'entity_id', 'entity_type')
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->where('user_id', '=', $user->id)
|
||||
->whereNotNull('activities.entity_id')
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class Favourite extends Model
|
||||
@@ -16,4 +18,10 @@ class Favourite extends Model
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'favouritable_id')
|
||||
->whereColumn('favourites.favouritable_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
@@ -27,6 +29,12 @@ class Tag extends Model
|
||||
return $this->morphTo('entity');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
|
||||
->whereColumn('tags.entity_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a full URL to start a tag name search for this tag name.
|
||||
*/
|
||||
|
||||
@@ -29,16 +29,15 @@ class TagRepo
|
||||
$sort = 'value';
|
||||
}
|
||||
|
||||
$entityTypeCol = DB::getTablePrefix() . 'tags.entity_type';
|
||||
$query = Tag::query()
|
||||
->select([
|
||||
'name',
|
||||
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
|
||||
DB::raw('COUNT(id) as usages'),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'page', 1, 0)) as page_count"),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'chapter', 1, 0)) as chapter_count"),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'book', 1, 0)) as book_count"),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'bookshelf', 1, 0)) as shelf_count"),
|
||||
DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
|
||||
])
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Interfaces\Viewable;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
@@ -28,6 +30,12 @@ class View extends Model
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'viewable_id')
|
||||
->whereColumn('views.viewable_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the current user's view count for the given viewable model.
|
||||
*/
|
||||
|
||||
@@ -198,7 +198,8 @@ class OidcService
|
||||
*/
|
||||
protected function getUserDetails(OidcIdToken $token): array
|
||||
{
|
||||
$id = $token->getClaim('sub');
|
||||
$idClaim = $this->config()['external_id_claim'];
|
||||
$id = $token->getClaim($idClaim);
|
||||
|
||||
return [
|
||||
'external_id' => $id,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property ?int $role_id
|
||||
* @property ?int $user_id
|
||||
* @property string $entity_type
|
||||
* @property int $entity_id
|
||||
* @property bool $view
|
||||
*/
|
||||
class CollapsedPermission extends Model
|
||||
{
|
||||
protected $table = 'entity_permissions_collapsed';
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Collapsed permissions act as a "flattened" view of entity-level permissions in the system
|
||||
* so inheritance does not have to managed as part of permission querying.
|
||||
*/
|
||||
class CollapsedPermissionBuilder
|
||||
{
|
||||
/**
|
||||
* Re-generate all collapsed permissions from scratch.
|
||||
*/
|
||||
public function rebuildForAll()
|
||||
{
|
||||
DB::table('entity_permissions_collapsed')->truncate();
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) {
|
||||
$this->buildForBooks($books, false);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->withTrashed()
|
||||
->select(['id'])
|
||||
->chunk(50, function (EloquentCollection $shelves) {
|
||||
$this->generateCollapsedPermissions($shelves->all());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the collapsed permissions for a particular entity.
|
||||
*/
|
||||
public function rebuildForEntity(Entity $entity)
|
||||
{
|
||||
$entities = [$entity];
|
||||
if ($entity instanceof Book) {
|
||||
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
|
||||
$this->buildForBooks($books, true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BookChild $entity */
|
||||
if ($entity->book) {
|
||||
$entities[] = $entity->book;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$entities[] = $entity->chapter;
|
||||
}
|
||||
|
||||
if ($entity instanceof Chapter) {
|
||||
foreach ($entity->pages as $page) {
|
||||
$entities[] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
$this->buildForEntities($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query for fetching a book with its children.
|
||||
*/
|
||||
protected function bookFetchQuery(): Builder
|
||||
{
|
||||
return Book::query()->withTrashed()
|
||||
->select(['id'])->with([
|
||||
'chapters' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'book_id']);
|
||||
},
|
||||
'pages' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'book_id', 'chapter_id']);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build collapsed permissions for the given books.
|
||||
*/
|
||||
protected function buildForBooks(EloquentCollection $books, bool $deleteOld)
|
||||
{
|
||||
$entities = clone $books;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($books->all() as $book) {
|
||||
foreach ($book->getRelation('chapters') as $chapter) {
|
||||
$entities->push($chapter);
|
||||
}
|
||||
foreach ($book->getRelation('pages') as $page) {
|
||||
$entities->push($page);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteOld) {
|
||||
$this->deleteForEntities($entities->all());
|
||||
}
|
||||
|
||||
$this->generateCollapsedPermissions($entities->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the collapsed permissions for a collection of entities.
|
||||
*/
|
||||
protected function buildForEntities(array $entities)
|
||||
{
|
||||
$this->deleteForEntities($entities);
|
||||
$this->generateCollapsedPermissions($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored collapsed permissions for a list of entities.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function deleteForEntities(array $entities)
|
||||
{
|
||||
$simpleEntities = $this->entitiesToSimpleEntities($entities);
|
||||
$idsByType = $this->entitiesToTypeIdMap($simpleEntities);
|
||||
|
||||
DB::transaction(function () use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
foreach (array_chunk($ids, 1000) as $idChunk) {
|
||||
DB::table('entity_permissions_collapsed')
|
||||
->where('entity_type', '=', $type)
|
||||
->whereIn('entity_id', $idChunk)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given list of entities into "SimpleEntityData" representations
|
||||
* for faster usage and property access.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*
|
||||
* @return SimpleEntityData[]
|
||||
*/
|
||||
protected function entitiesToSimpleEntities(array $entities): array
|
||||
{
|
||||
$simpleEntities = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$attrs = $entity->getAttributes();
|
||||
$simple = new SimpleEntityData();
|
||||
$simple->id = $attrs['id'];
|
||||
$simple->type = $entity->getMorphClass();
|
||||
$simple->book_id = $attrs['book_id'] ?? null;
|
||||
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
||||
$simpleEntities[] = $simple;
|
||||
}
|
||||
|
||||
return $simpleEntities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create & Save collapsed entity permissions.
|
||||
*
|
||||
* @param Entity[] $originalEntities
|
||||
*/
|
||||
protected function generateCollapsedPermissions(array $originalEntities)
|
||||
{
|
||||
$entities = $this->entitiesToSimpleEntities($originalEntities);
|
||||
$collapsedPermData = [];
|
||||
|
||||
// Fetch related entity permissions
|
||||
$permissions = $this->getEntityPermissionsForEntities($entities);
|
||||
|
||||
// Create a mapping of explicit entity permissions
|
||||
$permissionMap = new EntityPermissionMap($permissions);
|
||||
|
||||
// Create Joint Permission Data
|
||||
foreach ($entities as $entity) {
|
||||
array_push($collapsedPermData, ...$this->createCollapsedPermissionData($entity, $permissionMap));
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($collapsedPermData) {
|
||||
foreach (array_chunk($collapsedPermData, 1000) as $dataChunk) {
|
||||
DB::table('entity_permissions_collapsed')->insert($dataChunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create collapsed permission data for the given entity using the given permission map.
|
||||
*/
|
||||
protected function createCollapsedPermissionData(SimpleEntityData $entity, EntityPermissionMap $permissionMap): array
|
||||
{
|
||||
$chain = [
|
||||
$entity->type . ':' . $entity->id,
|
||||
$entity->chapter_id ? ('chapter:' . $entity->chapter_id) : null,
|
||||
$entity->book_id ? ('book:' . $entity->book_id) : null,
|
||||
];
|
||||
|
||||
$permissionData = [];
|
||||
$overridesApplied = [];
|
||||
|
||||
foreach ($chain as $entityTypeId) {
|
||||
if ($entityTypeId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permissions = $permissionMap->getForEntity($entityTypeId);
|
||||
foreach ($permissions as $permission) {
|
||||
$related = $permission->getAssignedType() . ':' . $permission->getAssignedTypeId();
|
||||
if (!isset($overridesApplied[$related])) {
|
||||
$permissionData[] = [
|
||||
'role_id' => $permission->role_id,
|
||||
'user_id' => $permission->user_id,
|
||||
'view' => $permission->view,
|
||||
'entity_type' => $entity->type,
|
||||
'entity_id' => $entity->id,
|
||||
];
|
||||
$overridesApplied[$related] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $permissionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* From the given entity list, provide back a mapping of entity types to
|
||||
* the ids of that given type. The type used is the DB morph class.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return array<string, int[]>
|
||||
*/
|
||||
protected function entitiesToTypeIdMap(array $entities): array
|
||||
{
|
||||
$idsByType = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if (!isset($idsByType[$entity->type])) {
|
||||
$idsByType[$entity->type] = [];
|
||||
}
|
||||
|
||||
$idsByType[$entity->type][] = $entity->id;
|
||||
}
|
||||
|
||||
return $idsByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions for all the given entities.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return EntityPermission[]
|
||||
*/
|
||||
protected function getEntityPermissionsForEntities(array $entities): array
|
||||
{
|
||||
$idsByType = $this->entitiesToTypeIdMap($entities);
|
||||
$permissionFetch = EntityPermission::query()
|
||||
->where(function (Builder $query) use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$query->orWhere(function (Builder $query) use ($type, $ids) {
|
||||
$query->where('entity_type', '=', $type)->whereIn('entity_id', $ids);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return $permissionFetch->get()->all();
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,13 @@
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $role_id
|
||||
* @property int $user_id
|
||||
* @property int $entity_id
|
||||
* @property string $entity_type
|
||||
* @property boolean $view
|
||||
@@ -22,9 +21,17 @@ class EntityPermission extends Model
|
||||
{
|
||||
public const PERMISSIONS = ['view', 'create', 'update', 'delete'];
|
||||
|
||||
protected $fillable = ['role_id', 'user_id', 'view', 'create', 'update', 'delete'];
|
||||
protected $fillable = ['role_id', 'view', 'create', 'update', 'delete'];
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Get this restriction's attached entity.
|
||||
*/
|
||||
public function restrictable(): MorphTo
|
||||
{
|
||||
return $this->morphTo('restrictable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role assigned to this entity permission.
|
||||
*/
|
||||
@@ -32,38 +39,4 @@ class EntityPermission extends Model
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user assigned to this entity permission.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of entity permission this is.
|
||||
* Will be one of: user, role, fallback
|
||||
*/
|
||||
public function getAssignedType(): string
|
||||
{
|
||||
if ($this->user_id) {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
if ($this->role_id) {
|
||||
return 'role';
|
||||
}
|
||||
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID for the assigned type of permission.
|
||||
* (Role/User ID). Defaults to 0 for fallback.
|
||||
*/
|
||||
public function getAssignedTypeId(): int
|
||||
{
|
||||
return $this->user_id ?? $this->role_id ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
141
app/Auth/Permissions/EntityPermissionEvaluator.php
Normal file
141
app/Auth/Permissions/EntityPermissionEvaluator.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class EntityPermissionEvaluator
|
||||
{
|
||||
protected string $action;
|
||||
|
||||
public function __construct(string $action)
|
||||
{
|
||||
$this->action = $action;
|
||||
}
|
||||
|
||||
public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool
|
||||
{
|
||||
if ($this->isUserSystemAdmin($userRoleIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$typeIdChain = $this->gatherEntityChainTypeIds(SimpleEntityData::fromEntity($entity));
|
||||
$relevantPermissions = $this->getPermissionsMapByTypeId($typeIdChain, [...$userRoleIds, 0]);
|
||||
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
|
||||
|
||||
$status = $this->evaluatePermitsByType($permitsByType);
|
||||
|
||||
return is_null($status) ? null : $status === PermissionStatus::IMPLICIT_ALLOW || $status === PermissionStatus::EXPLICIT_ALLOW;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, int>> $permitsByType
|
||||
*/
|
||||
protected function evaluatePermitsByType(array $permitsByType): ?int
|
||||
{
|
||||
// Return grant or reject from role-level if exists
|
||||
if (count($permitsByType['role']) > 0) {
|
||||
return max($permitsByType['role']) ? PermissionStatus::EXPLICIT_ALLOW : PermissionStatus::EXPLICIT_DENY;
|
||||
}
|
||||
|
||||
// Return fallback permission if exists
|
||||
if (count($permitsByType['fallback']) > 0) {
|
||||
return $permitsByType['fallback'][0] ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $typeIdChain
|
||||
* @param array<string, EntityPermission[]> $permissionMapByTypeId
|
||||
* @return array<string, array<string, int>>
|
||||
*/
|
||||
protected function collapseAndCategorisePermissions(array $typeIdChain, array $permissionMapByTypeId): array
|
||||
{
|
||||
$permitsByType = ['fallback' => [], 'role' => []];
|
||||
|
||||
foreach ($typeIdChain as $typeId) {
|
||||
$permissions = $permissionMapByTypeId[$typeId] ?? [];
|
||||
foreach ($permissions as $permission) {
|
||||
$roleId = $permission->role_id;
|
||||
$type = $roleId === 0 ? 'fallback' : 'role';
|
||||
if (!isset($permitsByType[$type][$roleId])) {
|
||||
$permitsByType[$type][$roleId] = $permission->{$this->action};
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($permitsByType['fallback'][0])) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $permitsByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $typeIdChain
|
||||
* @return array<string, EntityPermission[]>
|
||||
*/
|
||||
protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): array
|
||||
{
|
||||
$query = EntityPermission::query()->where(function (Builder $query) use ($typeIdChain) {
|
||||
foreach ($typeIdChain as $typeId) {
|
||||
$query->orWhere(function (Builder $query) use ($typeId) {
|
||||
[$type, $id] = explode(':', $typeId);
|
||||
$query->where('entity_type', '=', $type)
|
||||
->where('entity_id', '=', $id);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!empty($filterRoleIds)) {
|
||||
$query->where(function (Builder $query) use ($filterRoleIds) {
|
||||
$query->whereIn('role_id', [...$filterRoleIds, 0]);
|
||||
});
|
||||
}
|
||||
|
||||
$relevantPermissions = $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
|
||||
|
||||
$map = [];
|
||||
foreach ($relevantPermissions as $permission) {
|
||||
$key = $permission->entity_type . ':' . $permission->entity_id;
|
||||
if (!isset($map[$key])) {
|
||||
$map[$key] = [];
|
||||
}
|
||||
|
||||
$map[$key][] = $permission;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
protected function gatherEntityChainTypeIds(SimpleEntityData $entity): array
|
||||
{
|
||||
// The array order here is very important due to the fact we walk up the chain
|
||||
// elsewhere in the class. Earlier items in the chain have higher priority.
|
||||
|
||||
$chain = [$entity->type . ':' . $entity->id];
|
||||
|
||||
if ($entity->type === 'page' && $entity->chapter_id) {
|
||||
$chain[] = 'chapter:' . $entity->chapter_id;
|
||||
}
|
||||
|
||||
if ($entity->type === 'page' || $entity->type === 'chapter') {
|
||||
$chain[] = 'book:' . $entity->book_id;
|
||||
}
|
||||
|
||||
return $chain;
|
||||
}
|
||||
|
||||
protected function isUserSystemAdmin($userRoleIds): bool
|
||||
{
|
||||
$adminRoleId = Role::getSystemRole('admin')->id;
|
||||
return in_array($adminRoleId, $userRoleIds);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
class EntityPermissionMap
|
||||
{
|
||||
protected array $map = [];
|
||||
|
||||
/**
|
||||
* @param EntityPermission[] $permissions
|
||||
*/
|
||||
public function __construct(array $permissions = [])
|
||||
{
|
||||
foreach ($permissions as $entityPermission) {
|
||||
$this->addPermission($entityPermission);
|
||||
}
|
||||
}
|
||||
|
||||
protected function addPermission(EntityPermission $permission)
|
||||
{
|
||||
$entityCombinedId = $permission->entity_type . ':' . $permission->entity_id;
|
||||
|
||||
if (!isset($this->map[$entityCombinedId])) {
|
||||
$this->map[$entityCombinedId] = [];
|
||||
}
|
||||
|
||||
$this->map[$entityCombinedId][] = $permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EntityPermission[]
|
||||
*/
|
||||
public function getForEntity(string $typeIdString): array
|
||||
{
|
||||
return $this->map[$typeIdString] ?? [];
|
||||
}
|
||||
}
|
||||
31
app/Auth/Permissions/JointPermission.php
Normal file
31
app/Auth/Permissions/JointPermission.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
|
||||
class JointPermission extends Model
|
||||
{
|
||||
protected $primaryKey = null;
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Get the role that this points to.
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity this points to.
|
||||
*/
|
||||
public function entity(): MorphOne
|
||||
{
|
||||
return $this->morphOne(Entity::class, 'entity');
|
||||
}
|
||||
}
|
||||
292
app/Auth/Permissions/JointPermissionBuilder.php
Normal file
292
app/Auth/Permissions/JointPermissionBuilder.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Joint permissions provide a pre-query "cached" table of view permissions for all core entity
|
||||
* types for all roles in the system. This class generates out that table for different scenarios.
|
||||
*/
|
||||
class JointPermissionBuilder
|
||||
{
|
||||
/**
|
||||
* Re-generate all entity permission from scratch.
|
||||
*/
|
||||
public function rebuildForAll()
|
||||
{
|
||||
JointPermission::query()->truncate();
|
||||
|
||||
// Get all roles (Should be the most limited dimension)
|
||||
$roles = Role::query()->with('permissions')->get()->all();
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
|
||||
$this->buildJointPermissionsForBooks($books, $roles);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->withTrashed()->select(['id', 'owned_by'])
|
||||
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
|
||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entity jointPermissions for a particular entity.
|
||||
*/
|
||||
public function rebuildForEntity(Entity $entity)
|
||||
{
|
||||
$entities = [$entity];
|
||||
if ($entity instanceof Book) {
|
||||
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
|
||||
$this->buildJointPermissionsForBooks($books, Role::query()->with('permissions')->get()->all(), true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BookChild $entity */
|
||||
if ($entity->book) {
|
||||
$entities[] = $entity->book;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$entities[] = $entity->chapter;
|
||||
}
|
||||
|
||||
if ($entity instanceof Chapter) {
|
||||
foreach ($entity->pages as $page) {
|
||||
$entities[] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
$this->buildJointPermissionsForEntities($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the entity jointPermissions for a particular role.
|
||||
*/
|
||||
public function rebuildForRole(Role $role)
|
||||
{
|
||||
$roles = [$role];
|
||||
$role->jointPermissions()->delete();
|
||||
$role->load('permissions');
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
|
||||
$this->buildJointPermissionsForBooks($books, $roles);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->select(['id', 'owned_by'])
|
||||
->chunk(50, function ($shelves) use ($roles) {
|
||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query for fetching a book with its children.
|
||||
*/
|
||||
protected function bookFetchQuery(): Builder
|
||||
{
|
||||
return Book::query()->withTrashed()
|
||||
->select(['id', 'owned_by'])->with([
|
||||
'chapters' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'owned_by', 'book_id']);
|
||||
},
|
||||
'pages' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build joint permissions for the given book and role combinations.
|
||||
*/
|
||||
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
|
||||
{
|
||||
$entities = clone $books;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($books->all() as $book) {
|
||||
foreach ($book->getRelation('chapters') as $chapter) {
|
||||
$entities->push($chapter);
|
||||
}
|
||||
foreach ($book->getRelation('pages') as $page) {
|
||||
$entities->push($page);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteOld) {
|
||||
$this->deleteManyJointPermissionsForEntities($entities->all());
|
||||
}
|
||||
|
||||
$this->createManyJointPermissions($entities->all(), $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entity jointPermissions for a collection of entities.
|
||||
*/
|
||||
protected function buildJointPermissionsForEntities(array $entities)
|
||||
{
|
||||
$roles = Role::query()->get()->values()->all();
|
||||
$this->deleteManyJointPermissionsForEntities($entities);
|
||||
$this->createManyJointPermissions($entities, $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all the entity jointPermissions for a list of entities.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function deleteManyJointPermissionsForEntities(array $entities)
|
||||
{
|
||||
$simpleEntities = $this->entitiesToSimpleEntities($entities);
|
||||
$idsByType = $this->entitiesToTypeIdMap($simpleEntities);
|
||||
|
||||
DB::transaction(function () use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
foreach (array_chunk($ids, 1000) as $idChunk) {
|
||||
DB::table('joint_permissions')
|
||||
->where('entity_type', '=', $type)
|
||||
->whereIn('entity_id', $idChunk)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Entity[] $entities
|
||||
*
|
||||
* @return SimpleEntityData[]
|
||||
*/
|
||||
protected function entitiesToSimpleEntities(array $entities): array
|
||||
{
|
||||
$simpleEntities = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$simple = SimpleEntityData::fromEntity($entity);
|
||||
$simpleEntities[] = $simple;
|
||||
}
|
||||
|
||||
return $simpleEntities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create & Save entity jointPermissions for many entities and roles.
|
||||
*
|
||||
* @param Entity[] $originalEntities
|
||||
* @param Role[] $roles
|
||||
*/
|
||||
protected function createManyJointPermissions(array $originalEntities, array $roles)
|
||||
{
|
||||
$entities = $this->entitiesToSimpleEntities($originalEntities);
|
||||
$jointPermissions = [];
|
||||
|
||||
// Fetch related entity permissions
|
||||
$permissions = new MassEntityPermissionEvaluator($entities, 'view');
|
||||
|
||||
// Create a mapping of role permissions
|
||||
$rolePermissionMap = [];
|
||||
foreach ($roles as $role) {
|
||||
foreach ($role->permissions as $permission) {
|
||||
$rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Create Joint Permission Data
|
||||
foreach ($entities as $entity) {
|
||||
foreach ($roles as $role) {
|
||||
$jp = $this->createJointPermissionData(
|
||||
$entity,
|
||||
$role->getRawAttribute('id'),
|
||||
$permissions,
|
||||
$rolePermissionMap,
|
||||
$role->system_name === 'admin'
|
||||
);
|
||||
$jointPermissions[] = $jp;
|
||||
}
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($jointPermissions) {
|
||||
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
|
||||
DB::table('joint_permissions')->insert($jointPermissionChunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* From the given entity list, provide back a mapping of entity types to
|
||||
* the ids of that given type. The type used is the DB morph class.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return array<string, int[]>
|
||||
*/
|
||||
protected function entitiesToTypeIdMap(array $entities): array
|
||||
{
|
||||
$idsByType = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if (!isset($idsByType[$entity->type])) {
|
||||
$idsByType[$entity->type] = [];
|
||||
}
|
||||
|
||||
$idsByType[$entity->type][] = $entity->id;
|
||||
}
|
||||
|
||||
return $idsByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create entity permission data for an entity and role
|
||||
* for a particular action.
|
||||
*/
|
||||
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, MassEntityPermissionEvaluator $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
|
||||
{
|
||||
// Ensure system admin role retains permissions
|
||||
if ($isAdminRole) {
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, PermissionStatus::EXPLICIT_ALLOW, true);
|
||||
}
|
||||
|
||||
// Return evaluated entity permission status if it has an affect.
|
||||
$entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId);
|
||||
if ($entityPermissionStatus !== null) {
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, false);
|
||||
}
|
||||
|
||||
// Otherwise default to the role-level permissions
|
||||
$permissionPrefix = $entity->type . '-view';
|
||||
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
|
||||
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
|
||||
$status = $roleHasPermission ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $status, $roleHasPermissionOwn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an array of data with the information of an entity jointPermissions.
|
||||
* Used to build data for bulk insertion.
|
||||
*/
|
||||
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, int $permissionStatus, bool $hasPermissionOwn): array
|
||||
{
|
||||
$ownPermissionActive = ($hasPermissionOwn && $permissionStatus !== PermissionStatus::EXPLICIT_DENY && $entity->owned_by);
|
||||
|
||||
return [
|
||||
'entity_id' => $entity->id,
|
||||
'entity_type' => $entity->type,
|
||||
'role_id' => $roleId,
|
||||
'status' => $permissionStatus,
|
||||
'owner_id' => $ownPermissionActive ? $entity->owned_by : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
81
app/Auth/Permissions/MassEntityPermissionEvaluator.php
Normal file
81
app/Auth/Permissions/MassEntityPermissionEvaluator.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
class MassEntityPermissionEvaluator extends EntityPermissionEvaluator
|
||||
{
|
||||
/**
|
||||
* @var SimpleEntityData[]
|
||||
*/
|
||||
protected array $entitiesInvolved;
|
||||
protected array $permissionMapCache;
|
||||
|
||||
public function __construct(array $entitiesInvolved, string $action)
|
||||
{
|
||||
$this->entitiesInvolved = $entitiesInvolved;
|
||||
parent::__construct($action);
|
||||
}
|
||||
|
||||
public function evaluateEntityForRole(SimpleEntityData $entity, int $roleId): ?int
|
||||
{
|
||||
$typeIdChain = $this->gatherEntityChainTypeIds($entity);
|
||||
$relevantPermissions = $this->getPermissionMapByTypeIdForChainAndRole($typeIdChain, $roleId);
|
||||
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
|
||||
|
||||
return $this->evaluatePermitsByType($permitsByType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $typeIdChain
|
||||
* @return array<string, EntityPermission[]>
|
||||
*/
|
||||
protected function getPermissionMapByTypeIdForChainAndRole(array $typeIdChain, int $roleId): array
|
||||
{
|
||||
$allPermissions = $this->getPermissionMapByTypeIdAndRoleForAllInvolved();
|
||||
$relevantPermissions = [];
|
||||
|
||||
// Filter down permissions to just those for current typeId
|
||||
// and current roleID or fallback permissions.
|
||||
foreach ($typeIdChain as $typeId) {
|
||||
$relevantPermissions[$typeId] = [
|
||||
...($allPermissions[$typeId][$roleId] ?? []),
|
||||
...($allPermissions[$typeId][0] ?? [])
|
||||
];
|
||||
}
|
||||
|
||||
return $relevantPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, EntityPermission[]>>
|
||||
*/
|
||||
protected function getPermissionMapByTypeIdAndRoleForAllInvolved(): array
|
||||
{
|
||||
if (isset($this->permissionMapCache)) {
|
||||
return $this->permissionMapCache;
|
||||
}
|
||||
|
||||
$entityTypeIdChain = [];
|
||||
foreach ($this->entitiesInvolved as $entity) {
|
||||
$entityTypeIdChain[] = $entity->type . ':' . $entity->id;
|
||||
}
|
||||
|
||||
$permissionMap = $this->getPermissionsMapByTypeId($entityTypeIdChain, []);
|
||||
|
||||
// Manipulate permission map to also be keyed by roleId.
|
||||
foreach ($permissionMap as $typeId => $permissions) {
|
||||
$permissionMap[$typeId] = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$roleId = $permission->getRawAttribute('role_id');
|
||||
if (!isset($permissionMap[$typeId][$roleId])) {
|
||||
$permissionMap[$typeId][$roleId] = [];
|
||||
}
|
||||
$permissionMap[$typeId][$roleId][] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
$this->permissionMapCache = $permissionMap;
|
||||
|
||||
return $this->permissionMapCache;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Model;
|
||||
@@ -12,8 +11,6 @@ use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use BookStack\Traits\HasOwner;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class PermissionApplicator
|
||||
@@ -50,7 +47,7 @@ class PermissionApplicator
|
||||
return $hasRolePermission;
|
||||
}
|
||||
|
||||
$hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $user->id, $action);
|
||||
$hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action);
|
||||
|
||||
return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions;
|
||||
}
|
||||
@@ -59,75 +56,11 @@ class PermissionApplicator
|
||||
* Check if there are permissions that are applicable for the given entity item, action and roles.
|
||||
* Returns null when no entity permissions are in force.
|
||||
*/
|
||||
protected function hasEntityPermission(Entity $entity, array $userRoleIds, int $userId, string $action): ?bool
|
||||
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 array order here is very important due to the fact we walk up the chain
|
||||
// in the flattening loop below. Earlier items in the chain have higher priority.
|
||||
$typeIdList = [$entity->getMorphClass() . ':' . $entity->id];
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$typeIdList[] = 'chapter:' . $entity->chapter_id;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page || $entity instanceof Chapter) {
|
||||
$typeIdList[] = 'book:' . $entity->book_id;
|
||||
}
|
||||
|
||||
$relevantPermissions = EntityPermission::query()
|
||||
->where(function (Builder $query) use ($typeIdList) {
|
||||
foreach ($typeIdList as $typeId) {
|
||||
$query->orWhere(function (Builder $query) use ($typeId) {
|
||||
[$type, $id] = explode(':', $typeId);
|
||||
$query->where('entity_type', '=', $type)
|
||||
->where('entity_id', '=', $id);
|
||||
});
|
||||
}
|
||||
})->where(function (Builder $query) use ($userRoleIds, $userId) {
|
||||
$query->whereIn('role_id', $userRoleIds)
|
||||
->orWhere('user_id', '=', $userId)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->whereNull(['role_id', 'user_id']);
|
||||
});
|
||||
})->get(['entity_id', 'entity_type', 'role_id', 'user_id', $action])
|
||||
->all();
|
||||
|
||||
$permissionMap = new EntityPermissionMap($relevantPermissions);
|
||||
$permitsByType = ['user' => [], 'fallback' => [], 'role' => []];
|
||||
|
||||
// Collapse and simplify permission structure
|
||||
foreach ($typeIdList as $typeId) {
|
||||
$permissions = $permissionMap->getForEntity($typeId);
|
||||
foreach ($permissions as $permission) {
|
||||
$related = $permission->getAssignedType();
|
||||
$relatedId = $permission->getAssignedTypeId();
|
||||
if (!isset($permitsByType[$related][$relatedId])) {
|
||||
$permitsByType[$related][$relatedId] = $permission->$action;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return user-level permission if exists
|
||||
if (count($permitsByType['user']) > 0) {
|
||||
return boolval(array_values($permitsByType['user'])[0]);
|
||||
}
|
||||
|
||||
// Return grant or reject from role-level if exists
|
||||
if (count($permitsByType['role']) > 0) {
|
||||
return boolval(max($permitsByType['role']));
|
||||
}
|
||||
|
||||
// Return fallback permission if exists
|
||||
if (count($permitsByType['fallback']) > 0) {
|
||||
return boolval($permitsByType['fallback'][0]);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (new EntityPermissionEvaluator($action))->evaluateEntityForUser($entity, $userRoleIds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,10 +73,7 @@ class PermissionApplicator
|
||||
|
||||
$permissionQuery = EntityPermission::query()
|
||||
->where($action, '=', true)
|
||||
->where(function (Builder $query) {
|
||||
$query->whereIn('role_id', $this->getCurrentUserRoleIds())
|
||||
->orWhere('user_id', '=', $this->currentUser()->id);
|
||||
});
|
||||
->whereIn('role_id', $this->getCurrentUserRoleIds());
|
||||
|
||||
if (!empty($entityClass)) {
|
||||
/** @var Entity $entityInstance */
|
||||
@@ -160,140 +90,20 @@ class PermissionApplicator
|
||||
* Limit the given entity query so that the query will only
|
||||
* return items that the user has view permission for.
|
||||
*/
|
||||
public function restrictEntityQuery(Builder $query, string $morphClass): Builder
|
||||
public function restrictEntityQuery(Builder $query): Builder
|
||||
{
|
||||
$this->applyPermissionsToQuery($query, $query->getModel()->getTable(), $morphClass, 'id', '');
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyPermissionsToQuery($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn): void
|
||||
{
|
||||
if ($this->currentUser()->hasSystemRole('admin')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyFallbackJoin($query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
$this->applyRoleJoin($query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
$this->applyUserJoin($query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
$this->applyPermissionWhereFilter($query, $queryTable, $entityTypeLimiter, $entityTypeColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the where condition to a permission restricting query, to limit based upon the values of the joined
|
||||
* permission data. Query must have joins pre-applied.
|
||||
* Either entityTypeLimiter or entityTypeColumn should be supplied, with the other empty.
|
||||
* Both should not be applied since that would conflict upon intent.
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyPermissionWhereFilter($query, string $queryTable, string $entityTypeLimiter, string $entityTypeColumn)
|
||||
{
|
||||
$abilities = ['all' => [], 'own' => []];
|
||||
$types = $entityTypeLimiter ? [$entityTypeLimiter] : ['page', 'chapter', 'bookshelf', 'book'];
|
||||
$fullEntityTypeColumn = $queryTable . '.' . $entityTypeColumn;
|
||||
foreach ($types as $type) {
|
||||
$abilities['all'][$type] = userCan($type . '-view-all');
|
||||
$abilities['own'][$type] = userCan($type . '-view-own');
|
||||
}
|
||||
|
||||
$abilities['all'] = array_filter($abilities['all']);
|
||||
$abilities['own'] = array_filter($abilities['own']);
|
||||
|
||||
$query->where(function (Builder $query) use ($abilities, $fullEntityTypeColumn, $entityTypeColumn) {
|
||||
$query->where('perms_user', '=', 1)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->whereNull('perms_user')->where('perms_role', '=', 1);
|
||||
})->orWhere(function (Builder $query) {
|
||||
$query->whereNull(['perms_user', 'perms_role'])
|
||||
->where('perms_fallback', '=', 1);
|
||||
});
|
||||
|
||||
if (count($abilities['all']) > 0) {
|
||||
$query->orWhere(function (Builder $query) use ($abilities, $fullEntityTypeColumn, $entityTypeColumn) {
|
||||
$query->whereNull(['perms_user', 'perms_role', 'perms_fallback']);
|
||||
if ($entityTypeColumn) {
|
||||
$query->whereIn($fullEntityTypeColumn, array_keys($abilities['all']));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (count($abilities['own']) > 0) {
|
||||
$query->orWhere(function (Builder $query) use ($abilities, $fullEntityTypeColumn, $entityTypeColumn) {
|
||||
$query->whereNull(['perms_user', 'perms_role', 'perms_fallback'])
|
||||
->where('owned_by', '=', $this->currentUser()->id);
|
||||
if ($entityTypeColumn) {
|
||||
$query->whereIn($fullEntityTypeColumn, array_keys($abilities['all']));
|
||||
}
|
||||
});
|
||||
}
|
||||
return $query->where(function (Builder $parentQuery) {
|
||||
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) {
|
||||
$permissionQuery->select(['entity_id', 'entity_type'])
|
||||
->selectRaw('max(owner_id) as owner_id')
|
||||
->selectRaw('max(status) as status')
|
||||
->whereIn('role_id', $this->getCurrentUserRoleIds())
|
||||
->groupBy(['entity_type', 'entity_id'])
|
||||
->havingRaw('(status IN (1, 3) or (owner_id = ? and status != 2))', [$this->currentUser()->id]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyPermissionJoin(callable $joinCallable, string $subAlias, $query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$joinCondition = $this->getJoinCondition($queryTable, $subAlias, $entityIdColumn, $entityTypeColumn);
|
||||
|
||||
$query->joinSub(function (QueryBuilder $joinQuery) use ($joinCallable, $entityTypeLimiter) {
|
||||
$joinQuery->select(['entity_id', 'entity_type'])->from('entity_permissions_collapsed')
|
||||
->groupBy('entity_id', 'entity_type');
|
||||
$joinCallable($joinQuery);
|
||||
|
||||
if ($entityTypeLimiter) {
|
||||
$joinQuery->where('entity_type', '=', $entityTypeLimiter);
|
||||
}
|
||||
}, $subAlias, $joinCondition, null, null, 'left');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyUserJoin($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$this->applyPermissionJoin(function (QueryBuilder $joinQuery) {
|
||||
$joinQuery->selectRaw('max(view) as perms_user')
|
||||
->where('user_id', '=', $this->currentUser()->id);
|
||||
}, 'p_u', $query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyRoleJoin($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$this->applyPermissionJoin(function (QueryBuilder $joinQuery) {
|
||||
$joinQuery->selectRaw('max(view) as perms_role')
|
||||
->whereIn('role_id', $this->getCurrentUserRoleIds());
|
||||
}, 'p_r', $query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyFallbackJoin($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$this->applyPermissionJoin(function (QueryBuilder $joinQuery) {
|
||||
$joinQuery->selectRaw('max(view) as perms_fallback')
|
||||
->whereNull(['role_id', 'user_id']);
|
||||
}, 'p_f', $query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
}
|
||||
|
||||
protected function getJoinCondition(string $queryTable, string $joinTableName, string $entityIdColumn, string $entityTypeColumn): callable
|
||||
{
|
||||
return function (JoinClause $join) use ($queryTable, $joinTableName, $entityIdColumn, $entityTypeColumn) {
|
||||
$join->on($queryTable . '.' . $entityIdColumn, '=', $joinTableName . '.entity_id');
|
||||
if ($entityTypeColumn) {
|
||||
$join->on($queryTable . '.' . $entityTypeColumn, '=', $joinTableName . '.entity_type');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the given page query to ensure draft items are not visible
|
||||
* unless created by the given user.
|
||||
@@ -313,28 +123,23 @@ class PermissionApplicator
|
||||
* Filter items that have entities set as a polymorphic relation.
|
||||
* For simplicity, this will not return results attached to draft pages.
|
||||
* Draft pages should never really have related items though.
|
||||
*
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn)
|
||||
public function restrictEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder
|
||||
{
|
||||
$query->leftJoinSub(function (QueryBuilder $query) {
|
||||
$query->select(['id as entity_id', DB::raw("'page' as entity_type"), 'owned_by', 'deleted_at', 'draft'])->from('pages');
|
||||
$tablesByType = ['page' => 'pages', 'book' => 'books', 'chapter' => 'chapters', 'bookshelf' => 'bookshelves'];
|
||||
foreach ($tablesByType as $type => $table) {
|
||||
$query->unionAll(function (QueryBuilder $query) use ($type, $table) {
|
||||
$query->select(['id as entity_id', DB::raw("'{$type}' as entity_type"), 'owned_by', 'deleted_at', DB::raw('0 as draft')])->from($table);
|
||||
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
|
||||
$pageMorphClass = (new Page())->getMorphClass();
|
||||
|
||||
return $this->restrictEntityQuery($query)
|
||||
->where(function ($query) use ($tableDetails, $pageMorphClass) {
|
||||
/** @var Builder $query */
|
||||
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
|
||||
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
|
||||
->where('pages.draft', '=', false);
|
||||
});
|
||||
}
|
||||
}, 'entities', function (JoinClause $join) use ($tableName, $entityIdColumn, $entityTypeColumn) {
|
||||
$join->on($tableName . '.' . $entityIdColumn, '=', 'entities.entity_id')
|
||||
->on($tableName . '.' . $entityTypeColumn, '=', 'entities.entity_type');
|
||||
});
|
||||
|
||||
$this->applyPermissionsToQuery($query, $tableName, '', $entityIdColumn, $entityTypeColumn);
|
||||
// TODO - Test page draft access (Might allow drafts which should not be seen)
|
||||
|
||||
return $query;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,12 +150,16 @@ class PermissionApplicator
|
||||
*/
|
||||
public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
|
||||
{
|
||||
$morphClass = (new Page())->getMorphClass();
|
||||
|
||||
$this->applyPermissionsToQuery($query, $tableName, $morphClass, $pageIdColumn, '');
|
||||
// TODO - Draft display
|
||||
// TODO - Likely need owned_by entity join workaround as used above
|
||||
return $query;
|
||||
$fullPageIdColumn = $tableName . '.' . $pageIdColumn;
|
||||
return $this->restrictEntityQuery($query)
|
||||
->where(function ($query) use ($fullPageIdColumn) {
|
||||
/** @var Builder $query */
|
||||
$query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $fullPageIdColumn)
|
||||
->where('pages.draft', '=', false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,32 +21,19 @@ class PermissionFormData
|
||||
{
|
||||
return $this->entity->permissions()
|
||||
->with('role')
|
||||
->whereNotNull('role_id')
|
||||
->where('role_id', '!=', 0)
|
||||
->get()
|
||||
->sortBy('role.display_name')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permissions with assigned users.
|
||||
*/
|
||||
public function permissionsWithUsers(): array
|
||||
{
|
||||
return $this->entity->permissions()
|
||||
->with('user')
|
||||
->whereNotNull('user_id')
|
||||
->get()
|
||||
->sortBy('user.name')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles that don't yet have specific permissions for the
|
||||
* entity we're managing permissions for.
|
||||
*/
|
||||
public function rolesNotAssigned(): array
|
||||
{
|
||||
$assigned = $this->entity->permissions()->whereNotNull('role_id')->pluck('role_id');
|
||||
$assigned = $this->entity->permissions()->pluck('role_id');
|
||||
return Role::query()
|
||||
->where('system_name', '!=', 'admin')
|
||||
->whereNotIn('id', $assigned)
|
||||
@@ -62,19 +49,20 @@ class PermissionFormData
|
||||
{
|
||||
/** @var ?EntityPermission $permission */
|
||||
$permission = $this->entity->permissions()
|
||||
->whereNull(['role_id', 'user_id'])
|
||||
->where('role_id', '=', 0)
|
||||
->first();
|
||||
return $permission ?? (new EntityPermission());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the "Everyone else" option is inheriting default role system permissions.
|
||||
* Is determined by any system entity_permission existing for the current entity.
|
||||
* Get the "Everyone Else" role entry.
|
||||
*/
|
||||
public function everyoneElseInheriting(): bool
|
||||
public function everyoneElseRole(): Role
|
||||
{
|
||||
return !$this->entity->permissions()
|
||||
->whereNull(['role_id', 'user_id'])
|
||||
->exists();
|
||||
return (new Role())->forceFill([
|
||||
'id' => 0,
|
||||
'display_name' => trans('entities.permissions_role_everyone_else'),
|
||||
'description' => trans('entities.permissions_role_everyone_else_desc'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
11
app/Auth/Permissions/PermissionStatus.php
Normal file
11
app/Auth/Permissions/PermissionStatus.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
class PermissionStatus
|
||||
{
|
||||
const IMPLICIT_DENY = 0;
|
||||
const IMPLICIT_ALLOW = 1;
|
||||
const EXPLICIT_DENY = 2;
|
||||
const EXPLICIT_ALLOW = 3;
|
||||
}
|
||||
@@ -11,13 +11,13 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class PermissionsRepo
|
||||
{
|
||||
protected CollapsedPermissionBuilder $permissionBuilder;
|
||||
protected array $systemRoles = ['admin', 'public'];
|
||||
protected JointPermissionBuilder $permissionBuilder;
|
||||
protected $systemRoles = ['admin', 'public'];
|
||||
|
||||
/**
|
||||
* PermissionsRepo constructor.
|
||||
*/
|
||||
public function __construct(CollapsedPermissionBuilder $permissionBuilder)
|
||||
public function __construct(JointPermissionBuilder $permissionBuilder)
|
||||
{
|
||||
$this->permissionBuilder = $permissionBuilder;
|
||||
}
|
||||
@@ -57,6 +57,7 @@ class PermissionsRepo
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
$this->assignRolePermissions($role, $permissions);
|
||||
$this->permissionBuilder->rebuildForRole($role);
|
||||
|
||||
Activity::add(ActivityType::ROLE_CREATE, $role);
|
||||
|
||||
@@ -87,6 +88,7 @@ class PermissionsRepo
|
||||
$role->fill($roleData);
|
||||
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
|
||||
$role->save();
|
||||
$this->permissionBuilder->rebuildForRole($role);
|
||||
|
||||
Activity::add(ActivityType::ROLE_UPDATE, $role);
|
||||
}
|
||||
@@ -138,7 +140,7 @@ class PermissionsRepo
|
||||
}
|
||||
|
||||
$role->entityPermissions()->delete();
|
||||
$role->collapsedPermissions()->delete();
|
||||
$role->jointPermissions()->delete();
|
||||
Activity::add(ActivityType::ROLE_DELETE, $role);
|
||||
$role->delete();
|
||||
}
|
||||
|
||||
@@ -2,10 +2,27 @@
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class SimpleEntityData
|
||||
{
|
||||
public int $id;
|
||||
public string $type;
|
||||
public int $owned_by;
|
||||
public ?int $book_id;
|
||||
public ?int $chapter_id;
|
||||
|
||||
public static function fromEntity(Entity $entity): self
|
||||
{
|
||||
$attrs = $entity->getAttributes();
|
||||
$simple = new self();
|
||||
|
||||
$simple->id = $attrs['id'];
|
||||
$simple->type = $entity->getMorphClass();
|
||||
$simple->owned_by = $attrs['owned_by'] ?? 0;
|
||||
$simple->book_id = $attrs['book_id'] ?? null;
|
||||
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
||||
|
||||
return $simple;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use BookStack\Auth\Permissions\CollapsedPermission;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\RolePermission;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
@@ -39,6 +39,14 @@ class Role extends Model implements Loggable
|
||||
return $this->belongsToMany(User::class)->orderBy('name', 'asc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related JointPermissions.
|
||||
*/
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The RolePermissions that belong to the role.
|
||||
*/
|
||||
@@ -55,14 +63,6 @@ class Role extends Model implements Loggable
|
||||
return $this->hasMany(EntityPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related entity collapsed permissions.
|
||||
*/
|
||||
public function collapsedPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CollapsedPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this role has a permission.
|
||||
*/
|
||||
|
||||
@@ -5,8 +5,6 @@ namespace BookStack\Auth;
|
||||
use BookStack\Actions\Favourite;
|
||||
use BookStack\Api\ApiToken;
|
||||
use BookStack\Auth\Access\Mfa\MfaValue;
|
||||
use BookStack\Auth\Permissions\CollapsedPermission;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Interfaces\Sluggable;
|
||||
@@ -202,6 +200,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
public function attachRole(Role $role)
|
||||
{
|
||||
$this->roles()->attach($role->id);
|
||||
$this->unsetRelation('roles');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,22 +299,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
}, 'activities', 'users.id', '=', 'activities.user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions assigned to this specific user.
|
||||
*/
|
||||
public function entityPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntityPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related entity collapsed permissions.
|
||||
*/
|
||||
public function collapsedPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CollapsedPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the url for editing this user.
|
||||
*/
|
||||
|
||||
@@ -153,8 +153,6 @@ class UserRepo
|
||||
$user->apiTokens()->delete();
|
||||
$user->favourites()->delete();
|
||||
$user->mfaValues()->delete();
|
||||
$user->collapsedPermissions()->delete();
|
||||
$user->entityPermissions()->delete();
|
||||
$user->delete();
|
||||
|
||||
// Delete user profile images
|
||||
@@ -236,6 +234,8 @@ class UserRepo
|
||||
*/
|
||||
protected function setUserRoles(User $user, array $roles)
|
||||
{
|
||||
$roles = array_filter(array_values($roles));
|
||||
|
||||
if ($this->demotingLastAdmin($user, $roles)) {
|
||||
throw new UserUpdateException(trans('errors.role_cannot_remove_only_admin'), $user->getEditUrl());
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ return [
|
||||
// Dump user details after a login request for debugging purposes
|
||||
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
|
||||
|
||||
// Attribute, within a OpenId token, to find the user's display name
|
||||
// Claim, within an OpenId token, to find the user's display name
|
||||
'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
|
||||
|
||||
// Claim, within an OpenID token, to use to connect a BookStack user to the OIDC user.
|
||||
'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'),
|
||||
|
||||
// OAuth2/OpenId client id, as configured in your Authorization server.
|
||||
'client_id' => env('OIDC_CLIENT_ID', null),
|
||||
|
||||
|
||||
@@ -16,11 +16,20 @@ return [
|
||||
'app-editor' => 'wysiwyg',
|
||||
'app-color' => '#206ea7',
|
||||
'app-color-light' => 'rgba(32,110,167,0.15)',
|
||||
'link-color' => '#206ea7',
|
||||
'bookshelf-color' => '#a94747',
|
||||
'book-color' => '#077b70',
|
||||
'chapter-color' => '#af4d0d',
|
||||
'page-color' => '#206ea7',
|
||||
'page-draft-color' => '#7e50b1',
|
||||
'app-color-dark' => '#195785',
|
||||
'app-color-light-dark' => 'rgba(32,110,167,0.15)',
|
||||
'link-color-dark' => '#429fe3',
|
||||
'bookshelf-color-dark' => '#ff5454',
|
||||
'book-color-dark' => '#389f60',
|
||||
'chapter-color-dark' => '#ee7a2d',
|
||||
'page-color-dark' => '#429fe3',
|
||||
'page-draft-color-dark' => '#a66ce8',
|
||||
'app-custom-head' => false,
|
||||
'registration-enabled' => false,
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\JointPermissionBuilder;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -22,12 +22,12 @@ class RegeneratePermissions extends Command
|
||||
*/
|
||||
protected $description = 'Regenerate all system permissions';
|
||||
|
||||
protected CollapsedPermissionBuilder $permissionBuilder;
|
||||
protected JointPermissionBuilder $permissionBuilder;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*/
|
||||
public function __construct(CollapsedPermissionBuilder $permissionBuilder)
|
||||
public function __construct(JointPermissionBuilder $permissionBuilder)
|
||||
{
|
||||
$this->permissionBuilder = $permissionBuilder;
|
||||
parent::__construct();
|
||||
|
||||
@@ -7,9 +7,9 @@ use BookStack\Actions\Comment;
|
||||
use BookStack\Actions\Favourite;
|
||||
use BookStack\Actions\Tag;
|
||||
use BookStack\Actions\View;
|
||||
use BookStack\Auth\Permissions\CollapsedPermission;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\JointPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Interfaces\Deletable;
|
||||
@@ -69,7 +69,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query, $this->getMorphClass());
|
||||
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,11 +187,11 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity collapsed permissions this is connected to.
|
||||
* Get the entity jointPermissions this is connected to.
|
||||
*/
|
||||
public function collapsedPermissions(): MorphMany
|
||||
public function jointPermissions(): MorphMany
|
||||
{
|
||||
return $this->morphMany(CollapsedPermission::class, 'entity');
|
||||
return $this->morphMany(JointPermission::class, 'entity');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,7 +292,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function rebuildPermissions()
|
||||
{
|
||||
app()->make(CollapsedPermissionBuilder::class)->rebuildForEntity(clone $this);
|
||||
app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,10 +5,10 @@ namespace BookStack\Entities\Tools\Markdown;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use League\CommonMark\Block\Element\ListItem;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use League\CommonMark\Environment;
|
||||
use League\CommonMark\Extension\Table\TableExtension;
|
||||
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
|
||||
class MarkdownToHtml
|
||||
{
|
||||
@@ -26,7 +26,7 @@ class MarkdownToHtml
|
||||
$environment->addExtension(new TaskListExtension());
|
||||
$environment->addExtension(new CustomStrikeThroughExtension());
|
||||
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
|
||||
$converter = new CommonMarkConverter([], $environment);
|
||||
$converter = new MarkdownConverter($environment);
|
||||
|
||||
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Activity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PermissionsUpdater
|
||||
{
|
||||
@@ -57,30 +58,13 @@ class PermissionsUpdater
|
||||
protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): array
|
||||
{
|
||||
$formatted = [];
|
||||
$columnsByType = [
|
||||
'role' => 'role_id',
|
||||
'user' => 'user_id',
|
||||
'fallback' => '',
|
||||
];
|
||||
|
||||
foreach ($permissions as $type => $byId) {
|
||||
$column = $columnsByType[$type] ?? null;
|
||||
if (is_null($column)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($byId as $id => $info) {
|
||||
$entityPermissionData = [];
|
||||
|
||||
if (!empty($column)) {
|
||||
$entityPermissionData[$column] = $id;
|
||||
}
|
||||
|
||||
foreach (EntityPermission::PERMISSIONS as $permission) {
|
||||
$entityPermissionData[$permission] = (($info[$permission] ?? false) === "true");
|
||||
}
|
||||
$formatted[] = $entityPermissionData;
|
||||
foreach ($permissions as $roleId => $info) {
|
||||
$entityPermissionData = ['role_id' => $roleId];
|
||||
foreach (EntityPermission::PERMISSIONS as $permission) {
|
||||
$entityPermissionData[$permission] = (($info[$permission] ?? false) === "true");
|
||||
}
|
||||
$formatted[] = $entityPermissionData;
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
|
||||
@@ -372,7 +372,7 @@ class TrashCan
|
||||
$entity->permissions()->delete();
|
||||
$entity->tags()->delete();
|
||||
$entity->comments()->delete();
|
||||
$entity->collapsedPermissions()->delete();
|
||||
$entity->jointPermissions()->delete();
|
||||
$entity->searchTerms()->delete();
|
||||
$entity->deletions()->delete();
|
||||
$entity->favourites()->delete();
|
||||
|
||||
@@ -66,14 +66,19 @@ class DrawioImageController extends Controller
|
||||
*/
|
||||
public function getAsBase64($id)
|
||||
{
|
||||
$image = $this->imageRepo->getById($id);
|
||||
if (is_null($image) || $image->type !== 'drawio' || !userCan('page-view', $image->getPage())) {
|
||||
return $this->jsonError('Image data could not be found');
|
||||
try {
|
||||
$image = $this->imageRepo->getById($id);
|
||||
} catch (Exception $exception) {
|
||||
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
|
||||
}
|
||||
|
||||
if ($image->type !== 'drawio' || !userCan('page-view', $image->getPage())) {
|
||||
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
|
||||
}
|
||||
|
||||
$imageData = $this->imageRepo->getImageData($image);
|
||||
if (is_null($imageData)) {
|
||||
return $this->jsonError('Image data could not be found');
|
||||
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace BookStack\Http\Controllers;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\PermissionFormData;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
@@ -163,35 +162,10 @@ class PermissionsController extends Controller
|
||||
{
|
||||
$this->checkPermissionOr('restrictions-manage-all', fn() => userCan('restrictions-manage-own'));
|
||||
|
||||
/** @var Role $role */
|
||||
$role = Role::query()->findOrFail($roleId);
|
||||
|
||||
return view('form.entity-permissions-row', [
|
||||
'modelType' => 'role',
|
||||
'modelId' => $role->id,
|
||||
'modelName' => $role->display_name,
|
||||
'modelDescription' => $role->description,
|
||||
'permission' => new EntityPermission(),
|
||||
'entityType' => $entityType,
|
||||
'inheriting' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an empty entity permissions form row for the given user.
|
||||
*/
|
||||
public function formRowForUser(string $entityType, string $userId)
|
||||
{
|
||||
$this->checkPermissionOr('restrictions-manage-all', fn() => userCan('restrictions-manage-own'));
|
||||
|
||||
/** @var User $user */
|
||||
$user = User::query()->findOrFail($userId);
|
||||
|
||||
return view('form.entity-permissions-row', [
|
||||
'modelType' => 'user',
|
||||
'modelId' => $user->id,
|
||||
'modelName' => $user->name,
|
||||
'modelDescription' => '',
|
||||
'role' => $role,
|
||||
'permission' => new EntityPermission(),
|
||||
'entityType' => $entityType,
|
||||
'inheriting' => false,
|
||||
|
||||
@@ -4,20 +4,14 @@ namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Settings\AppSettingsStore;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SettingController extends Controller
|
||||
{
|
||||
protected ImageRepo $imageRepo;
|
||||
|
||||
protected array $settingCategories = ['features', 'customization', 'registration'];
|
||||
|
||||
public function __construct(ImageRepo $imageRepo)
|
||||
{
|
||||
$this->imageRepo = $imageRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle requests to the settings index path.
|
||||
*/
|
||||
@@ -48,37 +42,17 @@ class SettingController extends Controller
|
||||
/**
|
||||
* Update the specified settings in storage.
|
||||
*/
|
||||
public function update(Request $request, string $category)
|
||||
public function update(Request $request, AppSettingsStore $store, string $category)
|
||||
{
|
||||
$this->ensureCategoryExists($category);
|
||||
$this->preventAccessInDemoMode();
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->validate($request, [
|
||||
'app_logo' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'app_logo' => ['nullable', ...$this->getImageValidationRules()],
|
||||
'app_icon' => ['nullable', ...$this->getImageValidationRules()],
|
||||
]);
|
||||
|
||||
// Cycles through posted settings and update them
|
||||
foreach ($request->all() as $name => $value) {
|
||||
$key = str_replace('setting-', '', trim($name));
|
||||
if (strpos($name, 'setting-') !== 0) {
|
||||
continue;
|
||||
}
|
||||
setting()->put($key, $value);
|
||||
}
|
||||
|
||||
// Update logo image if set
|
||||
if ($category === 'customization' && $request->hasFile('app_logo')) {
|
||||
$logoFile = $request->file('app_logo');
|
||||
$this->imageRepo->destroyByType('system');
|
||||
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
|
||||
setting()->put('app-logo', $image->url);
|
||||
}
|
||||
|
||||
// Clear logo image if requested
|
||||
if ($category === 'customization' && $request->get('app_logo_reset', null)) {
|
||||
$this->imageRepo->destroyByType('system');
|
||||
setting()->remove('app-logo');
|
||||
}
|
||||
$store->storeFromUpdateRequest($request, $category);
|
||||
|
||||
$this->logActivity(ActivityType::SETTINGS_UPDATE, $category);
|
||||
$this->showSuccessNotification(trans('settings.settings_save_success'));
|
||||
|
||||
@@ -164,6 +164,8 @@ class UserController extends Controller
|
||||
// Delete the profile image if reset option is in request
|
||||
if ($request->has('profile_image_reset')) {
|
||||
$this->imageRepo->destroyImage($user->avatar);
|
||||
$user->image_id = 0;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
$redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}";
|
||||
|
||||
@@ -3,10 +3,41 @@
|
||||
namespace BookStack\Providers;
|
||||
|
||||
use BookStack\Translation\FileLoader;
|
||||
use BookStack\Translation\MessageSelector;
|
||||
use Illuminate\Translation\TranslationServiceProvider as BaseProvider;
|
||||
use Illuminate\Translation\Translator;
|
||||
|
||||
class TranslationServiceProvider extends BaseProvider
|
||||
{
|
||||
/**
|
||||
* Register the service provider.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->registerLoader();
|
||||
|
||||
// This is a tweak upon Laravel's based translation service registration to allow
|
||||
// usage of a custom MessageSelector class
|
||||
$this->app->singleton('translator', function ($app) {
|
||||
$loader = $app['translation.loader'];
|
||||
|
||||
// When registering the translator component, we'll need to set the default
|
||||
// locale as well as the fallback locale. So, we'll grab the application
|
||||
// configuration so we can easily get both of these values from there.
|
||||
$locale = $app['config']['app.locale'];
|
||||
|
||||
$trans = new Translator($loader, $locale);
|
||||
$trans->setFallback($app['config']['app.fallback_locale']);
|
||||
$trans->setSelector(new MessageSelector());
|
||||
|
||||
return $trans;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Register the translation line loader.
|
||||
* Overrides the default register action from Laravel so a custom loader can be used.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace BookStack\References;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
@@ -24,4 +26,10 @@ class Reference extends Model
|
||||
{
|
||||
return $this->morphTo('to');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'from_id')
|
||||
->whereColumn('references.from_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\References;
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
@@ -23,8 +24,7 @@ class ReferenceFetcher
|
||||
*/
|
||||
public function getPageReferencesToEntity(Entity $entity): Collection
|
||||
{
|
||||
$baseQuery = $entity->referencesTo()
|
||||
->where('from_type', '=', (new Page())->getMorphClass())
|
||||
$baseQuery = $this->queryPageReferencesToEntity($entity)
|
||||
->with([
|
||||
'from' => fn (Relation $query) => $query->select(Page::$listAttributes),
|
||||
'from.book' => fn (Relation $query) => $query->scopes('visible'),
|
||||
@@ -47,11 +47,8 @@ class ReferenceFetcher
|
||||
*/
|
||||
public function getPageReferenceCountToEntity(Entity $entity): int
|
||||
{
|
||||
$baseQuery = $entity->referencesTo()
|
||||
->where('from_type', '=', (new Page())->getMorphClass());
|
||||
|
||||
$count = $this->permissions->restrictEntityRelationQuery(
|
||||
$baseQuery,
|
||||
$this->queryPageReferencesToEntity($entity),
|
||||
'references',
|
||||
'from_id',
|
||||
'from_type'
|
||||
@@ -59,4 +56,12 @@ class ReferenceFetcher
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
protected function queryPageReferencesToEntity(Entity $entity): Builder
|
||||
{
|
||||
return Reference::query()
|
||||
->where('to_type', '=', $entity->getMorphClass())
|
||||
->where('to_id', '=', $entity->id)
|
||||
->where('from_type', '=', (new Page())->getMorphClass());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,12 +112,12 @@ class SearchIndex
|
||||
*
|
||||
* @returns array<string, int>
|
||||
*/
|
||||
protected function generateTermScoreMapFromText(string $text, int $scoreAdjustment = 1): array
|
||||
protected function generateTermScoreMapFromText(string $text, float $scoreAdjustment = 1): array
|
||||
{
|
||||
$termMap = $this->textToTermCountMap($text);
|
||||
|
||||
foreach ($termMap as $term => $count) {
|
||||
$termMap[$term] = $count * $scoreAdjustment;
|
||||
$termMap[$term] = floor($count * $scoreAdjustment);
|
||||
}
|
||||
|
||||
return $termMap;
|
||||
|
||||
@@ -223,7 +223,7 @@ class SearchRunner
|
||||
});
|
||||
$subQuery->groupBy('entity_type', 'entity_id');
|
||||
|
||||
$entityQuery->joinSub($subQuery, 's', 'id', '=', 's.entity_id');
|
||||
$entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id');
|
||||
$entityQuery->addSelect('s.score');
|
||||
$entityQuery->orderBy('score', 'desc');
|
||||
}
|
||||
|
||||
91
app/Settings/AppSettingsStore.php
Normal file
91
app/Settings/AppSettingsStore.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Settings;
|
||||
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AppSettingsStore
|
||||
{
|
||||
protected ImageRepo $imageRepo;
|
||||
|
||||
public function __construct(ImageRepo $imageRepo)
|
||||
{
|
||||
$this->imageRepo = $imageRepo;
|
||||
}
|
||||
|
||||
public function storeFromUpdateRequest(Request $request, string $category)
|
||||
{
|
||||
$this->storeSimpleSettings($request);
|
||||
if ($category === 'customization') {
|
||||
$this->updateAppLogo($request);
|
||||
$this->updateAppIcon($request);
|
||||
}
|
||||
}
|
||||
|
||||
protected function updateAppIcon(Request $request): void
|
||||
{
|
||||
$sizes = [180, 128, 64, 32];
|
||||
|
||||
// Update icon image if set
|
||||
if ($request->hasFile('app_icon')) {
|
||||
$iconFile = $request->file('app_icon');
|
||||
$this->destroyExistingSettingImage('app-icon');
|
||||
$image = $this->imageRepo->saveNew($iconFile, 'system', 0, 256, 256);
|
||||
setting()->put('app-icon', $image->url);
|
||||
|
||||
foreach ($sizes as $size) {
|
||||
$this->destroyExistingSettingImage('app-icon-' . $size);
|
||||
$icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size);
|
||||
setting()->put('app-icon-' . $size, $icon->url);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear icon image if requested
|
||||
if ($request->get('app_icon_reset')) {
|
||||
$this->destroyExistingSettingImage('app-icon');
|
||||
setting()->remove('app-icon');
|
||||
foreach ($sizes as $size) {
|
||||
$this->destroyExistingSettingImage('app-icon-' . $size);
|
||||
setting()->remove('app-icon-' . $size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function updateAppLogo(Request $request): void
|
||||
{
|
||||
// Update logo image if set
|
||||
if ($request->hasFile('app_logo')) {
|
||||
$logoFile = $request->file('app_logo');
|
||||
$this->destroyExistingSettingImage('app-logo');
|
||||
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
|
||||
setting()->put('app-logo', $image->url);
|
||||
}
|
||||
|
||||
// Clear logo image if requested
|
||||
if ($request->get('app_logo_reset')) {
|
||||
$this->destroyExistingSettingImage('app-logo');
|
||||
setting()->remove('app-logo');
|
||||
}
|
||||
}
|
||||
|
||||
protected function storeSimpleSettings(Request $request): void
|
||||
{
|
||||
foreach ($request->all() as $name => $value) {
|
||||
if (strpos($name, 'setting-') !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = str_replace('setting-', '', trim($name));
|
||||
setting()->put($key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
protected function destroyExistingSettingImage(string $settingKey)
|
||||
{
|
||||
$existingVal = setting()->get($settingKey);
|
||||
if ($existingVal) {
|
||||
$this->imageRepo->destroyByUrlAndType($existingVal, 'system');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,11 @@ use Illuminate\Contracts\Cache\Repository as Cache;
|
||||
*/
|
||||
class SettingService
|
||||
{
|
||||
protected $setting;
|
||||
protected $cache;
|
||||
protected $localCache = [];
|
||||
protected Setting $setting;
|
||||
protected Cache $cache;
|
||||
protected array $localCache = [];
|
||||
protected string $cachePrefix = 'setting-';
|
||||
|
||||
protected $cachePrefix = 'setting-';
|
||||
|
||||
/**
|
||||
* SettingService constructor.
|
||||
*/
|
||||
public function __construct(Setting $setting, Cache $cache)
|
||||
{
|
||||
$this->setting = $setting;
|
||||
|
||||
19
app/Translation/MessageSelector.php
Normal file
19
app/Translation/MessageSelector.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Translation;
|
||||
|
||||
use Illuminate\Translation\MessageSelector as BaseClass;
|
||||
|
||||
/**
|
||||
* This is a customization of the default Laravel MessageSelector class to tweak pluralization,
|
||||
* so that is uses just the first part of the locale string to provide support with
|
||||
* non-standard locales such as "de_informal".
|
||||
*/
|
||||
class MessageSelector extends BaseClass
|
||||
{
|
||||
public function getPluralIndex($locale, $number)
|
||||
{
|
||||
$locale = explode('_', $locale)[0];
|
||||
return parent::getPluralIndex($locale, $number);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
@@ -10,6 +11,7 @@ use BookStack\Model;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -56,6 +58,12 @@ class Attachment extends Model
|
||||
return $this->belongsTo(Page::class, 'uploaded_to');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'uploaded_to')
|
||||
->where('joint_permissions.entity_type', '=', 'page');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the url of this file.
|
||||
*/
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Model;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -25,6 +27,12 @@ class Image extends Model
|
||||
protected $fillable = ['name'];
|
||||
protected $hidden = [];
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'uploaded_to')
|
||||
->where('joint_permissions.entity_type', '=', 'page');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a thumbnail for this image.
|
||||
*
|
||||
|
||||
@@ -123,7 +123,10 @@ class ImageRepo
|
||||
public function saveNew(UploadedFile $uploadFile, string $type, int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, bool $keepRatio = true): Image
|
||||
{
|
||||
$image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
|
||||
$this->loadThumbs($image);
|
||||
|
||||
if ($type !== 'system') {
|
||||
$this->loadThumbs($image);
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
@@ -180,13 +183,17 @@ class ImageRepo
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all images of a certain type.
|
||||
* Destroy images that have a specific URL and type combination.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroyByType(string $imageType): void
|
||||
public function destroyByUrlAndType(string $url, string $imageType): void
|
||||
{
|
||||
$images = Image::query()->where('type', '=', $imageType)->get();
|
||||
$images = Image::query()
|
||||
->where('url', '=', $url)
|
||||
->where('type', '=', $imageType)
|
||||
->get();
|
||||
|
||||
foreach ($images as $image) {
|
||||
$this->destroyImage($image);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ class CspService
|
||||
|
||||
protected function getAllowedIframeHosts(): array
|
||||
{
|
||||
$hosts = config('app.iframe_hosts', '');
|
||||
$hosts = config('app.iframe_hosts') ?? '';
|
||||
|
||||
return array_filter(explode(' ', $hosts));
|
||||
}
|
||||
|
||||
@@ -55,9 +55,8 @@ function hasAppAccess(): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has a permission.
|
||||
* Checks a generic role permission or, if an ownable model is passed in, it will
|
||||
* check against the given entity model, taking into account entity-level permissions.
|
||||
* Check if the current user has a permission. If an ownable element
|
||||
* is passed in the jointPermissions are checked against that particular item.
|
||||
*/
|
||||
function userCan(string $permission, Model $ownable = null): bool
|
||||
{
|
||||
|
||||
665
composer.lock
generated
665
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
project_identifier: bookstack
|
||||
base_path: .
|
||||
preserve_hierarchy: false
|
||||
pull_request_title: Updated translations with latest Crowdin changes
|
||||
pull_request_labels:
|
||||
- ":earth_africa: Translations"
|
||||
files:
|
||||
- source: /resources/lang/en/*.php
|
||||
translation: /resources/lang/%two_letters_code%/%original_file_name%
|
||||
|
||||
@@ -21,7 +21,7 @@ class TagFactory extends Factory
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->city,
|
||||
'name' => $this->faker->city(),
|
||||
'value' => $this->faker->sentence(3),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class WebhookFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'name' => 'My webhook for ' . $this->faker->country(),
|
||||
'endpoint' => $this->faker->url,
|
||||
'endpoint' => $this->faker->url(),
|
||||
'active' => true,
|
||||
'timeout' => 3,
|
||||
];
|
||||
|
||||
@@ -22,11 +22,11 @@ class UserFactory extends Factory
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
$name = $this->faker->name;
|
||||
$name = $this->faker->name();
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'email' => $this->faker->email,
|
||||
'email' => $this->faker->email(),
|
||||
'slug' => Str::slug($name . '-' . Str::random(5)),
|
||||
'password' => Str::random(10),
|
||||
'remember_token' => Str::random(10),
|
||||
|
||||
@@ -22,9 +22,9 @@ class BookFactory extends Factory
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->sentence,
|
||||
'name' => $this->faker->sentence(),
|
||||
'slug' => Str::random(10),
|
||||
'description' => $this->faker->paragraph,
|
||||
'description' => $this->faker->paragraph(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ class ChapterFactory extends Factory
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->sentence,
|
||||
'name' => $this->faker->sentence(),
|
||||
'slug' => Str::random(10),
|
||||
'description' => $this->faker->paragraph,
|
||||
'description' => $this->faker->paragraph(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class PageFactory extends Factory
|
||||
$html = '<p>' . implode('</p>', $this->faker->paragraphs(5)) . '</p>';
|
||||
|
||||
return [
|
||||
'name' => $this->faker->sentence,
|
||||
'name' => $this->faker->sentence(),
|
||||
'slug' => Str::random(10),
|
||||
'html' => $html,
|
||||
'text' => strip_tags($html),
|
||||
|
||||
@@ -21,9 +21,9 @@ class ImageFactory extends Factory
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->slug . '.jpg',
|
||||
'url' => $this->faker->url,
|
||||
'path' => $this->faker->url,
|
||||
'name' => $this->faker->slug() . '.jpg',
|
||||
'url' => $this->faker->url(),
|
||||
'path' => $this->faker->url(),
|
||||
'type' => 'gallery',
|
||||
'uploaded_to' => 0,
|
||||
];
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddUserIdToEntityPermissions extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('entity_permissions', function (Blueprint $table) {
|
||||
$table->unsignedInteger('role_id')->nullable()->default(null)->change();
|
||||
$table->unsignedInteger('user_id')->nullable()->default(null)->index();
|
||||
});
|
||||
|
||||
DB::table('entity_permissions')
|
||||
->where('role_id', '=', 0)
|
||||
->update(['role_id' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
DB::table('entity_permissions')
|
||||
->whereNull('role_id')
|
||||
->update(['role_id' => 0]);
|
||||
|
||||
DB::table('entity_permissions')
|
||||
->whereNotNull('user_id')
|
||||
->delete();
|
||||
|
||||
Schema::table('entity_permissions', function (Blueprint $table) {
|
||||
$table->unsignedInteger('role_id')->nullable(false)->change();
|
||||
$table->dropColumn('user_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateCollapsedRolePermissionsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
// TODO - Drop joint permissions
|
||||
// TODO - Run collapsed table rebuild.
|
||||
|
||||
Schema::create('entity_permissions_collapsed', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('role_id')->nullable()->index();
|
||||
$table->unsignedInteger('user_id')->nullable()->index();
|
||||
$table->string('entity_type');
|
||||
$table->unsignedInteger('entity_id');
|
||||
$table->boolean('view')->index();
|
||||
|
||||
$table->index(['entity_type', 'entity_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('entity_permissions_collapsed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermissionBuilder;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class RefactorJointPermissionsStorage extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
// Truncate before schema changes to avoid performance issues
|
||||
// since we'll need to rebuild anyway.
|
||||
DB::table('joint_permissions')->truncate();
|
||||
|
||||
if (Schema::hasColumn('joint_permissions', 'owned_by')) {
|
||||
Schema::table('joint_permissions', function (Blueprint $table) {
|
||||
$table->dropColumn(['has_permission', 'has_permission_own', 'owned_by']);
|
||||
|
||||
$table->unsignedTinyInteger('status')->index();
|
||||
$table->unsignedInteger('owner_id')->nullable()->index();
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuild permissions
|
||||
app(JointPermissionBuilder::class)->rebuildForAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
DB::table('joint_permissions')->truncate();
|
||||
|
||||
Schema::table('joint_permissions', function (Blueprint $table) {
|
||||
$table->dropColumn(['status', 'owner_id']);
|
||||
|
||||
$table->boolean('has_permission')->index();
|
||||
$table->boolean('has_permission_own')->index();
|
||||
$table->unsignedInteger('owned_by')->index();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CopyColorSettingsForDarkMode extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$colorSettings = [
|
||||
'app-color',
|
||||
'app-color-light',
|
||||
'bookshelf-color',
|
||||
'book-color',
|
||||
'chapter-color',
|
||||
'page-color',
|
||||
'page-draft-color',
|
||||
];
|
||||
|
||||
$existing = DB::table('settings')
|
||||
->whereIn('setting_key', $colorSettings)
|
||||
->get()->toArray();
|
||||
|
||||
$newData = [];
|
||||
foreach ($existing as $setting) {
|
||||
$newSetting = (array) $setting;
|
||||
$newSetting['setting_key'] .= '-dark';
|
||||
$newData[] = $newSetting;
|
||||
|
||||
if ($newSetting['setting_key'] === 'app-color-dark') {
|
||||
$newSetting['setting_key'] = 'link-color';
|
||||
$newData[] = $newSetting;
|
||||
$newSetting['setting_key'] = 'link-color-dark';
|
||||
$newData[] = $newSetting;
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('settings')->insert($newData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
$colorSettings = [
|
||||
'app-color-dark',
|
||||
'link-color',
|
||||
'link-color-dark',
|
||||
'app-color-light-dark',
|
||||
'bookshelf-color-dark',
|
||||
'book-color-dark',
|
||||
'chapter-color-dark',
|
||||
'page-color-dark',
|
||||
'page-draft-color-dark',
|
||||
];
|
||||
|
||||
DB::table('settings')
|
||||
->whereIn('setting_key', $colorSettings)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use BookStack\Api\ApiToken;
|
||||
use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\JointPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\RolePermission;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
@@ -69,7 +69,7 @@ class DummyContentSeeder extends Seeder
|
||||
]);
|
||||
$token->save();
|
||||
|
||||
app(CollapsedPermissionBuilder::class)->rebuildForAll();
|
||||
app(JointPermissionBuilder::class)->rebuildForAll();
|
||||
app(SearchIndex::class)->indexAllEntities();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\JointPermissionBuilder;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
@@ -35,7 +35,7 @@ class LargeContentSeeder extends Seeder
|
||||
$largeBook->chapters()->saveMany($chapters);
|
||||
$all = array_merge([$largeBook], array_values($pages->all()), array_values($chapters->all()));
|
||||
|
||||
app()->make(CollapsedPermissionBuilder::class)->rebuildForEntity($largeBook);
|
||||
app()->make(JointPermissionBuilder::class)->rebuildForEntity($largeBook);
|
||||
app()->make(SearchIndex::class)->indexEntities($all);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ The testing database will also need migrating and seeding beforehand. This can b
|
||||
|
||||
Once done you can run `composer test` in the application root directory to run all tests. Tests can be ran in parallel by running them via `composer t`. This will use Laravel's built-in parallel testing functionality, and attempt to create and seed a database instance for each testing thread. If required these parallel testing instances can be reset, before testing again, by running `composer t-reset`.
|
||||
|
||||
If the codebase needs to be tested with deprecations, this can be done via uncommenting the relevant line within the TestCase@setUp function.
|
||||
|
||||
## Code Standards
|
||||
|
||||
PHP code standards are managed by [using PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer).
|
||||
|
||||
@@ -6,19 +6,16 @@ Test cases are written ability abstract, since all abilities should act the same
|
||||
|
||||
Tests are categorised by the most specific element involved in the scenario, where the below list is most specific to least:
|
||||
|
||||
- User entity permissions.
|
||||
- Role entity permissions.
|
||||
- Fallback entity permissions.
|
||||
- Role permissions.
|
||||
|
||||
- TODO - Test fallback in the context of the above.
|
||||
|
||||
## General Permission Logical Rules
|
||||
|
||||
The below are some general rules we follow to standardise the behaviour of permissions in the platform:
|
||||
|
||||
- Most specific permission application (as above) take priority and can deny less specific permissions.
|
||||
- Parent user/role entity permissions that may be inherited, are considered to essentially be applied on the item they are inherited to unless a lower level has its own permission rule for an already specific role/user.
|
||||
- Parent role entity permissions that may be inherited, are considered to essentially be applied on the item they are inherited to unless a lower level has its own permission rule for an already specific role.
|
||||
- Where both grant and deny exist at the same specificity, we side towards grant.
|
||||
|
||||
## Cases
|
||||
@@ -232,7 +229,17 @@ User denied page permission.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_80_multi_role_inherited_deny_via_parent
|
||||
#### test_71_multi_role_inheriting_deny_on_own
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has own page role permission.
|
||||
- Role B has entity denied page permission.
|
||||
- User has Role A and B.
|
||||
- Use owns Page.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_75_multi_role_inherited_deny_via_parent
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit enabled.
|
||||
@@ -242,180 +249,115 @@ User denied page permission.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
---
|
||||
#### test_76_multi_role_inherited_deny_via_parent_on_own
|
||||
|
||||
### Entity User Permissions
|
||||
|
||||
These are tests related to entity-level user-specific permission overrides.
|
||||
|
||||
#### test_01_explicit_allow
|
||||
|
||||
- Page permissions have inherit disabled.
|
||||
- User has entity allow page permission.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_02_explicit_deny
|
||||
|
||||
- Page permissions have inherit disabled.
|
||||
- User has entity deny page permission.
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit enabled.
|
||||
- Role A has own page role permission.
|
||||
- Role B has entity denied chapter permission.
|
||||
- User has Role A & B.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_10_allow_inherit
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- User has entity allow chapter permission.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_11_deny_inherit
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- User has entity deny chapter permission.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_12_allow_inherit_override
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- User has entity deny chapter permission.
|
||||
- User has entity allow page permission.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_13_deny_inherit_override
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- User has entity allow chapter permission.
|
||||
- User has entity deny page permission.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_40_entity_role_override_allow
|
||||
#### test_80_fallback_override_allow
|
||||
|
||||
- Page permissions have inherit disabled.
|
||||
- User has entity allow page permission.
|
||||
- Role A has entity deny page permission.
|
||||
- User has role A.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_41_entity_role_override_deny
|
||||
|
||||
- Page permissions have inherit disabled.
|
||||
- User has entity deny page permission.
|
||||
- Page fallback has entity deny permission.
|
||||
- Role A has entity allow page permission.
|
||||
- User has role A.
|
||||
- User has Role A.
|
||||
|
||||
User denied page permission.
|
||||
User granted page permission.
|
||||
|
||||
#### test_42_entity_role_override_allow_via_inherit
|
||||
#### test_81_fallback_override_deny
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- User has entity allow chapter permission.
|
||||
- Page permissions have inherit disabled.
|
||||
- Page fallback has entity allow permission.
|
||||
- Role A has entity deny page permission.
|
||||
- User has role A.
|
||||
- User has Role A.
|
||||
|
||||
User granted page permission.
|
||||
User denied page permission.
|
||||
|
||||
#### test_43_entity_role_override_deny_via_inherit
|
||||
#### test_84_fallback_override_allow_multi_role
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- User has entity deny chapter permission.
|
||||
- Page permissions have inherit disabled.
|
||||
- Page fallback has entity deny permission.
|
||||
- Role A has entity allow page permission.
|
||||
- User has role A.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_50_role_override_allow
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has no page role permission.
|
||||
- User has entity allow page permission.
|
||||
- User has Role A.
|
||||
- Role B has no entity page permissions.
|
||||
- User has Role A & B.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_51_role_override_deny
|
||||
#### test_85_fallback_override_deny_multi_role
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has all-page role permission.
|
||||
- User has entity deny page permission.
|
||||
- User has Role A.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_60_inherited_role_override_allow
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has no page role permission.
|
||||
- User has entity allow chapter permission.
|
||||
- User has Role A.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_61_inherited_role_override_deny
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has view-all page role permission.
|
||||
- User has entity deny chapter permission.
|
||||
- User has Role A.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_61_inherited_role_override_deny_on_own
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has view-own page role permission.
|
||||
- User has entity deny chapter permission.
|
||||
- User has Role A.
|
||||
- User owns Page.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_70_all_override_allow
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has no page role permission.
|
||||
- Page permissions have inherit disabled.
|
||||
- Page fallback has entity allow permission.
|
||||
- Role A has entity deny page permission.
|
||||
- User has entity allow page permission.
|
||||
- User has Role A.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_71_all_override_deny
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has page-all role permission.
|
||||
- Role A has entity allow page permission.
|
||||
- User has entity deny page permission.
|
||||
- User has Role A.
|
||||
- Role B has no entity page permissions.
|
||||
- User has Role A & B.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_80_inherited_all_override_allow
|
||||
#### test_86_fallback_override_allow_inherit
|
||||
|
||||
- Chapter permissions have inherit disabled.
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has no page role permission.
|
||||
- Role A has entity deny chapter permission.
|
||||
- User has entity allow chapter permission.
|
||||
- User has Role A.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_81_inherited_all_override_deny
|
||||
|
||||
- Page permissions have inherit enabled.
|
||||
- Role A has view-all page role permission.
|
||||
- Chapter fallback has entity deny permission.
|
||||
- Role A has entity allow chapter permission.
|
||||
- User has entity deny chapter permission.
|
||||
- User has Role A.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_87_fallback_override_deny_inherit
|
||||
|
||||
- Chapter permissions have inherit disabled.
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter fallback has entity allow permission.
|
||||
- Role A has entity deny chapter permission.
|
||||
- User has Role A.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_88_fallback_override_allow_multi_role_inherit
|
||||
|
||||
- Chapter permissions have inherit disabled.
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter fallback has entity deny permission.
|
||||
- Role A has entity allow chapter permission.
|
||||
- Role B has no entity chapter permissions.
|
||||
- User has Role A & B.
|
||||
|
||||
User granted page permission.
|
||||
|
||||
#### test_89_fallback_override_deny_multi_role_inherit
|
||||
|
||||
- Chapter permissions have inherit disabled.
|
||||
- Page permissions have inherit enabled.
|
||||
- Chapter fallback has entity allow permission.
|
||||
- Role A has entity deny chapter permission.
|
||||
- Role B has no entity chapter permissions.
|
||||
- User has Role A & B.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_90_fallback_overrides_parent_entity_role_deny
|
||||
|
||||
- Chapter permissions have inherit disabled.
|
||||
- Page permissions have inherit disabled.
|
||||
- Chapter fallback has entity deny permission.
|
||||
- Page fallback has entity deny permission.
|
||||
- Role A has entity allow chapter permission.
|
||||
- User has Role A.
|
||||
|
||||
User denied page permission.
|
||||
|
||||
#### test_91_fallback_overrides_parent_entity_role_inherit
|
||||
|
||||
- Book permissions have inherit disabled.
|
||||
- Chapter permissions have inherit disabled.
|
||||
- Page permissions have inherit enabled.
|
||||
- Book fallback has entity deny permission.
|
||||
- Chapter fallback has entity deny permission.
|
||||
- Role A has entity allow book permission.
|
||||
- User has Role A.
|
||||
|
||||
User denied page permission.
|
||||
1076
package-lock.json
generated
1076
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,11 +16,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "^3.0",
|
||||
"esbuild": "^0.15.12",
|
||||
"esbuild": "^0.17.3",
|
||||
"livereload": "^0.9.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"punycode": "^2.1.1",
|
||||
"sass": "^1.55.0"
|
||||
"punycode": "^2.3.0",
|
||||
"sass": "^1.57.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"clipboard": "^2.0.11",
|
||||
@@ -28,7 +28,7 @@
|
||||
"dropzone": "^5.9.3",
|
||||
"markdown-it": "^13.0.1",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"snabbdom": "^3.5.0",
|
||||
"snabbdom": "^3.5.1",
|
||||
"sortablejs": "^1.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
69
public/dist/app.js
vendored
Normal file
69
public/dist/app.js
vendored
Normal file
File diff suppressed because one or more lines are too long
38
public/dist/code.js
vendored
Normal file
38
public/dist/code.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/dist/export-styles.css
vendored
Normal file
1
public/dist/export-styles.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/dist/print-styles.css
vendored
Normal file
1
public/dist/print-styles.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
:root{--color-primary: #206ea7;--color-primary-light: rgba(32,110,167,0.15);--color-link: #206ea7;--color-page: #206ea7;--color-page-draft: #7e50b1;--color-chapter: #af4d0d;--color-book: #077b70;--color-bookshelf: #a94747;--bg-disabled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='100%25' width='100%25'%3E%3Cdefs%3E%3Cpattern id='doodad' width='19' height='19' viewBox='0 0 40 40' patternUnits='userSpaceOnUse' patternTransform='rotate(143)'%3E%3Crect width='100%25' height='100%25' fill='rgba(42, 67, 101,0)'/%3E%3Cpath d='M-10 30h60v20h-60zM-10-10h60v20h-60' fill='rgba(26, 32, 44,0)'/%3E%3Cpath d='M-10 10h60v20h-60zM-10-30h60v20h-60z' fill='rgba(0, 0, 0,0.05)'/%3E%3C/pattern%3E%3C/defs%3E%3Crect fill='url(%23doodad)' height='200%25' width='200%25'/%3E%3C/svg%3E")}:root.dark-mode{--bg-disabled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='100%25' width='100%25'%3E%3Cdefs%3E%3Cpattern id='doodad' width='19' height='19' viewBox='0 0 40 40' patternUnits='userSpaceOnUse' patternTransform='rotate(143)'%3E%3Crect width='100%25' height='100%25' fill='rgba(42, 67, 101,0)'/%3E%3Cpath d='M-10 30h60v20h-60zM-10-10h60v20h-60' fill='rgba(26, 32, 44,0)'/%3E%3Cpath d='M-10 10h60v20h-60zM-10-30h60v20h-60z' fill='rgba(255, 255, 255,0.05)'/%3E%3C/pattern%3E%3C/defs%3E%3Crect fill='url(%23doodad)' height='200%25' width='200%25'/%3E%3C/svg%3E");color-scheme:only dark}:root:not(.dark-mode){color-scheme:only light}header{display:none}html,body{font-size:12px;background-color:#fff}.page-content{margin:0 auto}.print-hidden{display:none !important}.tri-layout-container{grid-template-columns:1fr;grid-template-areas:"b";margin-inline-start:0;margin-inline-end:0;display:block}.card{box-shadow:none}.content-wrap.card{padding-inline-start:0;padding-inline-end:0}/*# sourceMappingURL=print-styles.css.map */
|
||||
1
public/dist/styles.css
vendored
Normal file
1
public/dist/styles.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
public/icon-128.png
Normal file
BIN
public/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/icon-180.png
Normal file
BIN
public/icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
public/icon-32.png
Normal file
BIN
public/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/icon-64.png
Normal file
BIN
public/icon-64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
@@ -45,7 +45,7 @@ Note: Listed services are not tested, vetted nor supported by the official BookS
|
||||
<img width="400" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
|
||||
</a></td>
|
||||
<td><a href="https://cloudabove.com/hosting" target="_blank">
|
||||
<img height="100" src="https://raw.githubusercontent.com/BookStackApp/website/main/static/images/sponsors/cloudabove.svg" alt="Cloudabove">
|
||||
<img height="100" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/cloudabove.png" alt="Cloudabove">
|
||||
</a></td>
|
||||
</tr></tbody></table>
|
||||
|
||||
@@ -55,6 +55,9 @@ Note: Listed services are not tested, vetted nor supported by the official BookS
|
||||
<td><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
|
||||
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
|
||||
</a></td>
|
||||
<td><a href="https://www.practicali.be" target="_blank">
|
||||
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/practicali.png" alt="Stellar Hosted">
|
||||
</a></td>
|
||||
</tr></tbody></table>
|
||||
|
||||
## 🛣️ Road Map
|
||||
|
||||
@@ -25,11 +25,14 @@ import 'codemirror/mode/properties/properties';
|
||||
import 'codemirror/mode/python/python';
|
||||
import 'codemirror/mode/ruby/ruby';
|
||||
import 'codemirror/mode/rust/rust';
|
||||
import 'codemirror/mode/scheme/scheme';
|
||||
import 'codemirror/mode/shell/shell';
|
||||
import 'codemirror/mode/smarty/smarty';
|
||||
import 'codemirror/mode/sql/sql';
|
||||
import 'codemirror/mode/stex/stex';
|
||||
import 'codemirror/mode/swift/swift';
|
||||
import 'codemirror/mode/toml/toml';
|
||||
import 'codemirror/mode/twig/twig';
|
||||
import 'codemirror/mode/vb/vb';
|
||||
import 'codemirror/mode/vbscript/vbscript';
|
||||
import 'codemirror/mode/xml/xml';
|
||||
@@ -74,6 +77,8 @@ const modeMap = {
|
||||
mdown: 'markdown',
|
||||
markdown: 'markdown',
|
||||
ml: 'mllike',
|
||||
mssql: 'text/x-mssql',
|
||||
mysql: 'text/x-mysql',
|
||||
nginx: 'nginx',
|
||||
octave: 'text/x-octave',
|
||||
perl: 'perl',
|
||||
@@ -86,19 +91,26 @@ const modeMap = {
|
||||
php: (content) => {
|
||||
return content.includes('<?php') ? 'php' : 'text/x-php';
|
||||
},
|
||||
pgsql: 'text/x-pgsql',
|
||||
'pl/sql': 'text/x-plsql',
|
||||
postgresql: 'text/x-pgsql',
|
||||
py: 'python',
|
||||
python: 'python',
|
||||
ruby: 'ruby',
|
||||
rust: 'rust',
|
||||
rb: 'ruby',
|
||||
rs: 'rust',
|
||||
scheme: 'scheme',
|
||||
shell: 'shell',
|
||||
sh: 'shell',
|
||||
smarty: 'smarty',
|
||||
sql: 'text/x-sql',
|
||||
sqlite: 'text/x-sqlite',
|
||||
stext: 'text/x-stex',
|
||||
swift: 'text/x-swift',
|
||||
toml: 'toml',
|
||||
ts: 'text/typescript',
|
||||
twig: 'twig',
|
||||
typescript: 'text/typescript',
|
||||
vbs: 'vbscript',
|
||||
vbscript: 'vbscript',
|
||||
|
||||
@@ -45,7 +45,7 @@ export class Attachments extends Component {
|
||||
this.stopEdit();
|
||||
/** @var {Tabs} */
|
||||
const tabs = window.$components.firstOnElement(this.mainTabs, 'tabs');
|
||||
tabs.show('items');
|
||||
tabs.show('attachment-panel-items');
|
||||
window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => {
|
||||
this.list.innerHTML = resp.data;
|
||||
window.$components.init(this.list);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Sortable from "sortablejs";
|
||||
import Sortable, {MultiDrag} from "sortablejs";
|
||||
import {Component} from "./component";
|
||||
import {htmlToDom} from "../services/dom";
|
||||
|
||||
@@ -37,6 +37,113 @@ const sortOperations = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The available move actions.
|
||||
* The active function indicates if the action is possible for the given item.
|
||||
* The run function performs the move.
|
||||
* @type {{up: {active(Element, ?Element, Element): boolean, run(Element, ?Element, Element)}}}
|
||||
*/
|
||||
const moveActions = {
|
||||
up: {
|
||||
active(elem, parent, book) {
|
||||
return !(elem.previousElementSibling === null && !parent);
|
||||
},
|
||||
run(elem, parent, book) {
|
||||
const newSibling = elem.previousElementSibling || parent;
|
||||
newSibling.insertAdjacentElement('beforebegin', elem);
|
||||
}
|
||||
},
|
||||
down: {
|
||||
active(elem, parent, book) {
|
||||
return !(elem.nextElementSibling === null && !parent);
|
||||
},
|
||||
run(elem, parent, book) {
|
||||
const newSibling = elem.nextElementSibling || parent;
|
||||
newSibling.insertAdjacentElement('afterend', elem);
|
||||
}
|
||||
},
|
||||
next_book: {
|
||||
active(elem, parent, book) {
|
||||
return book.nextElementSibling !== null;
|
||||
},
|
||||
run(elem, parent, book) {
|
||||
const newList = book.nextElementSibling.querySelector('ul');
|
||||
newList.prepend(elem);
|
||||
}
|
||||
},
|
||||
prev_book: {
|
||||
active(elem, parent, book) {
|
||||
return book.previousElementSibling !== null;
|
||||
},
|
||||
run(elem, parent, book) {
|
||||
const newList = book.previousElementSibling.querySelector('ul');
|
||||
newList.appendChild(elem);
|
||||
}
|
||||
},
|
||||
next_chapter: {
|
||||
active(elem, parent, book) {
|
||||
return elem.dataset.type === 'page' && this.getNextChapter(elem, parent);
|
||||
},
|
||||
run(elem, parent, book) {
|
||||
const nextChapter = this.getNextChapter(elem, parent);
|
||||
nextChapter.querySelector('ul').prepend(elem);
|
||||
},
|
||||
getNextChapter(elem, parent) {
|
||||
const topLevel = (parent || elem);
|
||||
const topItems = Array.from(topLevel.parentElement.children);
|
||||
const index = topItems.indexOf(topLevel);
|
||||
return topItems.slice(index + 1).find(elem => elem.dataset.type === 'chapter');
|
||||
}
|
||||
},
|
||||
prev_chapter: {
|
||||
active(elem, parent, book) {
|
||||
return elem.dataset.type === 'page' && this.getPrevChapter(elem, parent);
|
||||
},
|
||||
run(elem, parent, book) {
|
||||
const prevChapter = this.getPrevChapter(elem, parent);
|
||||
prevChapter.querySelector('ul').append(elem);
|
||||
},
|
||||
getPrevChapter(elem, parent) {
|
||||
const topLevel = (parent || elem);
|
||||
const topItems = Array.from(topLevel.parentElement.children);
|
||||
const index = topItems.indexOf(topLevel);
|
||||
return topItems.slice(0, index).reverse().find(elem => elem.dataset.type === 'chapter');
|
||||
}
|
||||
},
|
||||
book_end: {
|
||||
active(elem, parent, book) {
|
||||
return parent || (parent === null && elem.nextElementSibling);
|
||||
},
|
||||
run(elem, parent, book) {
|
||||
book.querySelector('ul').append(elem);
|
||||
}
|
||||
},
|
||||
book_start: {
|
||||
active(elem, parent, book) {
|
||||
return parent || (parent === null && elem.previousElementSibling);
|
||||
},
|
||||
run(elem, parent, book) {
|
||||
book.querySelector('ul').prepend(elem);
|
||||
}
|
||||
},
|
||||
before_chapter: {
|
||||
active(elem, parent, book) {
|
||||
return parent;
|
||||
},
|
||||
run(elem, parent, book) {
|
||||
parent.insertAdjacentElement('beforebegin', elem);
|
||||
}
|
||||
},
|
||||
after_chapter: {
|
||||
active(elem, parent, book) {
|
||||
return parent;
|
||||
},
|
||||
run(elem, parent, book) {
|
||||
parent.insertAdjacentElement('afterend', elem);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export class BookSort extends Component {
|
||||
|
||||
setup() {
|
||||
@@ -44,15 +151,34 @@ export class BookSort extends Component {
|
||||
this.sortContainer = this.$refs.sortContainer;
|
||||
this.input = this.$refs.input;
|
||||
|
||||
Sortable.mount(new MultiDrag());
|
||||
|
||||
const initialSortBox = this.container.querySelector('.sort-box');
|
||||
this.setupBookSortable(initialSortBox);
|
||||
this.setupSortPresets();
|
||||
this.setupMoveActions();
|
||||
|
||||
window.$events.listen('entity-select-confirm', this.bookSelect.bind(this));
|
||||
window.$events.listen('entity-select-change', this.bookSelect.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the handlers for the preset sort type buttons.
|
||||
* Set up the handlers for the item-level move buttons.
|
||||
*/
|
||||
setupMoveActions() {
|
||||
// Handle move button click
|
||||
this.container.addEventListener('click', event => {
|
||||
if (event.target.matches('[data-move]')) {
|
||||
const action = event.target.getAttribute('data-move');
|
||||
const sortItem = event.target.closest('[data-id]');
|
||||
this.runSortAction(sortItem, action);
|
||||
}
|
||||
});
|
||||
|
||||
this.updateMoveActionStateForAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the handlers for the preset sort type buttons.
|
||||
*/
|
||||
setupSortPresets() {
|
||||
let lastSort = '';
|
||||
@@ -100,16 +226,19 @@ export class BookSort extends Component {
|
||||
const newBookContainer = htmlToDom(resp.data);
|
||||
this.sortContainer.append(newBookContainer);
|
||||
this.setupBookSortable(newBookContainer);
|
||||
this.updateMoveActionStateForAll();
|
||||
|
||||
const summary = newBookContainer.querySelector('summary');
|
||||
summary.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the given book container element to have sortable items.
|
||||
* Set up the given book container element to have sortable items.
|
||||
* @param {Element} bookContainer
|
||||
*/
|
||||
setupBookSortable(bookContainer) {
|
||||
const sortElems = [bookContainer.querySelector('.sort-list')];
|
||||
sortElems.push(...bookContainer.querySelectorAll('.entity-list-item + ul'));
|
||||
const sortElems = Array.from(bookContainer.querySelectorAll('.sort-list, .sortable-page-sublist'));
|
||||
|
||||
const bookGroupConfig = {
|
||||
name: 'book',
|
||||
@@ -125,22 +254,40 @@ export class BookSort extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
for (let sortElem of sortElems) {
|
||||
new Sortable(sortElem, {
|
||||
for (const sortElem of sortElems) {
|
||||
Sortable.create(sortElem, {
|
||||
group: sortElem.classList.contains('sort-list') ? bookGroupConfig : chapterGroupConfig,
|
||||
animation: 150,
|
||||
fallbackOnBody: true,
|
||||
swapThreshold: 0.65,
|
||||
onSort: this.updateMapInput.bind(this),
|
||||
onSort: (event) => {
|
||||
this.ensureNoNestedChapters()
|
||||
this.updateMapInput();
|
||||
this.updateMoveActionStateForAll();
|
||||
},
|
||||
dragClass: 'bg-white',
|
||||
ghostClass: 'primary-background-light',
|
||||
multiDrag: true,
|
||||
multiDragKey: 'CTRL',
|
||||
multiDragKey: 'Control',
|
||||
selectedClass: 'sortable-selected',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle nested chapters by moving them to the parent book.
|
||||
* Needed since sorting with multi-sort only checks group rules based on the active item,
|
||||
* not all in group, therefore need to manually check after a sort.
|
||||
* Must be done before updating the map input.
|
||||
*/
|
||||
ensureNoNestedChapters() {
|
||||
const nestedChapters = this.container.querySelectorAll('[data-type="chapter"] [data-type="chapter"]');
|
||||
for (const chapter of nestedChapters) {
|
||||
const parentChapter = chapter.parentElement.closest('[data-type="chapter"]');
|
||||
parentChapter.insertAdjacentElement('afterend', chapter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the input with our sort data.
|
||||
*/
|
||||
@@ -202,4 +349,38 @@ export class BookSort extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the given sort action up the provided sort item.
|
||||
* @param {Element} item
|
||||
* @param {String} action
|
||||
*/
|
||||
runSortAction(item, action) {
|
||||
const parentItem = item.parentElement.closest('li[data-id]');
|
||||
const parentBook = item.parentElement.closest('[data-type="book"]');
|
||||
moveActions[action].run(item, parentItem, parentBook);
|
||||
this.updateMapInput();
|
||||
this.updateMoveActionStateForAll();
|
||||
item.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
||||
item.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the state of the available move actions on this item.
|
||||
* @param {Element} item
|
||||
*/
|
||||
updateMoveActionState(item) {
|
||||
const parentItem = item.parentElement.closest('li[data-id]');
|
||||
const parentBook = item.parentElement.closest('[data-type="book"]');
|
||||
for (const [action, functions] of Object.entries(moveActions)) {
|
||||
const moveButton = item.querySelector(`[data-move="${action}"]`);
|
||||
moveButton.disabled = !functions.active(item, parentItem, parentBook);
|
||||
}
|
||||
}
|
||||
|
||||
updateMoveActionStateForAll() {
|
||||
const items = this.container.querySelectorAll('[data-type="chapter"],[data-type="page"]');
|
||||
for (const item of items) {
|
||||
this.updateMoveActionState(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ export class EntityPermissions extends Component {
|
||||
this.everyoneInheritToggle = this.$refs.everyoneInherit;
|
||||
this.roleSelect = this.$refs.roleSelect;
|
||||
this.roleContainer = this.$refs.roleContainer;
|
||||
this.userContainer = this.$refs.userContainer;
|
||||
this.userSelectContainer = this.$refs.userSelectContainer;
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
@@ -20,7 +18,7 @@ export class EntityPermissions extends Component {
|
||||
// "Everyone Else" inherit toggle
|
||||
this.everyoneInheritToggle.addEventListener('change', event => {
|
||||
const inherit = event.target.checked;
|
||||
const permissions = document.querySelectorAll('input[name^="permissions[fallback]"]');
|
||||
const permissions = document.querySelectorAll('input[name^="permissions[0]["]');
|
||||
for (const permission of permissions) {
|
||||
permission.disabled = inherit;
|
||||
permission.checked = false;
|
||||
@@ -30,7 +28,7 @@ export class EntityPermissions extends Component {
|
||||
// Remove role row button click
|
||||
this.container.addEventListener('click', event => {
|
||||
const button = event.target.closest('button');
|
||||
if (button && button.dataset.modelType) {
|
||||
if (button && button.dataset.roleId) {
|
||||
this.removeRowOnButtonClick(button)
|
||||
}
|
||||
});
|
||||
@@ -42,14 +40,6 @@ export class EntityPermissions extends Component {
|
||||
this.addRoleRow(roleId);
|
||||
}
|
||||
});
|
||||
|
||||
// User select change
|
||||
this.userSelectContainer.querySelector('input[name="user_select"]').addEventListener('change', event => {
|
||||
const userId = event.target.value;
|
||||
if (userId) {
|
||||
this.addUserRow(userId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async addRoleRow(roleId) {
|
||||
@@ -62,50 +52,23 @@ export class EntityPermissions extends Component {
|
||||
}
|
||||
|
||||
// Get and insert new row
|
||||
const resp = await window.$http.get(`/permissions/role-form-row/${this.entityType}/${roleId}`);
|
||||
const resp = await window.$http.get(`/permissions/form-row/${this.entityType}/${roleId}`);
|
||||
const row = htmlToDom(resp.data);
|
||||
this.roleContainer.append(row);
|
||||
|
||||
this.roleSelect.disabled = false;
|
||||
}
|
||||
|
||||
async addUserRow(userId) {
|
||||
const exists = this.userContainer.querySelector(`[name^="permissions[user][${userId}]"]`) !== null;
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = this.userSelectContainer.querySelector('.dropdown-search-toggle-select');
|
||||
toggle.classList.add('disabled');
|
||||
this.userContainer.style.pointerEvents = 'none';
|
||||
|
||||
// Get and insert new row
|
||||
const resp = await window.$http.get(`/permissions/user-form-row/${this.entityType}/${userId}`);
|
||||
const row = htmlToDom(resp.data);
|
||||
this.userContainer.append(row);
|
||||
|
||||
toggle.classList.remove('disabled');
|
||||
this.userContainer.style.pointerEvents = null;
|
||||
|
||||
/** @var {UserSelect} **/
|
||||
const userSelect = window.$components.firstOnElement(this.userSelectContainer.querySelector('.dropdown-search'), 'user-select');
|
||||
userSelect.reset();
|
||||
}
|
||||
|
||||
removeRowOnButtonClick(button) {
|
||||
const row = button.closest('.item-list-row');
|
||||
const modelId = button.dataset.modelId;
|
||||
const modelName = button.dataset.modelName;
|
||||
const modelType = button.dataset.modelType;
|
||||
const roleId = button.dataset.roleId;
|
||||
const roleName = button.dataset.roleName;
|
||||
|
||||
const option = document.createElement('option');
|
||||
option.value = modelId;
|
||||
option.textContent = modelName;
|
||||
|
||||
if (modelType === 'role') {
|
||||
this.roleSelect.append(option);
|
||||
}
|
||||
option.value = roleId;
|
||||
option.textContent = roleName;
|
||||
|
||||
this.roleSelect.append(option);
|
||||
row.remove();
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export class EntitySelector extends Component {
|
||||
this.searchInput = this.$refs.search;
|
||||
this.loading = this.$refs.loading;
|
||||
this.resultsContainer = this.$refs.results;
|
||||
this.addButton = this.$refs.add;
|
||||
|
||||
this.search = '';
|
||||
this.lastClick = 0;
|
||||
@@ -43,15 +42,6 @@ export class EntitySelector extends Component {
|
||||
if (event.keyCode === 13) event.preventDefault();
|
||||
});
|
||||
|
||||
if (this.addButton) {
|
||||
this.addButton.addEventListener('click', event => {
|
||||
if (this.selectedItemData) {
|
||||
this.confirmSelection(this.selectedItemData);
|
||||
this.unselectAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
onChildEvent(this.$el, '[data-entity-type]', 'keydown', (e, el) => {
|
||||
if (e.ctrlKey && e.code === 'Enter') {
|
||||
|
||||
@@ -140,10 +140,9 @@ export class ImageManager extends Component {
|
||||
}
|
||||
|
||||
setActiveFilterTab(filterName) {
|
||||
this.filterTabs.forEach(t => t.classList.remove('selected'));
|
||||
const activeTab = this.filterTabs.find(t => t.dataset.filter === filterName);
|
||||
if (activeTab) {
|
||||
activeTab.classList.add('selected');
|
||||
for (const tab of this.filterTabs) {
|
||||
const selected = tab.dataset.filter === filterName;
|
||||
tab.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export {PagePicker} from "./page-picker.js"
|
||||
export {PermissionsTable} from "./permissions-table.js"
|
||||
export {Pointer} from "./pointer.js"
|
||||
export {Popup} from "./popup.js"
|
||||
export {SettingAppColorPicker} from "./setting-app-color-picker.js"
|
||||
export {SettingAppColorScheme} from "./setting-app-color-scheme.js"
|
||||
export {SettingColorPicker} from "./setting-color-picker.js"
|
||||
export {SettingHomepageControl} from "./setting-homepage-control.js"
|
||||
export {ShelfSort} from "./shelf-sort.js"
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import {Component} from "./component";
|
||||
|
||||
export class SettingAppColorPicker extends Component {
|
||||
|
||||
setup() {
|
||||
this.colorInput = this.$refs.input;
|
||||
this.lightColorInput = this.$refs.lightInput;
|
||||
|
||||
this.colorInput.addEventListener('change', this.updateColor.bind(this));
|
||||
this.colorInput.addEventListener('input', this.updateColor.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the app colors as a preview, and create a light version of the color.
|
||||
*/
|
||||
updateColor() {
|
||||
const hexVal = this.colorInput.value;
|
||||
const rgb = this.hexToRgb(hexVal);
|
||||
const rgbLightVal = 'rgba('+ [rgb.r, rgb.g, rgb.b, '0.15'].join(',') +')';
|
||||
|
||||
this.lightColorInput.value = rgbLightVal;
|
||||
|
||||
const customStyles = document.getElementById('custom-styles');
|
||||
const oldColor = customStyles.getAttribute('data-color');
|
||||
const oldColorLight = customStyles.getAttribute('data-color-light');
|
||||
|
||||
customStyles.innerHTML = customStyles.innerHTML.split(oldColor).join(hexVal);
|
||||
customStyles.innerHTML = customStyles.innerHTML.split(oldColorLight).join(rgbLightVal);
|
||||
|
||||
customStyles.setAttribute('data-color', hexVal);
|
||||
customStyles.setAttribute('data-color-light', rgbLightVal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covert a hex color code to rgb components.
|
||||
* @attribution https://stackoverflow.com/a/5624139
|
||||
* @param {String} hex
|
||||
* @returns {{r: Number, g: Number, b: Number}}
|
||||
*/
|
||||
hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return {
|
||||
r: result ? parseInt(result[1], 16) : 0,
|
||||
g: result ? parseInt(result[2], 16) : 0,
|
||||
b: result ? parseInt(result[3], 16) : 0
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
82
resources/js/components/setting-app-color-scheme.js
Normal file
82
resources/js/components/setting-app-color-scheme.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import {Component} from "./component";
|
||||
|
||||
export class SettingAppColorScheme extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.mode = this.$opts.mode;
|
||||
this.lightContainer = this.$refs.lightContainer;
|
||||
this.darkContainer = this.$refs.darkContainer;
|
||||
|
||||
this.container.addEventListener('tabs-change', event => {
|
||||
const panel = event.detail.showing;
|
||||
const newMode = (panel === 'color-scheme-panel-light') ? 'light' : 'dark';
|
||||
this.handleModeChange(newMode);
|
||||
});
|
||||
|
||||
const onInputChange = (event) => {
|
||||
this.updateAppColorsFromInputs();
|
||||
|
||||
if (event.target.name.startsWith('setting-app-color')) {
|
||||
this.updateLightForInput(event.target);
|
||||
}
|
||||
};
|
||||
this.container.addEventListener('change', onInputChange);
|
||||
this.container.addEventListener('input', onInputChange);
|
||||
}
|
||||
|
||||
handleModeChange(newMode) {
|
||||
this.mode = newMode;
|
||||
const isDark = (newMode === 'dark');
|
||||
|
||||
document.documentElement.classList.toggle('dark-mode', isDark);
|
||||
this.updateAppColorsFromInputs();
|
||||
}
|
||||
|
||||
updateAppColorsFromInputs() {
|
||||
const inputContainer = this.mode === 'dark' ? this.darkContainer : this.lightContainer;
|
||||
const inputs = inputContainer.querySelectorAll('input[type="color"]');
|
||||
for (const input of inputs) {
|
||||
const splitName = input.name.split('-');
|
||||
const colorPos = splitName.indexOf('color');
|
||||
let cssId = splitName.slice(1, colorPos).join('-');
|
||||
if (cssId === 'app') {
|
||||
cssId = 'primary';
|
||||
}
|
||||
|
||||
const varName = '--color-' + cssId;
|
||||
document.body.style.setProperty(varName, input.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the 'light' app color variant for the given input.
|
||||
* @param {HTMLInputElement} input
|
||||
*/
|
||||
updateLightForInput(input) {
|
||||
const lightName = input.name.replace('-color', '-color-light');
|
||||
const hexVal = input.value;
|
||||
const rgb = this.hexToRgb(hexVal);
|
||||
const rgbLightVal = 'rgba('+ [rgb.r, rgb.g, rgb.b, '0.15'].join(',') +')';
|
||||
|
||||
console.log(input.name, lightName, hexVal, rgbLightVal)
|
||||
const lightColorInput = this.container.querySelector(`input[name="${lightName}"][type="hidden"]`);
|
||||
lightColorInput.value = rgbLightVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Covert a hex color code to rgb components.
|
||||
* @attribution https://stackoverflow.com/a/5624139
|
||||
* @param {String} hex
|
||||
* @returns {{r: Number, g: Number, b: Number}}
|
||||
*/
|
||||
hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return {
|
||||
r: result ? parseInt(result[1], 16) : 0,
|
||||
g: result ? parseInt(result[2], 16) : 0,
|
||||
b: result ? parseInt(result[3], 16) : 0
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,6 +15,6 @@ export class SettingColorPicker extends Component {
|
||||
|
||||
setValue(value) {
|
||||
this.colorInput.value = value;
|
||||
this.colorInput.dispatchEvent(new Event('change'));
|
||||
this.colorInput.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,49 @@
|
||||
import {onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Tabs
|
||||
* Works by matching 'tabToggle<Key>' with 'tabContent<Key>' sections.
|
||||
* Uses accessible attributes to drive its functionality.
|
||||
* On tab wrapping element:
|
||||
* - role=tablist
|
||||
* On tabs (Should be a button):
|
||||
* - id
|
||||
* - role=tab
|
||||
* - aria-selected=true/false
|
||||
* - aria-controls=<id-of-panel-section>
|
||||
* On panels:
|
||||
* - id
|
||||
* - tabindex=0
|
||||
* - role=tabpanel
|
||||
* - aria-labelledby=<id-of-tab-for-panel>
|
||||
* - hidden (If not shown by default).
|
||||
*/
|
||||
export class Tabs extends Component {
|
||||
|
||||
setup() {
|
||||
this.tabContentsByName = {};
|
||||
this.tabButtonsByName = {};
|
||||
this.allContents = [];
|
||||
this.allButtons = [];
|
||||
this.container = this.$el;
|
||||
this.tabs = Array.from(this.container.querySelectorAll('[role="tab"]'));
|
||||
this.panels = Array.from(this.container.querySelectorAll('[role="tabpanel"]'));
|
||||
|
||||
for (const [key, elems] of Object.entries(this.$manyRefs || {})) {
|
||||
if (key.startsWith('toggle')) {
|
||||
const cleanKey = key.replace('toggle', '').toLowerCase();
|
||||
onSelect(elems, e => this.show(cleanKey));
|
||||
this.allButtons.push(...elems);
|
||||
this.tabButtonsByName[cleanKey] = elems;
|
||||
this.container.addEventListener('click', event => {
|
||||
const button = event.target.closest('[role="tab"]');
|
||||
if (button) {
|
||||
this.show(button.getAttribute('aria-controls'));
|
||||
}
|
||||
if (key.startsWith('content')) {
|
||||
const cleanKey = key.replace('content', '').toLowerCase();
|
||||
this.tabContentsByName[cleanKey] = elems;
|
||||
this.allContents.push(...elems);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
show(key) {
|
||||
this.allContents.forEach(c => {
|
||||
c.classList.add('hidden');
|
||||
c.classList.remove('selected');
|
||||
});
|
||||
this.allButtons.forEach(b => b.classList.remove('selected'));
|
||||
|
||||
const contents = this.tabContentsByName[key] || [];
|
||||
const buttons = this.tabButtonsByName[key] || [];
|
||||
if (contents.length > 0) {
|
||||
contents.forEach(c => {
|
||||
c.classList.remove('hidden')
|
||||
c.classList.add('selected')
|
||||
});
|
||||
buttons.forEach(b => b.classList.add('selected'));
|
||||
show(sectionId) {
|
||||
for (const panel of this.panels) {
|
||||
panel.toggleAttribute('hidden', panel.id !== sectionId);
|
||||
}
|
||||
|
||||
for (const tab of this.tabs) {
|
||||
const tabSection = tab.getAttribute('aria-controls');
|
||||
const selected = tabSection === sectionId;
|
||||
tab.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
}
|
||||
|
||||
this.$emit('change', {showing: sectionId});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,10 +10,11 @@ export class TagManager extends Component {
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
this.container.addEventListener('change', event => {
|
||||
this.container.addEventListener('input', event => {
|
||||
|
||||
/** @var {AddRemoveRows} **/
|
||||
const addRemoveComponent = window.$components.firstOnElement(this.addRemoveComponentEl, 'add-remove-rows');
|
||||
if (!this.hasEmptyRows()) {
|
||||
if (!this.hasEmptyRows() && event.target.value) {
|
||||
addRemoveComponent.add();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,9 +8,6 @@ export class UserSelect extends Component {
|
||||
this.input = this.$refs.input;
|
||||
this.userInfoContainer = this.$refs.userInfo;
|
||||
|
||||
this.initialValue = this.input.value;
|
||||
this.initialContent = this.userInfoContainer.innerHTML;
|
||||
|
||||
onChildEvent(this.container, 'a.dropdown-search-item', 'click', this.selectUser.bind(this));
|
||||
}
|
||||
|
||||
@@ -22,13 +19,6 @@ export class UserSelect extends Component {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.input.value = this.initialValue;
|
||||
this.userInfoContainer.innerHTML = this.initialContent;
|
||||
this.input.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
this.hide();
|
||||
}
|
||||
|
||||
hide() {
|
||||
/** @var {Dropdown} **/
|
||||
const dropdown = window.$components.firstOnElement(this.container, 'dropdown');
|
||||
|
||||
@@ -95,8 +95,16 @@ async function upload(imageData, pageUploadedToId) {
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function load(drawingId) {
|
||||
const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`));
|
||||
return `data:image/png;base64,${resp.data.content}`;
|
||||
try {
|
||||
const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`));
|
||||
return `data:image/png;base64,${resp.data.content}`;
|
||||
} catch (error) {
|
||||
if (error instanceof window.$http.HttpError) {
|
||||
window.$events.showResponseError(error);
|
||||
}
|
||||
close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default {show, close, upload, load};
|
||||
@@ -43,10 +43,8 @@ function emitPublic(targetElement, eventName, eventData) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify of a http error.
|
||||
* Check for standard scenarios such as validation errors and
|
||||
* formats an error notification accordingly.
|
||||
* @param {Error} error
|
||||
* Notify of standard server-provided validation errors.
|
||||
* @param {Object} error
|
||||
*/
|
||||
function showValidationErrors(error) {
|
||||
if (!error.status) return;
|
||||
@@ -56,6 +54,17 @@ function showValidationErrors(error) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify standard server-provided error messages.
|
||||
* @param {Object} error
|
||||
*/
|
||||
function showResponseError(error) {
|
||||
if (!error.status) return;
|
||||
if (error.status >= 400 && error.data && error.data.message) {
|
||||
emit('error', error.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
emit,
|
||||
emitPublic,
|
||||
@@ -63,4 +72,5 @@ export default {
|
||||
success: (msg) => emit('success', msg),
|
||||
error: (msg) => emit('error', msg),
|
||||
showValidationErrors,
|
||||
showResponseError,
|
||||
}
|
||||
@@ -132,7 +132,7 @@ async function request(url, options = {}) {
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
throw returnData;
|
||||
throw new HttpError(response, content);
|
||||
}
|
||||
|
||||
return returnData;
|
||||
@@ -159,10 +159,24 @@ async function getResponseContent(response) {
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(response, content) {
|
||||
super(response.statusText);
|
||||
this.data = content;
|
||||
this.headers = response.headers;
|
||||
this.redirected = response.redirected;
|
||||
this.status = response.status;
|
||||
this.statusText = response.statusText;
|
||||
this.url = response.url;
|
||||
this.original = response;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
get: get,
|
||||
post: post,
|
||||
put: put,
|
||||
patch: patch,
|
||||
delete: performDelete,
|
||||
HttpError: HttpError,
|
||||
};
|
||||
@@ -57,6 +57,12 @@ export class KeyboardNavigationHandler {
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#keydownHandler(event) {
|
||||
|
||||
// Ignore certain key events in inputs to allow text editing.
|
||||
if (event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
this.focusNext();
|
||||
event.preventDefault();
|
||||
@@ -80,7 +86,7 @@ export class KeyboardNavigationHandler {
|
||||
*/
|
||||
#getFocusable() {
|
||||
const focusable = [];
|
||||
const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"]),input:not([type=hidden])';
|
||||
const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])';
|
||||
for (const container of this.containers) {
|
||||
focusable.push(...container.querySelectorAll(selector))
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function scrollAndHighlightElement(element) {
|
||||
if (!element) return;
|
||||
element.scrollIntoView({behavior: 'smooth'});
|
||||
|
||||
const color = document.getElementById('custom-styles').getAttribute('data-color-light');
|
||||
const color = getComputedStyle(document.body).getPropertyValue('--color-primary-light');
|
||||
const initColor = window.getComputedStyle(element).getPropertyValue('background-color');
|
||||
element.style.backgroundColor = color;
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -89,7 +89,7 @@ function drawingInit() {
|
||||
return Promise.resolve('');
|
||||
}
|
||||
|
||||
let drawingId = currentNode.getAttribute('drawio-diagram');
|
||||
const drawingId = currentNode.getAttribute('drawio-diagram');
|
||||
return DrawIO.load(drawingId);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user