mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-07 11:19:38 +03:00
Compare commits
475 Commits
docker_env
...
v25.11.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbda5fd468 | ||
|
|
8429cc93eb | ||
|
|
47f12cc8f6 | ||
|
|
b2f81f5c62 | ||
|
|
1be2969055 | ||
|
|
99a1d82f0a | ||
|
|
f06a6de2e7 | ||
|
|
aaa28186bc | ||
|
|
fef61f054a | ||
|
|
8082c95ec3 | ||
|
|
8ab9252f9b | ||
|
|
befc645705 | ||
|
|
4eb4407ef7 | ||
|
|
fcabf478de | ||
|
|
8de2c28497 | ||
|
|
5bf2d801cf | ||
|
|
1421ba871d | ||
|
|
563828ba52 | ||
|
|
d40a68b411 | ||
|
|
4a57933cd1 | ||
|
|
1df850ea3e | ||
|
|
7881bddce0 | ||
|
|
02d024aa32 | ||
|
|
652124abaf | ||
|
|
751934c84a | ||
|
|
3fd25bd03e | ||
|
|
f0303de2e5 | ||
|
|
0b26573314 | ||
|
|
c21c36e2a6 | ||
|
|
a949900570 | ||
|
|
9c4a9225af | ||
|
|
4627dfd4f7 | ||
|
|
fcacf7cacb | ||
|
|
cbf27d70c8 | ||
|
|
3ad1e31fcc | ||
|
|
082dbc9944 | ||
|
|
abe9c1e5a3 | ||
|
|
ebf82617b8 | ||
|
|
2c81447c9e | ||
|
|
8898647f78 | ||
|
|
ea6344898f | ||
|
|
0bfd79925e | ||
|
|
efff8700d4 | ||
|
|
5754acf2fb | ||
|
|
4c7d6420ee | ||
|
|
0838d5ea16 | ||
|
|
449ac40114 | ||
|
|
3131050acd | ||
|
|
c0d2874892 | ||
|
|
5940a91809 | ||
|
|
9a4651badb | ||
|
|
92d15d9cf2 | ||
|
|
b06147fef7 | ||
|
|
841350a937 | ||
|
|
12183bac07 | ||
|
|
e65b4b63a2 | ||
|
|
7cac3f4780 | ||
|
|
92cd11d105 | ||
|
|
13115ace84 | ||
|
|
73f9834e6f | ||
|
|
3afe855156 | ||
|
|
bfde896f0b | ||
|
|
1cdc0a7a3d | ||
|
|
d19b86640b | ||
|
|
2936ba609b | ||
|
|
573a2dd22a | ||
|
|
b55cc803d3 | ||
|
|
304ade418e | ||
|
|
997931c42f | ||
|
|
268e353431 | ||
|
|
b491b5fbca | ||
|
|
387c786768 | ||
|
|
2641586a6f | ||
|
|
6d2cd20e80 | ||
|
|
b0c574356a | ||
|
|
07e45a20e5 | ||
|
|
14056c69e6 | ||
|
|
fb9c840c46 | ||
|
|
5fba4a5399 | ||
|
|
c0b377050e | ||
|
|
f3efb6441d | ||
|
|
0cf313a21e | ||
|
|
26aadffb20 | ||
|
|
a5f48e3202 | ||
|
|
b0dda6e6a7 | ||
|
|
d4025d95e7 | ||
|
|
d6021f4d22 | ||
|
|
b9a3290731 | ||
|
|
48f235ea5a | ||
|
|
047771b9f4 | ||
|
|
b5375114d3 | ||
|
|
fc13e56cea | ||
|
|
77fc37ac25 | ||
|
|
3424351e84 | ||
|
|
606f9d92d0 | ||
|
|
a5e25abb9c | ||
|
|
b310e87e4c | ||
|
|
425baf9d6e | ||
|
|
825c369ad9 | ||
|
|
10bab70438 | ||
|
|
350e0b281b | ||
|
|
08805ea3c8 | ||
|
|
9441e32c69 | ||
|
|
530fc37067 | ||
|
|
369e499dce | ||
|
|
655815de6d | ||
|
|
457adc1fee | ||
|
|
e86a90967e | ||
|
|
5d08f7cf14 | ||
|
|
8744eb2d62 | ||
|
|
d8383cfa80 | ||
|
|
4626278447 | ||
|
|
c61af9c22b | ||
|
|
72521d0906 | ||
|
|
7e44b195c5 | ||
|
|
5b45eac5e1 | ||
|
|
c1d30341e7 | ||
|
|
80d2b4913b | ||
|
|
3f473528b1 | ||
|
|
d0dcd4f61b | ||
|
|
bde66a1396 | ||
|
|
4de5a2d9bf | ||
|
|
27bf4299cf | ||
|
|
164f01bb25 | ||
|
|
f563a005f5 | ||
|
|
a14d8e30cc | ||
|
|
a9194ffb63 | ||
|
|
2f9c1b7127 | ||
|
|
bbea76668b | ||
|
|
becc630acf | ||
|
|
4ac8ecad6b | ||
|
|
903e88c700 | ||
|
|
ed96aa820e | ||
|
|
63ec079b7b | ||
|
|
d485fcb3db | ||
|
|
0f895668a4 | ||
|
|
6c577ac3bf | ||
|
|
31cc2423d2 | ||
|
|
c9ed32e518 | ||
|
|
6b4c3a0969 | ||
|
|
2dad92d1bd | ||
|
|
c1fb7ab7dc | ||
|
|
98315f3899 | ||
|
|
8c82aaabd6 | ||
|
|
ce9b536b78 | ||
|
|
d9c50e5bc1 | ||
|
|
bf075f7dd8 | ||
|
|
a4fd673285 | ||
|
|
e794c977bc | ||
|
|
0b088ef1d3 | ||
|
|
bf6a6af683 | ||
|
|
914790fd99 | ||
|
|
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 |
7
.github/translators.txt
vendored
7
.github/translators.txt
vendored
@@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
|
||||
Rem (Rem9000) :: Dutch
|
||||
Michał Stelmach (stelmach-web) :: Polish
|
||||
arniom :: French
|
||||
REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
|
||||
REMOVED_USER :: French; German; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
|
||||
林祖年 (contagion) :: Chinese Traditional
|
||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||
@@ -222,7 +222,7 @@ SmokingCrop :: Dutch
|
||||
Maciej Lebiest (Szwendacz) :: Polish
|
||||
DiscordDigital :: German; German Informal
|
||||
Gábor Marton (dodver) :: Hungarian
|
||||
Jasell :: Swedish
|
||||
Jakob Åsell (Jasell) :: Swedish
|
||||
Ghost_chu (ghostchu) :: Chinese Simplified
|
||||
Ravid Shachar (ravidshachar) :: Hebrew
|
||||
Helga Guchshenskaya (guchshenskaya) :: Russian
|
||||
@@ -509,3 +509,6 @@ iamwhoiamwhoami :: Swedish
|
||||
Grogui :: French
|
||||
MrCharlesIII :: Arabic
|
||||
David Olsen (dawin) :: Danish
|
||||
ltnzr :: French
|
||||
Frank Holler (holler.frank) :: German; German Informal
|
||||
Korab Arifi (korabidev) :: Albanian
|
||||
|
||||
2
.github/workflows/test-migrations.yml
vendored
2
.github/workflows/test-migrations.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.2', '8.3', '8.4']
|
||||
php: ['8.2', '8.3', '8.4', '8.5']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
2
.github/workflows/test-php.yml
vendored
2
.github/workflows/test-php.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.2', '8.3', '8.4']
|
||||
php: ['8.2', '8.3', '8.4', '8.5']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,10 +8,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/
|
||||
/public/favicon.ico
|
||||
|
||||
@@ -11,7 +11,6 @@ class MfaSession
|
||||
*/
|
||||
public function isRequiredForUser(User $user): bool
|
||||
{
|
||||
// TODO - Test both these cases
|
||||
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace BookStack\Access\Mfa;
|
||||
|
||||
use BookStack\Users\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class MfaValue extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected static $unguarded = true;
|
||||
|
||||
const METHOD_TOTP = 'totp';
|
||||
|
||||
@@ -5,18 +5,23 @@ namespace BookStack\Access;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Class SocialAccount.
|
||||
*
|
||||
* @property string $driver
|
||||
* @property User $user
|
||||
*/
|
||||
class SocialAccount extends Model implements Loggable
|
||||
{
|
||||
protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
|
||||
use HasFactory;
|
||||
|
||||
public function user()
|
||||
protected $fillable = ['user_id', 'driver', 'driver_id'];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ namespace BookStack\Activity;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Exceptions\PrettyException;
|
||||
use BookStack\Facades\Activity as ActivityService;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class CommentRepo
|
||||
{
|
||||
@@ -19,11 +20,46 @@ class CommentRepo
|
||||
return Comment::query()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a comment by ID, ensuring it is visible to the user based upon access to the page
|
||||
* which the comment is attached to.
|
||||
*/
|
||||
public function getVisibleById(int $id): Comment
|
||||
{
|
||||
return $this->getQueryForVisible()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query for comments visible to the user.
|
||||
* @return Builder<Comment>
|
||||
*/
|
||||
public function getQueryForVisible(): Builder
|
||||
{
|
||||
return Comment::query()->scopes('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment on an entity.
|
||||
*/
|
||||
public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
|
||||
{
|
||||
// Prevent comments being added to draft pages
|
||||
if ($entity instanceof Page && $entity->draft) {
|
||||
throw new \Exception(trans('errors.cannot_add_comment_to_draft'));
|
||||
}
|
||||
|
||||
// Validate parent ID
|
||||
if ($parentId !== null) {
|
||||
$parentCommentExists = Comment::query()
|
||||
->where('commentable_id', '=', $entity->id)
|
||||
->where('commentable_type', '=', $entity->getMorphClass())
|
||||
->where('local_id', '=', $parentId)
|
||||
->exists();
|
||||
if (!$parentCommentExists) {
|
||||
$parentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
$userId = user()->id;
|
||||
$comment = new Comment();
|
||||
|
||||
@@ -38,6 +74,7 @@ class CommentRepo
|
||||
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
|
||||
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
||||
|
||||
$comment->refresh()->unsetRelations();
|
||||
return $comment;
|
||||
}
|
||||
|
||||
@@ -59,7 +96,7 @@ class CommentRepo
|
||||
/**
|
||||
* Archive an existing comment.
|
||||
*/
|
||||
public function archive(Comment $comment): Comment
|
||||
public function archive(Comment $comment, bool $log = true): Comment
|
||||
{
|
||||
if ($comment->parent_id) {
|
||||
throw new NotifyException('Only top-level comments can be archived.', '/', 400);
|
||||
@@ -68,7 +105,9 @@ class CommentRepo
|
||||
$comment->archived = true;
|
||||
$comment->save();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
if ($log) {
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
}
|
||||
|
||||
return $comment;
|
||||
}
|
||||
@@ -76,7 +115,7 @@ class CommentRepo
|
||||
/**
|
||||
* Un-archive an existing comment.
|
||||
*/
|
||||
public function unarchive(Comment $comment): Comment
|
||||
public function unarchive(Comment $comment, bool $log = true): Comment
|
||||
{
|
||||
if ($comment->parent_id) {
|
||||
throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
|
||||
@@ -85,7 +124,9 @@ class CommentRepo
|
||||
$comment->archived = false;
|
||||
$comment->save();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
if ($log) {
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
}
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
148
app/Activity/Controllers/CommentApiController.php
Normal file
148
app/Activity/Controllers/CommentApiController.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* The comment data model has a 'local_id' property, which is a unique integer ID
|
||||
* scoped to the page which the comment is on. The 'parent_id' is used for replies
|
||||
* and refers to the 'local_id' of the parent comment on the same page, not the main
|
||||
* globally unique 'id'.
|
||||
*
|
||||
* If you want to get all comments for a page in a tree-like structure, as reflected in
|
||||
* the UI, then that is provided on pages-read API responses.
|
||||
*/
|
||||
class CommentApiController extends ApiController
|
||||
{
|
||||
protected array $rules = [
|
||||
'create' => [
|
||||
'page_id' => ['required', 'integer'],
|
||||
'reply_to' => ['nullable', 'integer'],
|
||||
'html' => ['required', 'string'],
|
||||
'content_ref' => ['string'],
|
||||
],
|
||||
'update' => [
|
||||
'html' => ['string'],
|
||||
'archived' => ['boolean'],
|
||||
]
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected CommentRepo $commentRepo,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a listing of comments visible to the user.
|
||||
*/
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
$query = $this->commentRepo->getQueryForVisible();
|
||||
|
||||
return $this->apiListingResponse($query, [
|
||||
'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment on a page.
|
||||
* If commenting as a reply to an existing comment, the 'reply_to' parameter
|
||||
* should be provided, set to the 'local_id' of the comment being replied to.
|
||||
*/
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$this->checkPermission(Permission::CommentCreateAll);
|
||||
|
||||
$input = $this->validate($request, $this->rules()['create']);
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']);
|
||||
|
||||
$comment = $this->commentRepo->create(
|
||||
$page,
|
||||
$input['html'],
|
||||
$input['reply_to'] ?? null,
|
||||
$input['content_ref'] ?? '',
|
||||
);
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the details of a single comment, along with its direct replies.
|
||||
*/
|
||||
public function read(string $id): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||
$comment->load('createdBy', 'updatedBy');
|
||||
|
||||
$replies = $this->commentRepo->getQueryForVisible()
|
||||
->where('parent_id', '=', $comment->local_id)
|
||||
->where('commentable_id', '=', $comment->commentable_id)
|
||||
->where('commentable_type', '=', $comment->commentable_type)
|
||||
->get();
|
||||
|
||||
/** @var Comment[] $toProcess */
|
||||
$toProcess = [$comment, ...$replies];
|
||||
foreach ($toProcess as $commentToProcess) {
|
||||
$commentToProcess->setAttribute('html', $commentToProcess->safeHtml());
|
||||
$commentToProcess->makeVisible('html');
|
||||
}
|
||||
|
||||
$comment->setRelation('replies', $replies);
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the content or archived status of an existing comment.
|
||||
*
|
||||
* Only provide a new archived status if needing to actively change the archive state.
|
||||
* Only top-level comments (non-replies) can be archived or unarchived.
|
||||
*/
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
|
||||
|
||||
$input = $this->validate($request, $this->rules()['update']);
|
||||
$hasHtml = isset($input['html']);
|
||||
|
||||
if (isset($input['archived'])) {
|
||||
if ($input['archived']) {
|
||||
$this->commentRepo->archive($comment, !$hasHtml);
|
||||
} else {
|
||||
$this->commentRepo->unarchive($comment, !$hasHtml);
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasHtml) {
|
||||
$comment = $this->commentRepo->update($comment, $input['html']);
|
||||
}
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single comment from the system.
|
||||
*/
|
||||
public function delete(string $id): Response
|
||||
{
|
||||
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
|
||||
|
||||
$this->commentRepo->delete($comment);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class CommentController extends Controller
|
||||
/**
|
||||
* Save a new comment for a Page.
|
||||
*
|
||||
* @throws ValidationException
|
||||
* @throws ValidationException|\Exception
|
||||
*/
|
||||
public function savePageComment(Request $request, int $pageId)
|
||||
{
|
||||
@@ -37,11 +37,6 @@ class CommentController extends Controller
|
||||
return response('Not found', 404);
|
||||
}
|
||||
|
||||
// Prevent adding comments to draft pages
|
||||
if ($page->draft) {
|
||||
return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
|
||||
}
|
||||
|
||||
// Create a new comment.
|
||||
$this->checkPermission(Permission::CommentCreateAll);
|
||||
$contentRef = $input['content_ref'] ?? '';
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
@@ -24,6 +25,8 @@ use Illuminate\Support\Str;
|
||||
*/
|
||||
class Activity extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Get the loggable model related to this activity.
|
||||
* Currently only used for entities (previously entity_[id/type] columns).
|
||||
|
||||
@@ -3,22 +3,24 @@
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Users\Models\OwnableInterface;
|
||||
use BookStack\Users\Models\User;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $text - Deprecated & now unused (#4821)
|
||||
* @property string $html
|
||||
* @property int|null $parent_id - Relates to local_id, not id
|
||||
* @property int $local_id
|
||||
* @property string $entity_type
|
||||
* @property int $entity_id
|
||||
* @property string $commentable_type
|
||||
* @property int $commentable_id
|
||||
* @property string $content_ref
|
||||
* @property bool $archived
|
||||
*/
|
||||
@@ -28,13 +30,18 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
use HasCreatorAndUpdater;
|
||||
|
||||
protected $fillable = ['parent_id'];
|
||||
protected $hidden = ['html'];
|
||||
|
||||
protected $casts = [
|
||||
'archived' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the entity that this comment belongs to.
|
||||
*/
|
||||
public function entity(): MorphTo
|
||||
{
|
||||
return $this->morphTo('entity');
|
||||
return $this->morphTo('commentable');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,8 +51,8 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
|
||||
->where('entity_type', '=', $this->entity_type)
|
||||
->where('entity_id', '=', $this->entity_id);
|
||||
->where('commentable_type', '=', $this->commentable_type)
|
||||
->where('commentable_id', '=', $this->commentable_id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,11 +65,27 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
|
||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})";
|
||||
}
|
||||
|
||||
public function safeHtml(): string
|
||||
{
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id')
|
||||
->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope the query to just the comments visible to the user based upon the
|
||||
* user visibility of what has been commented on.
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return app()->make(PermissionApplicator::class)
|
||||
->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class Favourite extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['user_id'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Activity\Models;
|
||||
use BookStack\Activity\WatchLevels;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
@@ -20,6 +21,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
*/
|
||||
class Watch extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public function watchable(): MorphTo
|
||||
|
||||
@@ -27,7 +27,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
|
||||
$watcherIds = $watchers->getWatcherUserIds();
|
||||
|
||||
// Page owner if user preferences allow
|
||||
if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
|
||||
if ($page->owned_by && !$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
|
||||
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
|
||||
if ($userNotificationPrefs->notifyOnOwnPageComments()) {
|
||||
$watcherIds[] = $page->owned_by;
|
||||
@@ -36,7 +36,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
|
||||
|
||||
// Parent comment creator if preferences allow
|
||||
$parentComment = $detail->parent()->first();
|
||||
if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
|
||||
if ($parentComment && $parentComment->created_by && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
|
||||
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
|
||||
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
|
||||
$watcherIds[] = $parentComment->created_by;
|
||||
|
||||
@@ -39,8 +39,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
|
||||
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
|
||||
$watcherIds = $watchers->getWatcherUserIds();
|
||||
|
||||
// Add page owner if preferences allow
|
||||
if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
|
||||
// Add the page owner if preferences allow
|
||||
if ($detail->owned_by && !$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
|
||||
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
|
||||
if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
|
||||
$watcherIds[] = $detail->owned_by;
|
||||
|
||||
@@ -13,6 +13,11 @@ class CommentTree
|
||||
* @var CommentTreeNode[]
|
||||
*/
|
||||
protected array $tree;
|
||||
|
||||
/**
|
||||
* A linear array of loaded comments.
|
||||
* @var Comment[]
|
||||
*/
|
||||
protected array $comments;
|
||||
|
||||
public function __construct(
|
||||
@@ -39,7 +44,7 @@ class CommentTree
|
||||
|
||||
public function getActive(): array
|
||||
{
|
||||
return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
|
||||
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived));
|
||||
}
|
||||
|
||||
public function activeThreadCount(): int
|
||||
@@ -49,7 +54,7 @@ class CommentTree
|
||||
|
||||
public function getArchived(): array
|
||||
{
|
||||
return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
|
||||
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived));
|
||||
}
|
||||
|
||||
public function archivedThreadCount(): int
|
||||
@@ -79,6 +84,14 @@ class CommentTree
|
||||
return false;
|
||||
}
|
||||
|
||||
public function loadVisibleHtml(): void
|
||||
{
|
||||
foreach ($this->comments as $comment) {
|
||||
$comment->setAttribute('html', $comment->safeHtml());
|
||||
$comment->makeVisible('html');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Comment[] $comments
|
||||
* @return CommentTreeNode[]
|
||||
@@ -123,6 +136,9 @@ class CommentTree
|
||||
return new CommentTreeNode($byId[$id], $depth, $children);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Comment[]
|
||||
*/
|
||||
protected function loadComments(): array
|
||||
{
|
||||
if (!$this->enabled()) {
|
||||
|
||||
@@ -83,11 +83,19 @@ class ApiDocsGenerator
|
||||
protected function loadDetailsFromControllers(Collection $routes): Collection
|
||||
{
|
||||
return $routes->map(function (array $route) {
|
||||
$class = $this->getReflectionClass($route['controller']);
|
||||
$method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
|
||||
$comment = $method->getDocComment();
|
||||
$route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
|
||||
$route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null;
|
||||
$route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
|
||||
|
||||
// Load class description for the model
|
||||
// Not ideal to have it here on each route, but adding it in a more structured manner would break
|
||||
// docs resulting JSON format and therefore be an API break.
|
||||
// Save refactoring for a more significant set of changes.
|
||||
$classComment = $class->getDocComment();
|
||||
$route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null;
|
||||
|
||||
return $route;
|
||||
});
|
||||
}
|
||||
@@ -140,7 +148,7 @@ class ApiDocsGenerator
|
||||
/**
|
||||
* Parse out the description text from a class method comment.
|
||||
*/
|
||||
protected function parseDescriptionFromMethodComment(string $comment): string
|
||||
protected function parseDescriptionFromDocBlockComment(string $comment): string
|
||||
{
|
||||
$matches = [];
|
||||
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
|
||||
@@ -155,6 +163,16 @@ class ApiDocsGenerator
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
|
||||
{
|
||||
return $this->getReflectionClass($className)->getMethod($methodName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reflection class from the given class name.
|
||||
*
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
protected function getReflectionClass(string $className): ReflectionClass
|
||||
{
|
||||
$class = $this->reflectionClasses[$className] ?? null;
|
||||
if ($class === null) {
|
||||
@@ -162,7 +180,7 @@ class ApiDocsGenerator
|
||||
$this->reflectionClasses[$className] = $class;
|
||||
}
|
||||
|
||||
return $class->getMethod($methodName);
|
||||
return $class;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
return [
|
||||
|
||||
// Default Filesystem Disk
|
||||
// Options: local, local_secure, s3
|
||||
// Options: local, local_secure, local_secure_restricted, s3
|
||||
'default' => env('STORAGE_TYPE', 'local'),
|
||||
|
||||
// Filesystem to use specifically for image uploads.
|
||||
|
||||
@@ -45,10 +45,8 @@ class UpdateUrlCommand extends Command
|
||||
|
||||
$columnsToUpdateByTable = [
|
||||
'attachments' => ['path'],
|
||||
'pages' => ['html', 'text', 'markdown'],
|
||||
'chapters' => ['description_html'],
|
||||
'books' => ['description_html'],
|
||||
'bookshelves' => ['description_html'],
|
||||
'entity_page_data' => ['html', 'text', 'markdown'],
|
||||
'entity_container_data' => ['description_html'],
|
||||
'page_revisions' => ['html', 'text', 'markdown'],
|
||||
'images' => ['url'],
|
||||
'settings' => ['value'],
|
||||
|
||||
@@ -58,7 +58,7 @@ class BookApiController extends ApiController
|
||||
|
||||
/**
|
||||
* View the details of a single book.
|
||||
* The response data will contain 'content' property listing the chapter and pages directly within, in
|
||||
* The response data will contain a 'content' property listing the chapter and pages directly within, in
|
||||
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
|
||||
* contents will have a 'type' property to distinguish between pages & chapters.
|
||||
*/
|
||||
@@ -122,9 +122,10 @@ class BookApiController extends ApiController
|
||||
$book = clone $book;
|
||||
$book->unsetRelations()->refresh();
|
||||
|
||||
$book->load(['tags', 'cover']);
|
||||
$book->makeVisible('description_html')
|
||||
->setAttribute('description_html', $book->descriptionHtml());
|
||||
$book->load(['tags']);
|
||||
$book->makeVisible(['cover', 'description_html'])
|
||||
->setAttribute('description_html', $book->descriptionInfo()->getHtml())
|
||||
->setAttribute('cover', $book->coverInfo()->getImage());
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
@@ -116,9 +116,10 @@ class BookshelfApiController extends ApiController
|
||||
$shelf = clone $shelf;
|
||||
$shelf->unsetRelations()->refresh();
|
||||
|
||||
$shelf->load(['tags', 'cover']);
|
||||
$shelf->makeVisible('description_html')
|
||||
->setAttribute('description_html', $shelf->descriptionHtml());
|
||||
$shelf->load(['tags']);
|
||||
$shelf->makeVisible(['cover', 'description_html'])
|
||||
->setAttribute('description_html', $shelf->descriptionInfo()->getHtml())
|
||||
->setAttribute('cover', $shelf->coverInfo()->getImage());
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ class BookshelfController extends Controller
|
||||
]);
|
||||
|
||||
$sort = $listOptions->getSort();
|
||||
|
||||
$sortedVisibleShelfBooks = $shelf->visibleBooks()
|
||||
->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
|
||||
->get()
|
||||
|
||||
@@ -104,7 +104,7 @@ class ChapterApiController extends ApiController
|
||||
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
|
||||
|
||||
if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
|
||||
if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) {
|
||||
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
|
||||
|
||||
try {
|
||||
@@ -144,7 +144,7 @@ class ChapterApiController extends ApiController
|
||||
|
||||
$chapter->load(['tags']);
|
||||
$chapter->makeVisible('description_html');
|
||||
$chapter->setAttribute('description_html', $chapter->descriptionHtml());
|
||||
$chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml());
|
||||
|
||||
/** @var Book $book */
|
||||
$book = $chapter->book()->first();
|
||||
|
||||
@@ -130,7 +130,7 @@ class ChapterController extends Controller
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
|
||||
|
||||
$this->chapterRepo->update($chapter, $validated);
|
||||
$chapter = $this->chapterRepo->update($chapter, $validated);
|
||||
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\Tools\CommentTree;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
@@ -88,21 +89,32 @@ class PageApiController extends ApiController
|
||||
/**
|
||||
* View the details of a single page.
|
||||
* Pages will always have HTML content. They may have markdown content
|
||||
* if the markdown editor was used to last update the page.
|
||||
* if the Markdown editor was used to last update the page.
|
||||
*
|
||||
* The 'html' property is the fully rendered & escaped HTML content that BookStack
|
||||
* The 'html' property is the fully rendered and escaped HTML content that BookStack
|
||||
* would show on page view, with page includes handled.
|
||||
* The 'raw_html' property is the direct database stored HTML content, which would be
|
||||
* what BookStack shows on page edit.
|
||||
*
|
||||
* See the "Content Security" section of these docs for security considerations when using
|
||||
* the page content returned from this endpoint.
|
||||
*
|
||||
* Comments for the page are provided in a tree-structure representing the hierarchy of top-level
|
||||
* comments and replies, for both archived and active comments.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
|
||||
return response()->json($page->forJsonDisplay());
|
||||
$page = $page->forJsonDisplay();
|
||||
$commentTree = (new CommentTree($page));
|
||||
$commentTree->loadVisibleHtml();
|
||||
$page->setAttribute('comments', [
|
||||
'active' => $commentTree->getActive(),
|
||||
'archived' => $commentTree->getArchived(),
|
||||
]);
|
||||
|
||||
return response()->json($page);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -120,6 +120,7 @@ class PageController extends Controller
|
||||
$this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$draftPage = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent());
|
||||
|
||||
|
||||
20
app/Entities/EntityExistsRule.php
Normal file
20
app/Entities/EntityExistsRule.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities;
|
||||
|
||||
use Illuminate\Validation\Rules\Exists;
|
||||
|
||||
class EntityExistsRule implements \Stringable
|
||||
{
|
||||
public function __construct(
|
||||
protected string $type,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
$existsRule = (new Exists('entities', 'id'))
|
||||
->where('type', $this->type);
|
||||
return $existsRule->__toString();
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityCover;
|
||||
use BookStack\Entities\Tools\EntityDefaultTemplate;
|
||||
use BookStack\Sorting\SortRule;
|
||||
use BookStack\Uploads\Image;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
@@ -15,26 +16,25 @@ use Illuminate\Support\Collection;
|
||||
* Class Book.
|
||||
*
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
* @property int $image_id
|
||||
* @property ?int $default_template_id
|
||||
* @property ?int $sort_rule_id
|
||||
* @property Image|null $cover
|
||||
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
||||
* @property \Illuminate\Database\Eloquent\Collection $pages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $directPages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $shelves
|
||||
* @property ?Page $defaultTemplate
|
||||
* @property ?SortRule $sortRule
|
||||
* @property ?SortRule $sortRule
|
||||
*/
|
||||
class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterface
|
||||
class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use HtmlDescriptionTrait;
|
||||
use ContainerTrait;
|
||||
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $hidden = ['pivot', 'deleted_at', 'description_html', 'entity_id', 'entity_type', 'chapter_id', 'book_id', 'priority'];
|
||||
protected $fillable = ['name'];
|
||||
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
|
||||
|
||||
/**
|
||||
* Get the url for this book.
|
||||
@@ -44,55 +44,6 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
|
||||
return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns book cover image, if book cover not exists return default cover image.
|
||||
*/
|
||||
public function getBookCover(int $width = 440, int $height = 250): string
|
||||
{
|
||||
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
||||
if (!$this->image_id || !$this->cover) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->cover->getThumb($width, $height, false) ?? $default;
|
||||
} catch (Exception $err) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cover image of the book.
|
||||
*/
|
||||
public function cover(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of the image model that is used when storing a cover image.
|
||||
*/
|
||||
public function coverImageTypeKey(): string
|
||||
{
|
||||
return 'cover_book';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Page that is used as default template for newly created pages within this Book.
|
||||
*/
|
||||
public function defaultTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'default_template_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sort set assigned to this book, if existing.
|
||||
*/
|
||||
public function sortRule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SortRule::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pages within this book.
|
||||
* @return HasMany<Page, $this>
|
||||
@@ -107,7 +58,7 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
|
||||
*/
|
||||
public function directPages(): HasMany
|
||||
{
|
||||
return $this->pages()->where('chapter_id', '=', '0');
|
||||
return $this->pages()->whereNull('chapter_id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,4 +88,27 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
|
||||
|
||||
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
|
||||
}
|
||||
|
||||
public function defaultTemplate(): EntityDefaultTemplate
|
||||
{
|
||||
return new EntityDefaultTemplate($this);
|
||||
}
|
||||
|
||||
public function cover(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
|
||||
public function coverInfo(): EntityCover
|
||||
{
|
||||
return new EntityCover($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sort rule assigned to this container, if existing.
|
||||
*/
|
||||
public function sortRule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SortRule::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
@@ -27,13 +26,13 @@ abstract class BookChild extends Entity
|
||||
/**
|
||||
* Change the book that this entity belongs to.
|
||||
*/
|
||||
public function changeBook(int $newBookId): Entity
|
||||
public function changeBook(int $newBookId): self
|
||||
{
|
||||
$oldUrl = $this->getUrl();
|
||||
$this->book_id = $newBookId;
|
||||
$this->unsetRelation('book');
|
||||
$this->refreshSlug();
|
||||
$this->save();
|
||||
$this->refresh();
|
||||
|
||||
if ($oldUrl !== $this->getUrl()) {
|
||||
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
|
||||
|
||||
@@ -2,34 +2,34 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityCover;
|
||||
use BookStack\Uploads\Image;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionInterface
|
||||
/**
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
*/
|
||||
class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use HtmlDescriptionTrait;
|
||||
|
||||
protected $table = 'bookshelves';
|
||||
use ContainerTrait;
|
||||
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'image_id'];
|
||||
|
||||
protected $hidden = ['image_id', 'deleted_at', 'description_html'];
|
||||
protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
|
||||
protected $fillable = ['name'];
|
||||
|
||||
/**
|
||||
* Get the books in this shelf.
|
||||
* Should not be used directly since does not take into account permissions.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
* Should not be used directly since it does not take into account permissions.
|
||||
*/
|
||||
public function books()
|
||||
public function books(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id')
|
||||
->select(['entities.*', 'entity_container_data.*'])
|
||||
->withPivot('order')
|
||||
->orderBy('order', 'asc');
|
||||
}
|
||||
@@ -50,41 +50,6 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
|
||||
return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns shelf cover image, if cover not exists return default cover image.
|
||||
*/
|
||||
public function getBookCover(int $width = 440, int $height = 250): string
|
||||
{
|
||||
// TODO - Make generic, focused on books right now, Perhaps set-up a better image
|
||||
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
||||
if (!$this->image_id || !$this->cover) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->cover->getThumb($width, $height, false) ?? $default;
|
||||
} catch (Exception $err) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cover image of the shelf.
|
||||
* @return BelongsTo<Image, $this>
|
||||
*/
|
||||
public function cover(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of the image model that is used when storing a cover image.
|
||||
*/
|
||||
public function coverImageTypeKey(): string
|
||||
{
|
||||
return 'cover_bookshelf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this shelf contains the given book.
|
||||
*/
|
||||
@@ -96,7 +61,7 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
|
||||
/**
|
||||
* Add a book to the end of this shelf.
|
||||
*/
|
||||
public function appendBook(Book $book)
|
||||
public function appendBook(Book $book): void
|
||||
{
|
||||
if ($this->contains($book)) {
|
||||
return;
|
||||
@@ -106,12 +71,13 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
|
||||
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible shelf by its slug.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlug(string $slug): self
|
||||
public function coverInfo(): EntityCover
|
||||
{
|
||||
return static::visible()->where('slug', '=', $slug)->firstOrFail();
|
||||
return new EntityCover($this);
|
||||
}
|
||||
|
||||
public function cover(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,27 +2,25 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use BookStack\Entities\Tools\EntityDefaultTemplate;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Class Chapter.
|
||||
*
|
||||
* @property Collection<Page> $pages
|
||||
* @property ?int $default_template_id
|
||||
* @property ?Page $defaultTemplate
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
*/
|
||||
class Chapter extends BookChild implements HtmlDescriptionInterface
|
||||
class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use HtmlDescriptionTrait;
|
||||
use ContainerTrait;
|
||||
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'priority'];
|
||||
protected $hidden = ['pivot', 'deleted_at', 'description_html'];
|
||||
protected $hidden = ['pivot', 'deleted_at', 'description_html', 'sort_rule_id', 'image_id', 'entity_id', 'entity_type', 'chapter_id'];
|
||||
protected $fillable = ['name', 'priority'];
|
||||
|
||||
/**
|
||||
* Get the pages that this chapter contains.
|
||||
@@ -50,14 +48,6 @@ class Chapter extends BookChild implements HtmlDescriptionInterface
|
||||
return url('/' . implode('/', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Page that is used as default template for newly created pages within this Chapter.
|
||||
*/
|
||||
public function defaultTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'default_template_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible pages in this chapter.
|
||||
* @return Collection<Page>
|
||||
@@ -70,4 +60,9 @@ class Chapter extends BookChild implements HtmlDescriptionInterface
|
||||
->orderBy('priority', 'asc')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function defaultTemplate(): EntityDefaultTemplate
|
||||
{
|
||||
return new EntityDefaultTemplate($this);
|
||||
}
|
||||
}
|
||||
|
||||
26
app/Entities/Models/ContainerTrait.php
Normal file
26
app/Entities/Models/ContainerTrait.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityHtmlDescription;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* @mixin Entity
|
||||
*/
|
||||
trait ContainerTrait
|
||||
{
|
||||
public function descriptionInfo(): EntityHtmlDescription
|
||||
{
|
||||
return new EntityHtmlDescription($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<EntityContainerData, $this>
|
||||
*/
|
||||
public function relatedData(): HasOne
|
||||
{
|
||||
return $this->hasOne(EntityContainerData::class, 'entity_id', 'id')
|
||||
->where('entity_type', '=', $this->getMorphClass());
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
interface CoverImageInterface
|
||||
{
|
||||
/**
|
||||
* Get the cover image for this item.
|
||||
*/
|
||||
public function cover(): BelongsTo;
|
||||
|
||||
/**
|
||||
* Get the type of the image model that is used when storing a cover image.
|
||||
*/
|
||||
public function coverImageTypeKey(): string;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
@@ -17,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
*/
|
||||
class Deletion extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $hidden = [];
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,23 +28,25 @@ use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Class Entity
|
||||
* The base class for book-like items such as pages, chapters & books.
|
||||
* The base class for book-like items such as pages, chapters and books.
|
||||
* This is not a database model in itself but extended.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $type
|
||||
* @property string $name
|
||||
* @property string $slug
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Carbon $deleted_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property int $owned_by
|
||||
* @property int|null $created_by
|
||||
* @property int|null $updated_by
|
||||
* @property int|null $owned_by
|
||||
* @property Collection $tags
|
||||
*
|
||||
* @method static Entity|Builder visible()
|
||||
@@ -77,6 +79,72 @@ abstract class Entity extends Model implements
|
||||
*/
|
||||
public float $searchFactor = 1.0;
|
||||
|
||||
/**
|
||||
* Set the table to be that used by all entities.
|
||||
*/
|
||||
protected $table = 'entities';
|
||||
|
||||
/**
|
||||
* Set a custom query builder for entities.
|
||||
*/
|
||||
protected static string $builder = EntityQueryBuilder::class;
|
||||
|
||||
public static array $commonFields = [
|
||||
'id',
|
||||
'type',
|
||||
'name',
|
||||
'slug',
|
||||
'book_id',
|
||||
'chapter_id',
|
||||
'priority',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'owned_by',
|
||||
];
|
||||
|
||||
/**
|
||||
* Override the save method to also save the contents for convenience.
|
||||
*/
|
||||
public function save(array $options = []): bool
|
||||
{
|
||||
/** @var EntityPageData|EntityContainerData $contents */
|
||||
$contents = $this->relatedData()->firstOrNew();
|
||||
$contentFields = $this->getContentsAttributes();
|
||||
|
||||
foreach ($contentFields as $key => $value) {
|
||||
$contents->setAttribute($key, $value);
|
||||
unset($this->attributes[$key]);
|
||||
}
|
||||
|
||||
$this->setAttribute('type', $this->getMorphClass());
|
||||
$result = parent::save($options);
|
||||
$contentsResult = true;
|
||||
|
||||
if ($result && $contents->isDirty()) {
|
||||
$contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()];
|
||||
$contents->forceFill($contentsFillData);
|
||||
$contentsResult = $contents->save();
|
||||
$this->touch();
|
||||
}
|
||||
|
||||
$this->forceFill($contentFields);
|
||||
|
||||
return $result && $contentsResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this item is a container item.
|
||||
*/
|
||||
public function isContainer(): bool
|
||||
{
|
||||
return $this instanceof Bookshelf ||
|
||||
$this instanceof Book ||
|
||||
$this instanceof Chapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entities that are visible to the current user.
|
||||
*/
|
||||
@@ -91,8 +159,8 @@ abstract class Entity extends Model implements
|
||||
public function scopeWithLastView(Builder $query)
|
||||
{
|
||||
$viewedAtQuery = View::query()->select('updated_at')
|
||||
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
|
||||
->where('viewable_type', '=', $this->getMorphClass())
|
||||
->whereColumn('viewable_id', '=', 'entities.id')
|
||||
->whereColumn('viewable_type', '=', 'entities.type')
|
||||
->where('user_id', '=', user()->id)
|
||||
->take(1);
|
||||
|
||||
@@ -102,11 +170,12 @@ abstract class Entity extends Model implements
|
||||
/**
|
||||
* Query scope to get the total view count of the entities.
|
||||
*/
|
||||
public function scopeWithViewCount(Builder $query)
|
||||
public function scopeWithViewCount(Builder $query): void
|
||||
{
|
||||
$viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
|
||||
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
|
||||
->where('viewable_type', '=', $this->getMorphClass())->take(1);
|
||||
->whereColumn('viewable_id', '=', 'entities.id')
|
||||
->whereColumn('viewable_type', '=', 'entities.type')
|
||||
->take(1);
|
||||
|
||||
$query->addSelect(['view_count' => $viewCountQuery]);
|
||||
}
|
||||
@@ -162,15 +231,17 @@ abstract class Entity extends Model implements
|
||||
*/
|
||||
public function tags(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
|
||||
return $this->morphMany(Tag::class, 'entity')
|
||||
->orderBy('order', 'asc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the comments for an entity.
|
||||
* @return MorphMany<Comment, $this>
|
||||
*/
|
||||
public function comments(bool $orderByCreated = true): MorphMany
|
||||
{
|
||||
$query = $this->morphMany(Comment::class, 'entity');
|
||||
$query = $this->morphMany(Comment::class, 'commentable');
|
||||
|
||||
return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
|
||||
}
|
||||
@@ -184,7 +255,7 @@ abstract class Entity extends Model implements
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this entities restrictions.
|
||||
* Get this entities assigned permissions.
|
||||
*/
|
||||
public function permissions(): MorphMany
|
||||
{
|
||||
@@ -267,7 +338,7 @@ abstract class Entity extends Model implements
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a limited-length version of the entities name.
|
||||
* Gets a limited-length version of the entity name.
|
||||
*/
|
||||
public function getShortName(int $length = 25): string
|
||||
{
|
||||
@@ -377,4 +448,40 @@ abstract class Entity extends Model implements
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<covariant (EntityContainerData|EntityPageData), $this>
|
||||
*/
|
||||
abstract public function relatedData(): HasOne;
|
||||
|
||||
/**
|
||||
* Get the attributes that are intended for the related contents model.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getContentsAttributes(): array
|
||||
{
|
||||
$contentFields = [];
|
||||
$contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class;
|
||||
|
||||
foreach ($this->attributes as $key => $value) {
|
||||
if (in_array($key, $contentModel::$fields)) {
|
||||
$contentFields[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $contentFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance for the given entity type.
|
||||
*/
|
||||
public static function instanceFromType(string $type): self
|
||||
{
|
||||
return match ($type) {
|
||||
'page' => new Page(),
|
||||
'chapter' => new Chapter(),
|
||||
'book' => new Book(),
|
||||
'bookshelf' => new Bookshelf(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
52
app/Entities/Models/EntityContainerData.php
Normal file
52
app/Entities/Models/EntityContainerData.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $entity_id
|
||||
* @property string $entity_type
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
* @property ?int $default_template_id
|
||||
* @property ?int $image_id
|
||||
* @property ?int $sort_rule_id
|
||||
*/
|
||||
class EntityContainerData extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
protected $primaryKey = 'entity_id';
|
||||
public $incrementing = false;
|
||||
|
||||
public static array $fields = [
|
||||
'description',
|
||||
'description_html',
|
||||
'default_template_id',
|
||||
'image_id',
|
||||
'sort_rule_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Override the default set keys for save query method to make it work with composite keys.
|
||||
*/
|
||||
public function setKeysForSaveQuery($query): Builder
|
||||
{
|
||||
$query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery())
|
||||
->where('entity_type', '=', $this->entity_type);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the default set keys for a select query method to make it work with composite keys.
|
||||
*/
|
||||
protected function setKeysForSelectQuery($query): Builder
|
||||
{
|
||||
$query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery())
|
||||
->where('entity_type', '=', $this->entity_type);
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
25
app/Entities/Models/EntityPageData.php
Normal file
25
app/Entities/Models/EntityPageData.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $page_id
|
||||
*/
|
||||
class EntityPageData extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
protected $primaryKey = 'page_id';
|
||||
public $incrementing = false;
|
||||
|
||||
public static array $fields = [
|
||||
'draft',
|
||||
'template',
|
||||
'revision_count',
|
||||
'editor',
|
||||
'html',
|
||||
'text',
|
||||
'markdown',
|
||||
];
|
||||
}
|
||||
38
app/Entities/Models/EntityQueryBuilder.php
Normal file
38
app/Entities/Models/EntityQueryBuilder.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
|
||||
class EntityQueryBuilder extends Builder
|
||||
{
|
||||
/**
|
||||
* Create a new Eloquent query builder instance.
|
||||
*/
|
||||
public function __construct(QueryBuilder $query)
|
||||
{
|
||||
parent::__construct($query);
|
||||
|
||||
$this->withGlobalScope('entity', new EntityScope());
|
||||
}
|
||||
|
||||
public function withoutGlobalScope($scope): static
|
||||
{
|
||||
// Prevent removal of the entity scope
|
||||
if ($scope === 'entity') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return parent::withoutGlobalScope($scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the default forceDelete method to add type filter onto the query
|
||||
* since it specifically ignores scopes by default.
|
||||
*/
|
||||
public function forceDelete()
|
||||
{
|
||||
return $this->query->where('type', '=', $this->model->getMorphClass())->delete();
|
||||
}
|
||||
}
|
||||
28
app/Entities/Models/EntityScope.php
Normal file
28
app/Entities/Models/EntityScope.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Scope;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
|
||||
class EntityScope implements Scope
|
||||
{
|
||||
/**
|
||||
* Apply the scope to a given Eloquent query builder.
|
||||
*/
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
$builder = $builder->where('type', '=', $model->getMorphClass());
|
||||
$table = $model->getTable();
|
||||
if ($model instanceof Page) {
|
||||
$builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', "{$table}.id");
|
||||
} else {
|
||||
$builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model, $table) {
|
||||
$join->on('entity_container_data.entity_id', '=', "{$table}.id")
|
||||
->where('entity_container_data.entity_type', '=', $model->getMorphClass());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
69
app/Entities/Models/EntityTable.php
Normal file
69
app/Entities/Models/EntityTable.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\EntityPermission;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* This is a simplistic model interpretation of a generic Entity used to query and represent
|
||||
* that database abstractly. Generally, this should rarely be used outside queries.
|
||||
*/
|
||||
class EntityTable extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'entities';
|
||||
|
||||
/**
|
||||
* Get the entities that are visible to the current user.
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity jointPermissions this is connected to.
|
||||
*/
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id')
|
||||
->whereColumn('entity_type', '=', 'entities.type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Tags that have been assigned to entities.
|
||||
*/
|
||||
public function tags(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tag::class, 'entity_id')
|
||||
->whereColumn('entity_type', '=', 'entities.type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the assigned permissions.
|
||||
*/
|
||||
public function permissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntityPermission::class, 'entity_id')
|
||||
->whereColumn('entity_type', '=', 'entities.type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get View objects for this entity.
|
||||
*/
|
||||
public function views(): HasMany
|
||||
{
|
||||
return $this->hasMany(View::class, 'viewable_id')
|
||||
->whereColumn('viewable_type', '=', 'entities.type');
|
||||
}
|
||||
}
|
||||
18
app/Entities/Models/HasCoverInterface.php
Normal file
18
app/Entities/Models/HasCoverInterface.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityCover;
|
||||
use BookStack\Uploads\Image;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
interface HasCoverInterface
|
||||
{
|
||||
public function coverInfo(): EntityCover;
|
||||
|
||||
/**
|
||||
* The cover image of this entity.
|
||||
* @return BelongsTo<Image, covariant Entity>
|
||||
*/
|
||||
public function cover(): BelongsTo;
|
||||
}
|
||||
10
app/Entities/Models/HasDefaultTemplateInterface.php
Normal file
10
app/Entities/Models/HasDefaultTemplateInterface.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityDefaultTemplate;
|
||||
|
||||
interface HasDefaultTemplateInterface
|
||||
{
|
||||
public function defaultTemplate(): EntityDefaultTemplate;
|
||||
}
|
||||
10
app/Entities/Models/HasDescriptionInterface.php
Normal file
10
app/Entities/Models/HasDescriptionInterface.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityHtmlDescription;
|
||||
|
||||
interface HasDescriptionInterface
|
||||
{
|
||||
public function descriptionInfo(): EntityHtmlDescription;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
interface HtmlDescriptionInterface
|
||||
{
|
||||
/**
|
||||
* Get the HTML-based description for this item.
|
||||
* By default, the content should be sanitised unless raw is set to true.
|
||||
*/
|
||||
public function descriptionHtml(bool $raw = false): string;
|
||||
|
||||
/**
|
||||
* Set the HTML-based description for this item.
|
||||
*/
|
||||
public function setDescriptionHtml(string $html, string|null $plaintext = null): void;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
|
||||
/**
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
*/
|
||||
trait HtmlDescriptionTrait
|
||||
{
|
||||
public function descriptionHtml(bool $raw = false): string
|
||||
{
|
||||
$html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
|
||||
if ($raw) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($html);
|
||||
}
|
||||
|
||||
public function setDescriptionHtml(string $html, string|null $plaintext = null): void
|
||||
{
|
||||
$this->description_html = $html;
|
||||
|
||||
if ($plaintext !== null) {
|
||||
$this->description = $plaintext;
|
||||
}
|
||||
|
||||
if (empty($html) && !empty($plaintext)) {
|
||||
$this->description_html = $this->descriptionHtml();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorType;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -15,7 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* Class Page.
|
||||
*
|
||||
* @property EntityPageData $pageData
|
||||
* @property int $chapter_id
|
||||
* @property string $html
|
||||
* @property string $markdown
|
||||
@@ -33,12 +32,10 @@ class Page extends BookChild
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'priority'];
|
||||
|
||||
public string $textField = 'text';
|
||||
public string $htmlField = 'html';
|
||||
|
||||
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
|
||||
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at', 'entity_id', 'entity_type'];
|
||||
protected $fillable = ['name', 'priority'];
|
||||
|
||||
protected $casts = [
|
||||
'draft' => 'boolean',
|
||||
@@ -57,10 +54,8 @@ class Page extends BookChild
|
||||
|
||||
/**
|
||||
* Get the chapter that this page is in, If applicable.
|
||||
*
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function chapter()
|
||||
public function chapter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Chapter::class);
|
||||
}
|
||||
@@ -107,10 +102,8 @@ class Page extends BookChild
|
||||
|
||||
/**
|
||||
* Get the attachments assigned to this page.
|
||||
*
|
||||
* @return HasMany
|
||||
*/
|
||||
public function attachments()
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
|
||||
}
|
||||
@@ -139,8 +132,16 @@ class Page extends BookChild
|
||||
$refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
|
||||
$refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));
|
||||
$refreshed->setAttribute('raw_html', $refreshed->html);
|
||||
$refreshed->html = (new PageContent($refreshed))->render();
|
||||
$refreshed->setAttribute('html', (new PageContent($refreshed))->render());
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<EntityPageData, $this>
|
||||
*/
|
||||
public function relatedData(): HasOne
|
||||
{
|
||||
return $this->hasOne(EntityPageData::class, 'page_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Users\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
*/
|
||||
class PageRevision extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'text', 'summary'];
|
||||
protected $hidden = ['html', 'markdown', 'text'];
|
||||
|
||||
|
||||
@@ -55,6 +55,11 @@ class BookQueries implements ProvidesEntityQueries
|
||||
->select(static::$listAttributes);
|
||||
}
|
||||
|
||||
public function visibleForContent(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible');
|
||||
}
|
||||
|
||||
public function visibleForListWithCover(): Builder
|
||||
{
|
||||
return $this->visibleForList()->with('cover');
|
||||
|
||||
@@ -60,6 +60,11 @@ class BookshelfQueries implements ProvidesEntityQueries
|
||||
return $this->start()->scopes('visible')->select(static::$listAttributes);
|
||||
}
|
||||
|
||||
public function visibleForContent(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible');
|
||||
}
|
||||
|
||||
public function visibleForListWithCover(): Builder
|
||||
{
|
||||
return $this->visibleForList()->with('cover');
|
||||
|
||||
@@ -65,8 +65,14 @@ class ChapterQueries implements ProvidesEntityQueries
|
||||
->scopes('visible')
|
||||
->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) {
|
||||
$builder->select('slug')
|
||||
->from('books')
|
||||
->whereColumn('books.id', '=', 'chapters.book_id');
|
||||
->from('entities as books')
|
||||
->where('type', '=', 'book')
|
||||
->whereColumn('books.id', '=', 'entities.book_id');
|
||||
}]));
|
||||
}
|
||||
|
||||
public function visibleForContent(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\EntityTable;
|
||||
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 EntityQueries
|
||||
@@ -32,17 +36,53 @@ class EntityQueries
|
||||
return $queries->findVisibleById($entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query across all entity types.
|
||||
* Combines the description/text fields into a single 'description' field.
|
||||
* @return Builder<EntityTable>
|
||||
*/
|
||||
public function visibleForList(): Builder
|
||||
{
|
||||
$rawDescriptionField = DB::raw('COALESCE(description, text) as description');
|
||||
$bookSlugSelect = function (QueryBuilder $query) {
|
||||
return $query->select('slug')->from('entities as books')
|
||||
->whereColumn('books.id', '=', 'entities.book_id')
|
||||
->where('type', '=', 'book');
|
||||
};
|
||||
|
||||
return EntityTable::query()->scopes('visible')
|
||||
->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', 'book_slug' => $bookSlugSelect, $rawDescriptionField])
|
||||
->leftJoin('entity_container_data', function (JoinClause $join) {
|
||||
$join->on('entity_container_data.entity_id', '=', 'entities.id')
|
||||
->on('entity_container_data.entity_type', '=', 'entities.type');
|
||||
})->leftJoin('entity_page_data', function (JoinClause $join) {
|
||||
$join->on('entity_page_data.page_id', '=', 'entities.id')
|
||||
->where('entities.type', '=', 'page');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query of visible entities of the given type,
|
||||
* suitable for listing display.
|
||||
* @return Builder<Entity>
|
||||
*/
|
||||
public function visibleForList(string $entityType): Builder
|
||||
public function visibleForListForType(string $entityType): Builder
|
||||
{
|
||||
$queries = $this->getQueriesForType($entityType);
|
||||
return $queries->visibleForList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query of visible entities of the given type,
|
||||
* suitable for using the contents of the items.
|
||||
* @return Builder<Entity>
|
||||
*/
|
||||
public function visibleForContentForType(string $entityType): Builder
|
||||
{
|
||||
$queries = $this->getQueriesForType($entityType);
|
||||
return $queries->visibleForContent();
|
||||
}
|
||||
|
||||
protected function getQueriesForType(string $type): ProvidesEntityQueries
|
||||
{
|
||||
$queries = match ($type) {
|
||||
|
||||
@@ -13,7 +13,7 @@ class PageQueries implements ProvidesEntityQueries
|
||||
{
|
||||
protected static array $contentAttributes = [
|
||||
'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
|
||||
'template', 'html', 'text', 'created_at', 'updated_at', 'priority',
|
||||
'template', 'html', 'markdown', 'text', 'created_at', 'updated_at', 'priority',
|
||||
'created_by', 'updated_by', 'owned_by',
|
||||
];
|
||||
protected static array $listAttributes = [
|
||||
@@ -82,6 +82,14 @@ class PageQueries implements ProvidesEntityQueries
|
||||
->select($this->mergeBookSlugForSelect(static::$listAttributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Page>
|
||||
*/
|
||||
public function visibleForContent(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible');
|
||||
}
|
||||
|
||||
public function visibleForChapterList(int $chapterId): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
@@ -104,18 +112,19 @@ class PageQueries implements ProvidesEntityQueries
|
||||
->where('created_by', '=', user()->id);
|
||||
}
|
||||
|
||||
public function visibleTemplates(): Builder
|
||||
public function visibleTemplates(bool $includeContents = false): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
->where('template', '=', true);
|
||||
$base = $includeContents ? $this->visibleWithContents() : $this->visibleForList();
|
||||
return $base->where('template', '=', true);
|
||||
}
|
||||
|
||||
protected function mergeBookSlugForSelect(array $columns): array
|
||||
{
|
||||
return array_merge($columns, ['book_slug' => function ($builder) {
|
||||
$builder->select('slug')
|
||||
->from('books')
|
||||
->whereColumn('books.id', '=', 'pages.book_id');
|
||||
->from('entities as books')
|
||||
->where('type', '=', 'book')
|
||||
->whereColumn('books.id', '=', 'entities.book_id');
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,4 +35,11 @@ interface ProvidesEntityQueries
|
||||
* @return Builder<TModel>
|
||||
*/
|
||||
public function visibleForList(): Builder;
|
||||
|
||||
/**
|
||||
* Start a query for items that are visible, with selection
|
||||
* configured for using the content of the items found.
|
||||
* @return Builder<TModel>
|
||||
*/
|
||||
public function visibleForContent(): Builder;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Activity\TagRepo;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\HasCoverInterface;
|
||||
use BookStack\Entities\Models\HasDescriptionInterface;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\CoverImageInterface;
|
||||
use BookStack\Entities\Models\HtmlDescriptionInterface;
|
||||
use BookStack\Entities\Models\HtmlDescriptionTrait;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\References\ReferenceStore;
|
||||
@@ -33,17 +30,25 @@ class BaseRepo
|
||||
|
||||
/**
|
||||
* Create a new entity in the system.
|
||||
* @template T of Entity
|
||||
* @param T $entity
|
||||
* @return T
|
||||
*/
|
||||
public function create(Entity $entity, array $input)
|
||||
public function create(Entity $entity, array $input): Entity
|
||||
{
|
||||
$entity = (clone $entity)->refresh();
|
||||
$entity->fill($input);
|
||||
$this->updateDescription($entity, $input);
|
||||
$entity->forceFill([
|
||||
'created_by' => user()->id,
|
||||
'updated_by' => user()->id,
|
||||
'owned_by' => user()->id,
|
||||
]);
|
||||
$entity->refreshSlug();
|
||||
|
||||
if ($entity instanceof HasDescriptionInterface) {
|
||||
$this->updateDescription($entity, $input);
|
||||
}
|
||||
|
||||
$entity->save();
|
||||
|
||||
if (isset($input['tags'])) {
|
||||
@@ -53,24 +58,33 @@ class BaseRepo
|
||||
$entity->refresh();
|
||||
$entity->rebuildPermissions();
|
||||
$entity->indexForSearch();
|
||||
|
||||
$this->referenceStore->updateForEntity($entity);
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given entity.
|
||||
* @template T of Entity
|
||||
* @param T $entity
|
||||
* @return T
|
||||
*/
|
||||
public function update(Entity $entity, array $input)
|
||||
public function update(Entity $entity, array $input): Entity
|
||||
{
|
||||
$oldUrl = $entity->getUrl();
|
||||
|
||||
$entity->fill($input);
|
||||
$this->updateDescription($entity, $input);
|
||||
$entity->updated_by = user()->id;
|
||||
|
||||
if ($entity->isDirty('name') || empty($entity->slug)) {
|
||||
$entity->refreshSlug();
|
||||
}
|
||||
|
||||
if ($entity instanceof HasDescriptionInterface) {
|
||||
$this->updateDescription($entity, $input);
|
||||
}
|
||||
|
||||
$entity->save();
|
||||
|
||||
if (isset($input['tags'])) {
|
||||
@@ -84,59 +98,35 @@ class BaseRepo
|
||||
if ($oldUrl !== $entity->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given items' cover image, or clear it.
|
||||
* Update the given items' cover image or clear it.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function updateCoverImage(Entity&CoverImageInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void
|
||||
{
|
||||
if ($coverImage) {
|
||||
$imageType = $entity->coverImageTypeKey();
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$imageType = 'cover_' . $entity->type;
|
||||
$this->imageRepo->destroyImage($entity->coverInfo()->getImage());
|
||||
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
|
||||
$entity->cover()->associate($image);
|
||||
$entity->coverInfo()->setImage($image);
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
if ($removeImage) {
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$entity->cover()->dissociate();
|
||||
$this->imageRepo->destroyImage($entity->coverInfo()->getImage());
|
||||
$entity->coverInfo()->setImage(null);
|
||||
$entity->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the default page template used for this item.
|
||||
* Checks that, if changing, the provided value is a valid template and the user
|
||||
* has visibility of the provided page template id.
|
||||
*/
|
||||
public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void
|
||||
{
|
||||
$changing = $templateId !== intval($entity->default_template_id);
|
||||
if (!$changing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($templateId === 0) {
|
||||
$entity->default_template_id = null;
|
||||
$entity->save();
|
||||
return;
|
||||
}
|
||||
|
||||
$templateExists = $this->pageQueries->visibleTemplates()
|
||||
->where('id', '=', $templateId)
|
||||
->exists();
|
||||
|
||||
$entity->default_template_id = $templateExists ? $templateId : null;
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the parent of the given entity, if any auto sort actions are set for it.
|
||||
* Sort the parent of the given entity if any auto sort actions are set for it.
|
||||
* Typically ran during create/update/insert events.
|
||||
*/
|
||||
public function sortParent(Entity $entity): void
|
||||
@@ -147,19 +137,22 @@ class BaseRepo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the description of the given entity from input data.
|
||||
*/
|
||||
protected function updateDescription(Entity $entity, array $input): void
|
||||
{
|
||||
if (!($entity instanceof HtmlDescriptionInterface)) {
|
||||
if (!$entity instanceof HasDescriptionInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($input['description_html'])) {
|
||||
$entity->setDescriptionHtml(
|
||||
$entity->descriptionInfo()->set(
|
||||
HtmlDescriptionFilter::filterFromString($input['description_html']),
|
||||
html_entity_decode(strip_tags($input['description_html']))
|
||||
);
|
||||
} else if (isset($input['description'])) {
|
||||
$entity->setDescriptionHtml('', $input['description']);
|
||||
$entity->descriptionInfo()->set('', $input['description']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,19 +30,18 @@ class BookRepo
|
||||
public function create(array $input): Book
|
||||
{
|
||||
return (new DatabaseTransaction(function () use ($input) {
|
||||
$book = new Book();
|
||||
|
||||
$this->baseRepo->create($book, $input);
|
||||
$book = $this->baseRepo->create(new Book(), $input);
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
|
||||
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
|
||||
$book->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
|
||||
Activity::add(ActivityType::BOOK_CREATE, $book);
|
||||
|
||||
$defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
|
||||
if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
|
||||
$book->sort_rule_id = $defaultBookSortSetting;
|
||||
$book->save();
|
||||
}
|
||||
|
||||
$book->save();
|
||||
|
||||
return $book;
|
||||
}))->run();
|
||||
}
|
||||
@@ -52,28 +51,29 @@ class BookRepo
|
||||
*/
|
||||
public function update(Book $book, array $input): Book
|
||||
{
|
||||
$this->baseRepo->update($book, $input);
|
||||
$book = $this->baseRepo->update($book, $input);
|
||||
|
||||
if (array_key_exists('default_template_id', $input)) {
|
||||
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id']));
|
||||
$book->defaultTemplate()->setFromId(intval($input['default_template_id']));
|
||||
}
|
||||
|
||||
if (array_key_exists('image', $input)) {
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
|
||||
}
|
||||
|
||||
$book->save();
|
||||
Activity::add(ActivityType::BOOK_UPDATE, $book);
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given book's cover image, or clear it.
|
||||
* Update the given book's cover image or clear it.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void
|
||||
{
|
||||
$this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ class BookRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Book $book)
|
||||
public function destroy(Book $book): void
|
||||
{
|
||||
$this->trashCan->softDestroyBook($book);
|
||||
Activity::add(ActivityType::BOOK_DELETE, $book);
|
||||
|
||||
@@ -25,8 +25,7 @@ class BookshelfRepo
|
||||
public function create(array $input, array $bookIds): Bookshelf
|
||||
{
|
||||
return (new DatabaseTransaction(function () use ($input, $bookIds) {
|
||||
$shelf = new Bookshelf();
|
||||
$this->baseRepo->create($shelf, $input);
|
||||
$shelf = $this->baseRepo->create(new Bookshelf(), $input);
|
||||
$this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
|
||||
@@ -39,7 +38,7 @@ class BookshelfRepo
|
||||
*/
|
||||
public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf
|
||||
{
|
||||
$this->baseRepo->update($shelf, $input);
|
||||
$shelf = $this->baseRepo->update($shelf, $input);
|
||||
|
||||
if (!is_null($bookIds)) {
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
@@ -96,7 +95,7 @@ class BookshelfRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Bookshelf $shelf)
|
||||
public function destroy(Bookshelf $shelf): void
|
||||
{
|
||||
$this->trashCan->softDestroyShelf($shelf);
|
||||
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
|
||||
|
||||
@@ -33,8 +33,11 @@ class ChapterRepo
|
||||
$chapter = new Chapter();
|
||||
$chapter->book_id = $parentBook->id;
|
||||
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
|
||||
$this->baseRepo->create($chapter, $input);
|
||||
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
|
||||
|
||||
$chapter = $this->baseRepo->create($chapter, $input);
|
||||
$chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
|
||||
|
||||
$chapter->save();
|
||||
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
|
||||
|
||||
$this->baseRepo->sortParent($chapter);
|
||||
@@ -48,12 +51,13 @@ class ChapterRepo
|
||||
*/
|
||||
public function update(Chapter $chapter, array $input): Chapter
|
||||
{
|
||||
$this->baseRepo->update($chapter, $input);
|
||||
$chapter = $this->baseRepo->update($chapter, $input);
|
||||
|
||||
if (array_key_exists('default_template_id', $input)) {
|
||||
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id']));
|
||||
$chapter->defaultTemplate()->setFromId(intval($input['default_template_id']));
|
||||
}
|
||||
|
||||
$chapter->save();
|
||||
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
|
||||
|
||||
$this->baseRepo->sortParent($chapter);
|
||||
@@ -66,7 +70,7 @@ class ChapterRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Chapter $chapter)
|
||||
public function destroy(Chapter $chapter): void
|
||||
{
|
||||
$this->trashCan->softDestroyChapter($chapter);
|
||||
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
|
||||
@@ -93,7 +97,7 @@ class ChapterRepo
|
||||
}
|
||||
|
||||
return (new DatabaseTransaction(function () use ($chapter, $parent) {
|
||||
$chapter->changeBook($parent->id);
|
||||
$chapter = $chapter->changeBook($parent->id);
|
||||
$chapter->rebuildPermissions();
|
||||
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
||||
|
||||
|
||||
@@ -9,11 +9,9 @@ use BookStack\Facades\Activity;
|
||||
|
||||
class DeletionRepo
|
||||
{
|
||||
private TrashCan $trashCan;
|
||||
|
||||
public function __construct(TrashCan $trashCan)
|
||||
{
|
||||
$this->trashCan = $trashCan;
|
||||
public function __construct(
|
||||
protected TrashCan $trashCan
|
||||
) {
|
||||
}
|
||||
|
||||
public function restore(int $id): int
|
||||
|
||||
@@ -37,7 +37,7 @@ class PageRepo
|
||||
/**
|
||||
* Get a new draft page belonging to the given parent entity.
|
||||
*/
|
||||
public function getNewDraftPage(Entity $parent)
|
||||
public function getNewDraftPage(Entity $parent): Page
|
||||
{
|
||||
$page = (new Page())->forceFill([
|
||||
'name' => trans('entities.pages_initial_name'),
|
||||
@@ -46,6 +46,9 @@ class PageRepo
|
||||
'updated_by' => user()->id,
|
||||
'draft' => true,
|
||||
'editor' => PageEditorType::getSystemDefault()->value,
|
||||
'html' => '',
|
||||
'markdown' => '',
|
||||
'text' => '',
|
||||
]);
|
||||
|
||||
if ($parent instanceof Chapter) {
|
||||
@@ -55,17 +58,18 @@ class PageRepo
|
||||
$page->book_id = $parent->id;
|
||||
}
|
||||
|
||||
$defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate;
|
||||
if ($defaultTemplate && userCan(Permission::PageView, $defaultTemplate)) {
|
||||
$defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get();
|
||||
if ($defaultTemplate) {
|
||||
$page->forceFill([
|
||||
'html' => $defaultTemplate->html,
|
||||
'markdown' => $defaultTemplate->markdown,
|
||||
]);
|
||||
$page->text = (new PageContent($page))->toPlainText();
|
||||
}
|
||||
|
||||
(new DatabaseTransaction(function () use ($page) {
|
||||
$page->save();
|
||||
$page->refresh()->rebuildPermissions();
|
||||
$page->rebuildPermissions();
|
||||
}))->run();
|
||||
|
||||
return $page;
|
||||
@@ -81,7 +85,8 @@ class PageRepo
|
||||
$draft->revision_count = 1;
|
||||
$draft->priority = $this->getNewPriority($draft);
|
||||
$this->updateTemplateStatusAndContentFromInput($draft, $input);
|
||||
$this->baseRepo->update($draft, $input);
|
||||
|
||||
$draft = $this->baseRepo->update($draft, $input);
|
||||
$draft->rebuildPermissions();
|
||||
|
||||
$summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
|
||||
@@ -112,12 +117,12 @@ class PageRepo
|
||||
public function update(Page $page, array $input): Page
|
||||
{
|
||||
// Hold the old details to compare later
|
||||
$oldHtml = $page->html;
|
||||
$oldName = $page->name;
|
||||
$oldHtml = $page->html;
|
||||
$oldMarkdown = $page->markdown;
|
||||
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$this->baseRepo->update($page, $input);
|
||||
$page = $this->baseRepo->update($page, $input);
|
||||
|
||||
// Update with new details
|
||||
$page->revision_count++;
|
||||
@@ -176,12 +181,12 @@ class PageRepo
|
||||
/**
|
||||
* Save a page update draft.
|
||||
*/
|
||||
public function updatePageDraft(Page $page, array $input)
|
||||
public function updatePageDraft(Page $page, array $input): Page|PageRevision
|
||||
{
|
||||
// If the page itself is a draft simply update that
|
||||
// If the page itself is a draft, simply update that
|
||||
if ($page->draft) {
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$page->fill($input);
|
||||
$page->forceFill(array_intersect_key($input, array_flip(['name'])))->save();
|
||||
$page->save();
|
||||
|
||||
return $page;
|
||||
@@ -209,7 +214,7 @@ class PageRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Page $page)
|
||||
public function destroy(Page $page): void
|
||||
{
|
||||
$this->trashCan->softDestroyPage($page);
|
||||
Activity::add(ActivityType::PAGE_DELETE, $page);
|
||||
@@ -279,7 +284,7 @@ class PageRepo
|
||||
return (new DatabaseTransaction(function () use ($page, $parent) {
|
||||
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
|
||||
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
|
||||
$page->changeBook($newBookId);
|
||||
$page = $page->changeBook($newBookId);
|
||||
$page->rebuildPermissions();
|
||||
|
||||
Activity::add(ActivityType::PAGE_MOVE, $page);
|
||||
|
||||
@@ -23,7 +23,7 @@ class RevisionRepo
|
||||
|
||||
/**
|
||||
* Get a user update_draft page revision to update for the given page.
|
||||
* Checks for an existing revisions before providing a fresh one.
|
||||
* Checks for an existing revision before providing a fresh one.
|
||||
*/
|
||||
public function getNewDraftForCurrentUser(Page $page): PageRevision
|
||||
{
|
||||
@@ -72,7 +72,7 @@ class RevisionRepo
|
||||
/**
|
||||
* Delete old revisions, for the given page, from the system.
|
||||
*/
|
||||
protected function deleteOldRevisions(Page $page)
|
||||
protected function deleteOldRevisions(Page $page): void
|
||||
{
|
||||
$revisionLimit = config('app.revision_limit');
|
||||
if ($revisionLimit === false) {
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Sorting\BookSortMap;
|
||||
use BookStack\Sorting\BookSortMapItem;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BookContents
|
||||
@@ -29,7 +26,7 @@ class BookContents
|
||||
{
|
||||
$maxPage = $this->book->pages()
|
||||
->where('draft', '=', false)
|
||||
->where('chapter_id', '=', 0)
|
||||
->whereDoesntHave('chapter')
|
||||
->max('priority');
|
||||
|
||||
$maxChapter = $this->book->chapters()
|
||||
@@ -80,11 +77,11 @@ class BookContents
|
||||
protected function bookChildSortFunc(): callable
|
||||
{
|
||||
return function (Entity $entity) {
|
||||
if (isset($entity['draft']) && $entity['draft']) {
|
||||
if ($entity->getAttribute('draft') ?? false) {
|
||||
return -100;
|
||||
}
|
||||
|
||||
return $entity['priority'] ?? 0;
|
||||
return $entity->getAttribute('priority') ?? 0;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\HasCoverInterface;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\CoverImageInterface;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
@@ -106,8 +106,8 @@ class Cloner
|
||||
$inputData['tags'] = $this->entityTagsToInputArray($entity);
|
||||
|
||||
// Add a cover to the data if existing on the original entity
|
||||
if ($entity instanceof CoverImageInterface) {
|
||||
$cover = $entity->cover()->first();
|
||||
if ($entity instanceof HasCoverInterface) {
|
||||
$cover = $entity->coverInfo()->getImage();
|
||||
if ($cover) {
|
||||
$inputData['image'] = $this->imageToUploadedFile($cover);
|
||||
}
|
||||
|
||||
75
app/Entities/Tools/EntityCover.php
Normal file
75
app/Entities/Tools/EntityCover.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Uploads\Image;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class EntityCover
|
||||
{
|
||||
public function __construct(
|
||||
protected Book|Bookshelf $entity,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function imageQuery(): Builder
|
||||
{
|
||||
return Image::query()->where('id', '=', $this->entity->image_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cover image exists for this entity.
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
return $this->entity->image_id !== null && $this->imageQuery()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the assigned cover image model.
|
||||
*/
|
||||
public function getImage(): Image|null
|
||||
{
|
||||
if ($this->entity->image_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cover = $this->imageQuery()->first();
|
||||
if ($cover instanceof Image) {
|
||||
return $cover;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cover image URL, or the given default if none assigned/existing.
|
||||
*/
|
||||
public function getUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null
|
||||
{
|
||||
if (!$this->entity->image_id) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->getImage()?->getThumb($width, $height, false) ?? $default;
|
||||
} catch (Exception $err) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the image to use as the cover for this entity.
|
||||
*/
|
||||
public function setImage(Image|null $image): void
|
||||
{
|
||||
if ($image === null) {
|
||||
$this->entity->image_id = null;
|
||||
} else {
|
||||
$this->entity->image_id = $image->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
app/Entities/Tools/EntityDefaultTemplate.php
Normal file
60
app/Entities/Tools/EntityDefaultTemplate.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
|
||||
class EntityDefaultTemplate
|
||||
{
|
||||
public function __construct(
|
||||
protected Book|Chapter $entity,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default template ID for this entity.
|
||||
*/
|
||||
public function setFromId(int $templateId): void
|
||||
{
|
||||
$changing = $templateId !== intval($this->entity->default_template_id);
|
||||
if (!$changing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($templateId === 0) {
|
||||
$this->entity->default_template_id = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$pageQueries = app()->make(PageQueries::class);
|
||||
$templateExists = $pageQueries->visibleTemplates()
|
||||
->where('id', '=', $templateId)
|
||||
->exists();
|
||||
|
||||
$this->entity->default_template_id = $templateExists ? $templateId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default template for this entity (if visible).
|
||||
*/
|
||||
public function get(): Page|null
|
||||
{
|
||||
if (!$this->entity->default_template_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pageQueries = app()->make(PageQueries::class);
|
||||
$page = $pageQueries->visibleTemplates(true)
|
||||
->where('id', '=', $this->entity->default_template_id)
|
||||
->first();
|
||||
|
||||
if ($page instanceof Page) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
60
app/Entities/Tools/EntityHtmlDescription.php
Normal file
60
app/Entities/Tools/EntityHtmlDescription.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
|
||||
class EntityHtmlDescription
|
||||
{
|
||||
protected string $html = '';
|
||||
protected string $plain = '';
|
||||
|
||||
public function __construct(
|
||||
protected Book|Chapter|Bookshelf $entity,
|
||||
) {
|
||||
$this->html = $this->entity->description_html ?? '';
|
||||
$this->plain = $this->entity->description ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the description from HTML code.
|
||||
* Optionally takes plaintext to use for the model also.
|
||||
*/
|
||||
public function set(string $html, string|null $plaintext = null): void
|
||||
{
|
||||
$this->html = $html;
|
||||
$this->entity->description_html = $this->html;
|
||||
|
||||
if ($plaintext !== null) {
|
||||
$this->plain = $plaintext;
|
||||
$this->entity->description = $this->plain;
|
||||
}
|
||||
|
||||
if (empty($html) && !empty($plaintext)) {
|
||||
$this->html = $this->getHtml();
|
||||
$this->entity->description_html = $this->html;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the description as HTML.
|
||||
* Optionally returns the raw HTML if requested.
|
||||
*/
|
||||
public function getHtml(bool $raw = false): string
|
||||
{
|
||||
$html = $this->html ?: '<p>' . nl2br(e($this->plain)) . '</p>';
|
||||
if ($raw) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($html);
|
||||
}
|
||||
|
||||
public function getPlain(): string
|
||||
{
|
||||
return $this->plain;
|
||||
}
|
||||
}
|
||||
140
app/Entities/Tools/EntityHydrator.php
Normal file
140
app/Entities/Tools/EntityHydrator.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\EntityTable;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class EntityHydrator
|
||||
{
|
||||
public function __construct(
|
||||
protected EntityQueries $entityQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the entities of this hydrator to return a list of entities represented
|
||||
* in their original intended models.
|
||||
* @param EntityTable[] $entities
|
||||
* @return Entity[]
|
||||
*/
|
||||
public function hydrate(array $entities, bool $loadTags = false, bool $loadParents = false): array
|
||||
{
|
||||
$hydrated = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$data = $entity->getRawOriginal();
|
||||
$instance = Entity::instanceFromType($entity->type);
|
||||
|
||||
if ($instance instanceof Page) {
|
||||
$data['text'] = $data['description'];
|
||||
unset($data['description']);
|
||||
}
|
||||
|
||||
$instance = $instance->setRawAttributes($data, true);
|
||||
$hydrated[] = $instance;
|
||||
}
|
||||
|
||||
if ($loadTags) {
|
||||
$this->loadTagsIntoModels($hydrated);
|
||||
}
|
||||
|
||||
if ($loadParents) {
|
||||
$this->loadParentsIntoModels($hydrated);
|
||||
}
|
||||
|
||||
return $hydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function loadTagsIntoModels(array $entities): void
|
||||
{
|
||||
$idsByType = [];
|
||||
$entityMap = [];
|
||||
foreach ($entities as $entity) {
|
||||
if (!isset($idsByType[$entity->type])) {
|
||||
$idsByType[$entity->type] = [];
|
||||
}
|
||||
$idsByType[$entity->type][] = $entity->id;
|
||||
$entityMap[$entity->type . ':' . $entity->id] = $entity;
|
||||
}
|
||||
|
||||
$query = Tag::query();
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$query->orWhere(function ($query) use ($type, $ids) {
|
||||
$query->where('entity_type', '=', $type)
|
||||
->whereIn('entity_id', $ids);
|
||||
});
|
||||
}
|
||||
|
||||
$tags = empty($idsByType) ? [] : $query->get()->all();
|
||||
$tagMap = [];
|
||||
foreach ($tags as $tag) {
|
||||
$key = $tag->entity_type . ':' . $tag->entity_id;
|
||||
if (!isset($tagMap[$key])) {
|
||||
$tagMap[$key] = [];
|
||||
}
|
||||
$tagMap[$key][] = $tag;
|
||||
}
|
||||
|
||||
foreach ($entityMap as $key => $entity) {
|
||||
$entityTags = new Collection($tagMap[$key] ?? []);
|
||||
$entity->setRelation('tags', $entityTags);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function loadParentsIntoModels(array $entities): void
|
||||
{
|
||||
$parentsByType = ['book' => [], 'chapter' => []];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if ($entity->getAttribute('book_id') !== null) {
|
||||
$parentsByType['book'][] = $entity->getAttribute('book_id');
|
||||
}
|
||||
if ($entity->getAttribute('chapter_id') !== null) {
|
||||
$parentsByType['chapter'][] = $entity->getAttribute('chapter_id');
|
||||
}
|
||||
}
|
||||
|
||||
$parentQuery = $this->entityQueries->visibleForList();
|
||||
$filtered = count($parentsByType['book']) > 0 || count($parentsByType['chapter']) > 0;
|
||||
$parentQuery = $parentQuery->where(function ($query) use ($parentsByType) {
|
||||
foreach ($parentsByType as $type => $ids) {
|
||||
if (count($ids) > 0) {
|
||||
$query = $query->orWhere(function ($query) use ($type, $ids) {
|
||||
$query->where('type', '=', $type)
|
||||
->whereIn('id', $ids);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$parentModels = $filtered ? $parentQuery->get()->all() : [];
|
||||
$parents = $this->hydrate($parentModels);
|
||||
$parentMap = [];
|
||||
foreach ($parents as $parent) {
|
||||
$parentMap[$parent->type . ':' . $parent->id] = $parent;
|
||||
}
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if ($entity instanceof Page || $entity instanceof Chapter) {
|
||||
$key = 'book:' . $entity->getRawAttribute('book_id');
|
||||
$entity->setRelation('book', $parentMap[$key] ?? null);
|
||||
}
|
||||
if ($entity instanceof Page) {
|
||||
$key = 'chapter:' . $entity->getRawAttribute('chapter_id');
|
||||
$entity->setRelation('chapter', $parentMap[$key] ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ class HierarchyTransformer
|
||||
/** @var Page $page */
|
||||
foreach ($chapter->pages as $page) {
|
||||
$page->chapter_id = 0;
|
||||
$page->save();
|
||||
$page->changeBook($book->id);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class MixedEntityListLoader
|
||||
* This will look for a model id and type via 'name_id' and 'name_type'.
|
||||
* @param Model[] $relations
|
||||
*/
|
||||
public function loadIntoRelations(array $relations, string $relationName, bool $loadParents): void
|
||||
public function loadIntoRelations(array $relations, string $relationName, bool $loadParents, bool $withContents = false): void
|
||||
{
|
||||
$idsByType = [];
|
||||
foreach ($relations as $relation) {
|
||||
@@ -33,7 +33,7 @@ class MixedEntityListLoader
|
||||
$idsByType[$type][] = $id;
|
||||
}
|
||||
|
||||
$modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents);
|
||||
$modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents, $withContents);
|
||||
|
||||
foreach ($relations as $relation) {
|
||||
$type = $relation->getAttribute($relationName . '_type');
|
||||
@@ -49,13 +49,13 @@ class MixedEntityListLoader
|
||||
* @param array<string, int[]> $idsByType
|
||||
* @return array<string, array<int, Model>>
|
||||
*/
|
||||
protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents): array
|
||||
protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents, bool $withContents): array
|
||||
{
|
||||
$modelMap = [];
|
||||
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$models = $this->queries->visibleForList($type)
|
||||
->whereIn('id', $ids)
|
||||
$base = $withContents ? $this->queries->visibleForContentForType($type) : $this->queries->visibleForListForType($type);
|
||||
$models = $base->whereIn('id', $ids)
|
||||
->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : [])
|
||||
->get();
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ class PageContent
|
||||
/**
|
||||
* Get a plain-text visualisation of this page.
|
||||
*/
|
||||
protected function toPlainText(): string
|
||||
public function toPlainText(): string
|
||||
{
|
||||
$html = $this->render(true);
|
||||
|
||||
|
||||
@@ -6,14 +6,16 @@ use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\EntityContainerData;
|
||||
use BookStack\Entities\Models\HasCoverInterface;
|
||||
use BookStack\Entities\Models\Deletion;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\CoverImageInterface;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\AttachmentService;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\DatabaseTransaction;
|
||||
use Exception;
|
||||
@@ -140,6 +142,7 @@ class TrashCan
|
||||
protected function destroyShelf(Bookshelf $shelf): int
|
||||
{
|
||||
$this->destroyCommonRelations($shelf);
|
||||
$shelf->books()->detach();
|
||||
$shelf->forceDelete();
|
||||
|
||||
return 1;
|
||||
@@ -167,6 +170,7 @@ class TrashCan
|
||||
}
|
||||
|
||||
$this->destroyCommonRelations($book);
|
||||
$book->shelves()->detach();
|
||||
$book->forceDelete();
|
||||
|
||||
return $count + 1;
|
||||
@@ -209,15 +213,16 @@ class TrashCan
|
||||
$attachmentService->deleteFile($attachment);
|
||||
}
|
||||
|
||||
// Remove book template usages
|
||||
$this->queries->books->start()
|
||||
// Remove use as a template
|
||||
EntityContainerData::query()
|
||||
->where('default_template_id', '=', $page->id)
|
||||
->update(['default_template_id' => null]);
|
||||
|
||||
// Remove chapter template usages
|
||||
$this->queries->chapters->start()
|
||||
->where('default_template_id', '=', $page->id)
|
||||
->update(['default_template_id' => null]);
|
||||
// Nullify uploaded image relations
|
||||
Image::query()
|
||||
->whereIn('type', ['gallery', 'drawio'])
|
||||
->where('uploaded_to', '=', $page->id)
|
||||
->update(['uploaded_to' => null]);
|
||||
|
||||
$page->forceDelete();
|
||||
|
||||
@@ -268,8 +273,8 @@ class TrashCan
|
||||
// exists in the event it has already been destroyed during this request.
|
||||
$entity = $deletion->deletable()->first();
|
||||
$count = 0;
|
||||
if ($entity) {
|
||||
$count = $this->destroyEntity($deletion->deletable);
|
||||
if ($entity instanceof Entity) {
|
||||
$count = $this->destroyEntity($entity);
|
||||
}
|
||||
$deletion->delete();
|
||||
|
||||
@@ -398,9 +403,11 @@ class TrashCan
|
||||
$entity->referencesTo()->delete();
|
||||
$entity->referencesFrom()->delete();
|
||||
|
||||
if ($entity instanceof CoverImageInterface && $entity->cover()->exists()) {
|
||||
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
|
||||
$imageService = app()->make(ImageService::class);
|
||||
$imageService->destroy($entity->cover()->first());
|
||||
$imageService->destroy($entity->coverInfo()->getImage());
|
||||
}
|
||||
|
||||
$entity->relatedData()->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ class ExportFormatter
|
||||
public function bookToPlainText(Book $book): string
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$text = $book->name . "\n" . $book->description;
|
||||
$text = $book->name . "\n" . $book->descriptionInfo()->getPlain();
|
||||
$text = rtrim($text) . "\n\n";
|
||||
|
||||
$parts = [];
|
||||
@@ -318,7 +318,7 @@ class ExportFormatter
|
||||
{
|
||||
$text = '# ' . $chapter->name . "\n\n";
|
||||
|
||||
$description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert();
|
||||
$description = (new HtmlToMarkdown($chapter->descriptionInfo()->getHtml()))->convert();
|
||||
if ($description) {
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
@@ -338,7 +338,7 @@ class ExportFormatter
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$text = '# ' . $book->name . "\n\n";
|
||||
|
||||
$description = (new HtmlToMarkdown($book->descriptionHtml()))->convert();
|
||||
$description = (new HtmlToMarkdown($book->descriptionInfo()->getHtml()))->convert();
|
||||
if ($description) {
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
|
||||
@@ -55,10 +55,10 @@ final class ZipExportBook extends ZipExportModel
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->description_html = $model->descriptionHtml();
|
||||
$instance->description_html = $model->descriptionInfo()->getHtml();
|
||||
|
||||
if ($model->cover) {
|
||||
$instance->cover = $files->referenceForImage($model->cover);
|
||||
if ($model->coverInfo()->exists()) {
|
||||
$instance->cover = $files->referenceForImage($model->coverInfo()->getImage());
|
||||
}
|
||||
|
||||
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
|
||||
|
||||
@@ -40,7 +40,7 @@ final class ZipExportChapter extends ZipExportModel
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->description_html = $model->descriptionHtml();
|
||||
$instance->description_html = $model->descriptionInfo()->getHtml();
|
||||
$instance->priority = $model->priority;
|
||||
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
|
||||
class ZipExportReferences
|
||||
{
|
||||
@@ -33,6 +34,7 @@ class ZipExportReferences
|
||||
|
||||
public function __construct(
|
||||
protected ZipReferenceParser $parser,
|
||||
protected ImageService $imageService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -133,10 +135,17 @@ class ZipExportReferences
|
||||
return "[[bsexport:image:{$model->id}]]";
|
||||
}
|
||||
|
||||
// Find and include images if in visibility
|
||||
// Get the page which we'll reference this image upon
|
||||
$page = $model->getPage();
|
||||
$pageExportModel = $this->pages[$page->id] ?? ($exportModel instanceof ZipExportPage ? $exportModel : null);
|
||||
if (isset($this->images[$model->id]) || ($page && $pageExportModel && userCan(Permission::PageView, $page))) {
|
||||
$pageExportModel = null;
|
||||
if ($page && isset($this->pages[$page->id])) {
|
||||
$pageExportModel = $this->pages[$page->id];
|
||||
} elseif ($exportModel instanceof ZipExportPage) {
|
||||
$pageExportModel = $exportModel;
|
||||
}
|
||||
|
||||
// Add the image to the export if it's accessible or just return the existing reference if already added
|
||||
if (isset($this->images[$model->id]) || ($pageExportModel && $this->imageService->imageAccessible($model))) {
|
||||
if (!isset($this->images[$model->id])) {
|
||||
$exportImage = ZipExportImage::fromModel($model, $files);
|
||||
$this->images[$model->id] = $exportImage;
|
||||
@@ -144,6 +153,7 @@ class ZipExportReferences
|
||||
}
|
||||
return "[[bsexport:image:{$model->id}]]";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -135,8 +135,8 @@ class ZipImportRunner
|
||||
'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
|
||||
]);
|
||||
|
||||
if ($book->cover) {
|
||||
$this->references->addImage($book->cover, null);
|
||||
if ($book->coverInfo()->getImage()) {
|
||||
$this->references->addImage($book->coverInfo()->getImage(), null);
|
||||
}
|
||||
|
||||
$children = [
|
||||
@@ -197,8 +197,8 @@ class ZipImportRunner
|
||||
|
||||
$this->pageRepo->publishDraft($page, [
|
||||
'name' => $exportPage->name,
|
||||
'markdown' => $exportPage->markdown,
|
||||
'html' => $exportPage->html,
|
||||
'markdown' => $exportPage->markdown ?? '',
|
||||
'html' => $exportPage->html ?? '',
|
||||
'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
|
||||
]);
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ use Illuminate\Http\JsonResponse;
|
||||
|
||||
abstract class ApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* The validation rules for this controller.
|
||||
* Can alternative be defined in a rules() method is they need to be dynamic.
|
||||
*
|
||||
* @var array<string, array<string, string[]>>
|
||||
*/
|
||||
protected array $rules = [];
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,9 +48,7 @@ enum Permission: string
|
||||
case AttachmentUpdateAll = 'attachment-update-all';
|
||||
case AttachmentUpdateOwn = 'attachment-update-own';
|
||||
|
||||
case CommentCreate = 'comment-create';
|
||||
case CommentCreateAll = 'comment-create-all';
|
||||
case CommentCreateOwn = 'comment-create-own';
|
||||
case CommentDelete = 'comment-delete';
|
||||
case CommentDeleteAll = 'comment-delete-all';
|
||||
case CommentDeleteOwn = 'comment-delete-own';
|
||||
|
||||
@@ -40,10 +40,6 @@ class PermissionApplicator
|
||||
$ownerField = $ownable->getOwnerFieldName();
|
||||
$ownableFieldVal = $ownable->getAttribute($ownerField);
|
||||
|
||||
if (is_null($ownableFieldVal)) {
|
||||
throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
|
||||
}
|
||||
|
||||
$isOwner = $user->id === $ownableFieldVal;
|
||||
$hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
|
||||
|
||||
@@ -144,10 +140,10 @@ class PermissionApplicator
|
||||
/** @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'])
|
||||
$query->select('page_id')->from('entity_page_data')
|
||||
->whereColumn('entity_page_data.page_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
|
||||
->where('pages.draft', '=', false);
|
||||
->where('entity_page_data.draft', '=', false);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -197,18 +193,18 @@ class PermissionApplicator
|
||||
{
|
||||
$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);
|
||||
})->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $fullPageIdColumn)
|
||||
->where('pages.draft', '=', true)
|
||||
->where('pages.created_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
|
||||
$query->select('id')->from('entities')
|
||||
->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id')
|
||||
->whereColumn('entities.id', '=', $fullPageIdColumn)
|
||||
->where('entities.type', '=', 'page')
|
||||
->where(function (QueryBuilder $query) {
|
||||
$query->where('entity_page_data.draft', '=', false)
|
||||
->orWhere(function (QueryBuilder $query) {
|
||||
$query->where('entity_page_data.draft', '=', true)
|
||||
->where('entities.created_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ class ReferenceFetcher
|
||||
* Query and return the references pointing to the given entity.
|
||||
* Loads the commonly required relations while taking permissions into account.
|
||||
*/
|
||||
public function getReferencesToEntity(Entity $entity): Collection
|
||||
public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection
|
||||
{
|
||||
$references = $this->queryReferencesToEntity($entity)->get();
|
||||
$this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', true);
|
||||
$this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents);
|
||||
|
||||
return $references;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
namespace BookStack\References;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\HasDescriptionInterface;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HtmlDescriptionInterface;
|
||||
use BookStack\Entities\Models\HtmlDescriptionTrait;
|
||||
use BookStack\Entities\Models\EntityContainerData;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\RevisionRepo;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
@@ -36,7 +36,7 @@ class ReferenceUpdater
|
||||
protected function getReferencesToUpdate(Entity $entity): array
|
||||
{
|
||||
/** @var Reference[] $references */
|
||||
$references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all();
|
||||
$references = $this->referenceFetcher->getReferencesToEntity($entity, true)->values()->all();
|
||||
|
||||
if ($entity instanceof Book) {
|
||||
$pages = $entity->pages()->get(['id']);
|
||||
@@ -44,7 +44,7 @@ class ReferenceUpdater
|
||||
$children = $pages->concat($chapters);
|
||||
foreach ($children as $bookChild) {
|
||||
/** @var Reference[] $childRefs */
|
||||
$childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all();
|
||||
$childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild, true)->values()->all();
|
||||
array_push($references, ...$childRefs);
|
||||
}
|
||||
}
|
||||
@@ -64,16 +64,16 @@ class ReferenceUpdater
|
||||
$this->updateReferencesWithinPage($entity, $oldLink, $newLink);
|
||||
}
|
||||
|
||||
if ($entity instanceof HtmlDescriptionInterface) {
|
||||
if ($entity instanceof HasDescriptionInterface) {
|
||||
$this->updateReferencesWithinDescription($entity, $oldLink, $newLink);
|
||||
}
|
||||
}
|
||||
|
||||
protected function updateReferencesWithinDescription(Entity&HtmlDescriptionInterface $entity, string $oldLink, string $newLink): void
|
||||
protected function updateReferencesWithinDescription(Entity&HasDescriptionInterface $entity, string $oldLink, string $newLink): void
|
||||
{
|
||||
$entity = (clone $entity)->refresh();
|
||||
$html = $this->updateLinksInHtml($entity->descriptionHtml(true) ?: '', $oldLink, $newLink);
|
||||
$entity->setDescriptionHtml($html);
|
||||
$description = $entity->descriptionInfo();
|
||||
$html = $this->updateLinksInHtml($description->getHtml(true) ?: '', $oldLink, $newLink);
|
||||
$description->set($html);
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Search;
|
||||
|
||||
use BookStack\Api\ApiEntityListFormatter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Http\ApiController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchApiController extends ApiController
|
||||
@@ -31,11 +34,9 @@ class SearchApiController extends ApiController
|
||||
* between: bookshelf, book, chapter & page.
|
||||
*
|
||||
* The paging parameters and response format emulates a standard listing endpoint
|
||||
* but standard sorting and filtering cannot be done on this endpoint. If a count value
|
||||
* is provided this will only be taken as a suggestion. The results in the response
|
||||
* may currently be up to 4x this value.
|
||||
* but standard sorting and filtering cannot be done on this endpoint.
|
||||
*/
|
||||
public function all(Request $request)
|
||||
public function all(Request $request): JsonResponse
|
||||
{
|
||||
$this->validate($request, $this->rules['all']);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use BookStack\Entities\Queries\QueryPopular;
|
||||
use BookStack\Entities\Tools\SiblingFetcher;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
@@ -23,20 +24,21 @@ class SearchController extends Controller
|
||||
{
|
||||
$searchOpts = SearchOptions::fromRequest($request);
|
||||
$fullSearchString = $searchOpts->toString();
|
||||
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
|
||||
|
||||
$page = intval($request->get('page', '0')) ?: 1;
|
||||
$nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1));
|
||||
|
||||
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
|
||||
$formatter->format($results['results']->all(), $searchOpts);
|
||||
$paginator = new LengthAwarePaginator($results['results'], $results['total'], 20, $page);
|
||||
$paginator->setPath('/search');
|
||||
$paginator->appends($request->except('page'));
|
||||
|
||||
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
|
||||
|
||||
return view('search.all', [
|
||||
'entities' => $results['results'],
|
||||
'totalResults' => $results['total'],
|
||||
'paginator' => $paginator,
|
||||
'searchTerm' => $fullSearchString,
|
||||
'hasNextPage' => $results['has_more'],
|
||||
'nextPageLink' => $nextPageLink,
|
||||
'options' => $searchOpts,
|
||||
]);
|
||||
}
|
||||
@@ -128,7 +130,7 @@ class SearchController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Search siblings items in the system.
|
||||
* Search sibling items in the system.
|
||||
*/
|
||||
public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher)
|
||||
{
|
||||
|
||||
@@ -126,7 +126,7 @@ class SearchIndex
|
||||
$termMap = $this->textToTermCountMap($text);
|
||||
|
||||
foreach ($termMap as $term => $count) {
|
||||
$termMap[$term] = floor($count * $scoreAdjustment);
|
||||
$termMap[$term] = intval($count * $scoreAdjustment);
|
||||
}
|
||||
|
||||
return $termMap;
|
||||
|
||||
@@ -91,7 +91,7 @@ class SearchResultsFormatter
|
||||
$offset = 0;
|
||||
$term = mb_strtolower($term);
|
||||
$pos = mb_strpos($text, $term, $offset);
|
||||
while ($pos !== false) {
|
||||
while ($pos !== false && count($matchRefs) < 25) {
|
||||
$end = $pos + mb_strlen($term);
|
||||
$matchRefs[$pos] = $end;
|
||||
$offset = $end;
|
||||
|
||||
@@ -4,16 +4,15 @@ namespace BookStack\Search;
|
||||
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\EntityHydrator;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Search\Options\TagSearchOption;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -22,7 +21,7 @@ use WeakMap;
|
||||
class SearchRunner
|
||||
{
|
||||
/**
|
||||
* Retain a cache of score adjusted terms for specific search options.
|
||||
* Retain a cache of score-adjusted terms for specific search options.
|
||||
*/
|
||||
protected WeakMap $termAdjustmentCache;
|
||||
|
||||
@@ -30,16 +29,15 @@ class SearchRunner
|
||||
protected EntityProvider $entityProvider,
|
||||
protected PermissionApplicator $permissions,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected EntityHydrator $entityHydrator,
|
||||
) {
|
||||
$this->termAdjustmentCache = new WeakMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search all entities in the system.
|
||||
* The provided count is for each entity to search,
|
||||
* Total returned could be larger and not guaranteed.
|
||||
*
|
||||
* @return array{total: int, count: int, has_more: bool, results: Collection<Entity>}
|
||||
* @return array{total: int, results: Collection<Entity>}
|
||||
*/
|
||||
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array
|
||||
{
|
||||
@@ -53,32 +51,13 @@ class SearchRunner
|
||||
$entityTypesToSearch = explode('|', $filterMap['type']);
|
||||
}
|
||||
|
||||
$results = collect();
|
||||
$total = 0;
|
||||
$hasMore = false;
|
||||
|
||||
foreach ($entityTypesToSearch as $entityType) {
|
||||
if (!in_array($entityType, $entityTypes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$searchQuery = $this->buildQuery($searchOpts, $entityType);
|
||||
$entityTotal = $searchQuery->count();
|
||||
$searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityType, $page, $count);
|
||||
|
||||
if ($entityTotal > ($page * $count)) {
|
||||
$hasMore = true;
|
||||
}
|
||||
|
||||
$total += $entityTotal;
|
||||
$results = $results->merge($searchResults);
|
||||
}
|
||||
$searchQuery = $this->buildQuery($searchOpts, $entityTypesToSearch);
|
||||
$total = $searchQuery->count();
|
||||
$results = $this->getPageOfDataFromQuery($searchQuery, $page, $count);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'count' => count($results),
|
||||
'has_more' => $hasMore,
|
||||
'results' => $results->sortByDesc('score')->values(),
|
||||
'results' => $results->values(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -92,17 +71,10 @@ class SearchRunner
|
||||
$filterMap = $opts->filters->toValueMap();
|
||||
$entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes;
|
||||
|
||||
$results = collect();
|
||||
foreach ($entityTypesToSearch as $entityType) {
|
||||
if (!in_array($entityType, $entityTypes)) {
|
||||
continue;
|
||||
}
|
||||
$filteredTypes = array_intersect($entityTypesToSearch, $entityTypes);
|
||||
$query = $this->buildQuery($opts, $filteredTypes)->where('book_id', '=', $bookId);
|
||||
|
||||
$search = $this->buildQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
|
||||
$results = $results->merge($search);
|
||||
}
|
||||
|
||||
return $results->sortByDesc('score')->take(20);
|
||||
return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,54 +83,45 @@ class SearchRunner
|
||||
public function searchChapter(int $chapterId, string $searchString): Collection
|
||||
{
|
||||
$opts = SearchOptions::fromString($searchString);
|
||||
$pages = $this->buildQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
|
||||
$query = $this->buildQuery($opts, ['page'])->where('chapter_id', '=', $chapterId);
|
||||
|
||||
return $pages->sortByDesc('score');
|
||||
return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a page of result data from the given query based on the provided page parameters.
|
||||
*/
|
||||
protected function getPageOfDataFromQuery(EloquentBuilder $query, string $entityType, int $page = 1, int $count = 20): EloquentCollection
|
||||
protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int $count): Collection
|
||||
{
|
||||
$relations = ['tags'];
|
||||
|
||||
if ($entityType === 'page' || $entityType === 'chapter') {
|
||||
$relations['book'] = function (BelongsTo $query) {
|
||||
$query->scopes('visible');
|
||||
};
|
||||
}
|
||||
|
||||
if ($entityType === 'page') {
|
||||
$relations['chapter'] = function (BelongsTo $query) {
|
||||
$query->scopes('visible');
|
||||
};
|
||||
}
|
||||
|
||||
return $query->clone()
|
||||
->with(array_filter($relations))
|
||||
$entities = $query->clone()
|
||||
->skip(($page - 1) * $count)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
$hydrated = $this->entityHydrator->hydrate($entities->all(), true, true);
|
||||
|
||||
return collect($hydrated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a search query for an entity.
|
||||
* @param string[] $entityTypes
|
||||
*/
|
||||
protected function buildQuery(SearchOptions $searchOpts, string $entityType): EloquentBuilder
|
||||
protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): EloquentBuilder
|
||||
{
|
||||
$entityModelInstance = $this->entityProvider->get($entityType);
|
||||
$entityQuery = $this->entityQueries->visibleForList($entityType);
|
||||
$entityQuery = $this->entityQueries->visibleForList()
|
||||
->whereIn('type', $entityTypes);
|
||||
|
||||
// Handle normal search terms
|
||||
$this->applyTermSearch($entityQuery, $searchOpts, $entityType);
|
||||
$this->applyTermSearch($entityQuery, $searchOpts, $entityTypes);
|
||||
|
||||
// Handle exact term matching
|
||||
foreach ($searchOpts->exacts->all() as $exact) {
|
||||
$filter = function (EloquentBuilder $query) use ($exact, $entityModelInstance) {
|
||||
$filter = function (EloquentBuilder $query) use ($exact) {
|
||||
$inputTerm = str_replace('\\', '\\\\', $exact->value);
|
||||
$query->where('name', 'like', '%' . $inputTerm . '%')
|
||||
->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%');
|
||||
->orWhere('description', 'like', '%' . $inputTerm . '%')
|
||||
->orWhere('text', 'like', '%' . $inputTerm . '%');
|
||||
};
|
||||
|
||||
$exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter);
|
||||
@@ -173,7 +136,7 @@ class SearchRunner
|
||||
foreach ($searchOpts->filters->all() as $filterOption) {
|
||||
$functionName = Str::camel('filter_' . $filterOption->getKey());
|
||||
if (method_exists($this, $functionName)) {
|
||||
$this->$functionName($entityQuery, $entityModelInstance, $filterOption->value, $filterOption->negated);
|
||||
$this->$functionName($entityQuery, $filterOption->value, $filterOption->negated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +146,7 @@ class SearchRunner
|
||||
/**
|
||||
* For the given search query, apply the queries for handling the regular search terms.
|
||||
*/
|
||||
protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, string $entityType): void
|
||||
protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, array $entityTypes): void
|
||||
{
|
||||
$terms = $options->searches->toValueArray();
|
||||
if (count($terms) === 0) {
|
||||
@@ -200,8 +163,6 @@ class SearchRunner
|
||||
]);
|
||||
|
||||
$subQuery->addBinding($scoreSelect['bindings'], 'select');
|
||||
|
||||
$subQuery->where('entity_type', '=', $entityType);
|
||||
$subQuery->where(function (Builder $query) use ($terms) {
|
||||
foreach ($terms as $inputTerm) {
|
||||
$escapedTerm = str_replace('\\', '\\\\', $inputTerm);
|
||||
@@ -210,7 +171,10 @@ class SearchRunner
|
||||
});
|
||||
$subQuery->groupBy('entity_type', 'entity_id');
|
||||
|
||||
$entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id');
|
||||
$entityQuery->joinSub($subQuery, 's', function (JoinClause $join) {
|
||||
$join->on('s.entity_id', '=', 'entities.id')
|
||||
->on('s.entity_type', '=', 'entities.type');
|
||||
});
|
||||
$entityQuery->addSelect('s.score');
|
||||
$entityQuery->orderBy('score', 'desc');
|
||||
}
|
||||
@@ -338,7 +302,7 @@ class SearchRunner
|
||||
$option->negated ? $query->whereDoesntHave('tags', $filter) : $query->whereHas('tags', $filter);
|
||||
}
|
||||
|
||||
protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string $column, string $operator, mixed $value): void
|
||||
protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string|callable $column, string|null $operator, mixed $value): void
|
||||
{
|
||||
if ($negated) {
|
||||
$query->whereNot($column, $operator, $value);
|
||||
@@ -350,31 +314,31 @@ class SearchRunner
|
||||
/**
|
||||
* Custom entity search filters.
|
||||
*/
|
||||
protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
|
||||
protected function filterUpdatedAfter(EloquentBuilder $query, string $input, bool $negated): void
|
||||
{
|
||||
$date = date_create($input);
|
||||
$this->applyNegatableWhere($query, $negated, 'updated_at', '>=', $date);
|
||||
}
|
||||
|
||||
protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
|
||||
protected function filterUpdatedBefore(EloquentBuilder $query, string $input, bool $negated): void
|
||||
{
|
||||
$date = date_create($input);
|
||||
$this->applyNegatableWhere($query, $negated, 'updated_at', '<', $date);
|
||||
}
|
||||
|
||||
protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
|
||||
protected function filterCreatedAfter(EloquentBuilder $query, string $input, bool $negated): void
|
||||
{
|
||||
$date = date_create($input);
|
||||
$this->applyNegatableWhere($query, $negated, 'created_at', '>=', $date);
|
||||
}
|
||||
|
||||
protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
protected function filterCreatedBefore(EloquentBuilder $query, string $input, bool $negated)
|
||||
{
|
||||
$date = date_create($input);
|
||||
$this->applyNegatableWhere($query, $negated, 'created_at', '<', $date);
|
||||
}
|
||||
|
||||
protected function filterCreatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
protected function filterCreatedBy(EloquentBuilder $query, string $input, bool $negated)
|
||||
{
|
||||
$userSlug = $input === 'me' ? user()->slug : trim($input);
|
||||
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
|
||||
@@ -383,7 +347,7 @@ class SearchRunner
|
||||
}
|
||||
}
|
||||
|
||||
protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
protected function filterUpdatedBy(EloquentBuilder $query, string $input, bool $negated)
|
||||
{
|
||||
$userSlug = $input === 'me' ? user()->slug : trim($input);
|
||||
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
|
||||
@@ -392,7 +356,7 @@ class SearchRunner
|
||||
}
|
||||
}
|
||||
|
||||
protected function filterOwnedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
protected function filterOwnedBy(EloquentBuilder $query, string $input, bool $negated)
|
||||
{
|
||||
$userSlug = $input === 'me' ? user()->slug : trim($input);
|
||||
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
|
||||
@@ -401,27 +365,30 @@ class SearchRunner
|
||||
}
|
||||
}
|
||||
|
||||
protected function filterInName(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
protected function filterInName(EloquentBuilder $query, string $input, bool $negated)
|
||||
{
|
||||
$this->applyNegatableWhere($query, $negated, 'name', 'like', '%' . $input . '%');
|
||||
}
|
||||
|
||||
protected function filterInTitle(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
protected function filterInTitle(EloquentBuilder $query, string $input, bool $negated)
|
||||
{
|
||||
$this->filterInName($query, $model, $input, $negated);
|
||||
$this->filterInName($query, $input, $negated);
|
||||
}
|
||||
|
||||
protected function filterInBody(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
protected function filterInBody(EloquentBuilder $query, string $input, bool $negated)
|
||||
{
|
||||
$this->applyNegatableWhere($query, $negated, $model->textField, 'like', '%' . $input . '%');
|
||||
$this->applyNegatableWhere($query, $negated, function (EloquentBuilder $query) use ($input) {
|
||||
$query->where('description', 'like', '%' . $input . '%')
|
||||
->orWhere('text', 'like', '%' . $input . '%');
|
||||
}, null, null);
|
||||
}
|
||||
|
||||
protected function filterIsRestricted(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
protected function filterIsRestricted(EloquentBuilder $query, string $input, bool $negated)
|
||||
{
|
||||
$negated ? $query->whereDoesntHave('permissions') : $query->whereHas('permissions');
|
||||
}
|
||||
|
||||
protected function filterViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
protected function filterViewedByMe(EloquentBuilder $query, string $input, bool $negated)
|
||||
{
|
||||
$filter = function ($query) {
|
||||
$query->where('user_id', '=', user()->id);
|
||||
@@ -430,7 +397,7 @@ class SearchRunner
|
||||
$negated ? $query->whereDoesntHave('views', $filter) : $query->whereHas('views', $filter);
|
||||
}
|
||||
|
||||
protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
protected function filterNotViewedByMe(EloquentBuilder $query, string $input, bool $negated)
|
||||
{
|
||||
$filter = function ($query) {
|
||||
$query->where('user_id', '=', user()->id);
|
||||
@@ -439,31 +406,30 @@ class SearchRunner
|
||||
$negated ? $query->whereHas('views', $filter) : $query->whereDoesntHave('views', $filter);
|
||||
}
|
||||
|
||||
protected function filterIsTemplate(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
protected function filterIsTemplate(EloquentBuilder $query, string $input, bool $negated)
|
||||
{
|
||||
if ($model instanceof Page) {
|
||||
$this->applyNegatableWhere($query, $negated, 'template', '=', true);
|
||||
}
|
||||
$this->applyNegatableWhere($query, $negated, 'template', '=', true);
|
||||
}
|
||||
|
||||
protected function filterSortBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
protected function filterSortBy(EloquentBuilder $query, string $input, bool $negated)
|
||||
{
|
||||
$functionName = Str::camel('sort_by_' . $input);
|
||||
if (method_exists($this, $functionName)) {
|
||||
$this->$functionName($query, $model, $negated);
|
||||
$this->$functionName($query, $negated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorting filter options.
|
||||
*/
|
||||
protected function sortByLastCommented(EloquentBuilder $query, Entity $model, bool $negated)
|
||||
protected function sortByLastCommented(EloquentBuilder $query, bool $negated)
|
||||
{
|
||||
$commentsTable = DB::getTablePrefix() . 'comments';
|
||||
$morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
|
||||
$commentQuery = DB::raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments');
|
||||
$commentQuery = DB::raw('(SELECT c1.commentable_id, c1.commentable_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.commentable_id = c2.commentable_id AND c1.commentable_type = c2.commentable_type AND c1.created_at < c2.created_at) WHERE c2.created_at IS NULL) as comments');
|
||||
|
||||
$query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.entity_id'))
|
||||
->orderBy('last_commented', $negated ? 'asc' : 'desc');
|
||||
$query->join($commentQuery, function (JoinClause $join) {
|
||||
$join->on('entities.id', '=', 'comments.commentable_id')
|
||||
->on('entities.type', '=', 'comments.commentable_type');
|
||||
})->orderBy('last_commented', $negated ? 'asc' : 'desc');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,22 +33,22 @@ class BookSorter
|
||||
*/
|
||||
public function runBookAutoSort(Book $book): void
|
||||
{
|
||||
$set = $book->sortRule;
|
||||
if (!$set) {
|
||||
$rule = $book->sortRule()->first();
|
||||
if (!($rule instanceof SortRule)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sortFunctions = array_map(function (SortRuleOperation $op) {
|
||||
return $op->getSortFunction();
|
||||
}, $set->getOperations());
|
||||
}, $rule->getOperations());
|
||||
|
||||
$chapters = $book->chapters()
|
||||
->with('pages:id,name,priority,created_at,updated_at,chapter_id')
|
||||
->with('pages:id,name,book_id,chapter_id,priority,created_at,updated_at')
|
||||
->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
|
||||
|
||||
/** @var (Chapter|Book)[] $topItems */
|
||||
$topItems = [
|
||||
...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
|
||||
...$book->directPages()->get(['id', 'book_id', 'name', 'priority', 'created_at', 'updated_at']),
|
||||
...$chapters,
|
||||
];
|
||||
|
||||
@@ -155,11 +155,12 @@ class BookSorter
|
||||
|
||||
// Action the required changes
|
||||
if ($bookChanged) {
|
||||
$model->changeBook($newBook->id);
|
||||
$model = $model->changeBook($newBook->id);
|
||||
}
|
||||
|
||||
if ($model instanceof Page && $chapterChanged) {
|
||||
$model->chapter_id = $newChapter->id ?? 0;
|
||||
$model->unsetRelation('chapter');
|
||||
}
|
||||
|
||||
if ($priorityChanged) {
|
||||
|
||||
@@ -50,7 +50,7 @@ class SortRule extends Model implements Loggable
|
||||
|
||||
public function books(): HasMany
|
||||
{
|
||||
return $this->hasMany(Book::class);
|
||||
return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id');
|
||||
}
|
||||
|
||||
public static function allByName(): Collection
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Sorting;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Models\EntityContainerData;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -88,7 +89,9 @@ class SortRuleController extends Controller
|
||||
|
||||
if ($booksAssigned > 0) {
|
||||
if ($confirmed) {
|
||||
$rule->books()->update(['sort_rule_id' => null]);
|
||||
EntityContainerData::query()
|
||||
->where('sort_rule_id', $rule->id)
|
||||
->update(['sort_rule_id' => null]);
|
||||
} else {
|
||||
$warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Uploads\Controllers;
|
||||
|
||||
use BookStack\Entities\EntityExistsRule;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use BookStack\Http\ApiController;
|
||||
@@ -173,13 +174,13 @@ class AttachmentApiController extends ApiController
|
||||
return [
|
||||
'create' => [
|
||||
'name' => ['required', 'string', 'min:1', 'max:255'],
|
||||
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
|
||||
'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')],
|
||||
'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
|
||||
'link' => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'],
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'uploaded_to' => ['integer', 'exists:pages,id'],
|
||||
'uploaded_to' => ['integer', new EntityExistsRule('page')],
|
||||
'file' => $this->attachmentService->getFileValidationRules(),
|
||||
'link' => ['string', 'min:1', 'max:2000', 'safe_url'],
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Uploads\Controllers;
|
||||
|
||||
use BookStack\Entities\EntityExistsRule;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
@@ -34,7 +35,7 @@ class AttachmentController extends Controller
|
||||
public function upload(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
|
||||
'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')],
|
||||
'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
|
||||
]);
|
||||
|
||||
@@ -144,7 +145,7 @@ class AttachmentController extends Controller
|
||||
|
||||
try {
|
||||
$this->validate($request, [
|
||||
'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'],
|
||||
'attachment_link_uploaded_to' => ['required', 'integer', new EntityExistsRule('page')],
|
||||
'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'],
|
||||
'attachment_link_url' => ['required', 'string', 'min:1', 'max:2000', 'safe_url'],
|
||||
]);
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
namespace BookStack\Uploads\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageResizer;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ImageGalleryApiController extends ApiController
|
||||
@@ -20,6 +22,7 @@ class ImageGalleryApiController extends ApiController
|
||||
protected ImageRepo $imageRepo,
|
||||
protected ImageResizer $imageResizer,
|
||||
protected PageQueries $pageQueries,
|
||||
protected ImageService $imageService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -32,6 +35,9 @@ class ImageGalleryApiController extends ApiController
|
||||
'image' => ['required', 'file', ...$this->getImageValidationRules()],
|
||||
'name' => ['string', 'max:180'],
|
||||
],
|
||||
'readDataForUrl' => [
|
||||
'url' => ['required', 'string', 'url'],
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'max:180'],
|
||||
'image' => ['file', ...$this->getImageValidationRules()],
|
||||
@@ -85,7 +91,8 @@ class ImageGalleryApiController extends ApiController
|
||||
* The "thumbs" response property contains links to scaled variants that BookStack may use in its UI.
|
||||
* The "content" response property provides HTML and Markdown content, in the format that BookStack
|
||||
* would typically use by default to add the image in page content, as a convenience.
|
||||
* Actual image file data is not provided but can be fetched via the "url" response property.
|
||||
* Actual image file data is not provided but can be fetched via the "url" response property or by
|
||||
* using the "read-data" endpoint.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
@@ -94,6 +101,37 @@ class ImageGalleryApiController extends ApiController
|
||||
return response()->json($this->formatForSingleResponse($image));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the image file data for a single image in the system.
|
||||
* The returned response will be a stream of image data instead of a JSON response.
|
||||
*/
|
||||
public function readData(string $id)
|
||||
{
|
||||
$image = Image::query()->scopes(['visible'])->findOrFail($id);
|
||||
|
||||
return $this->imageService->streamImageFromStorageResponse('gallery', $image->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the image file data for a single image in the system, using the provided URL
|
||||
* to identify the image instead of its ID, which is provided as a "URL" query parameter.
|
||||
* The returned response will be a stream of image data instead of a JSON response.
|
||||
*/
|
||||
public function readDataForUrl(Request $request)
|
||||
{
|
||||
$data = $this->validate($request, $this->rules()['readDataForUrl']);
|
||||
$basePath = url('/uploads/images/');
|
||||
$imagePath = str_replace($basePath, '', $data['url']);
|
||||
|
||||
if (!$this->imageService->pathAccessible($imagePath)) {
|
||||
throw (new NotFoundException(trans('errors.image_not_found')))
|
||||
->setSubtitle(trans('errors.image_not_found_subtitle'))
|
||||
->setDetails(trans('errors.image_not_found_details'));
|
||||
}
|
||||
|
||||
return $this->imageService->streamImageFromStorageResponse('gallery', $imagePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the details of an existing image in the system.
|
||||
* Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a
|
||||
|
||||
@@ -13,14 +13,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $url
|
||||
* @property string $path
|
||||
* @property string $type
|
||||
* @property int $uploaded_to
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $url
|
||||
* @property string $path
|
||||
* @property string $type
|
||||
* @property int|null $uploaded_to
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
*/
|
||||
class Image extends Model implements OwnableInterface
|
||||
{
|
||||
@@ -42,7 +42,9 @@ class Image extends Model implements OwnableInterface
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return app()->make(PermissionApplicator::class)->restrictPageRelationQuery($query, 'images', 'uploaded_to');
|
||||
return app()->make(PermissionApplicator::class)
|
||||
->restrictPageRelationQuery($query, 'images', 'uploaded_to')
|
||||
->whereIn('type', ['gallery', 'drawio']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -148,7 +148,7 @@ class ImageService
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an image along with its revisions, thumbnails and remaining folders.
|
||||
* Destroy an image along with its revisions, thumbnails, and remaining folders.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -184,7 +184,7 @@ class ImageService
|
||||
/** @var Image $image */
|
||||
foreach ($images as $image) {
|
||||
$searchQuery = '%' . basename($image->path) . '%';
|
||||
$inPage = DB::table('pages')
|
||||
$inPage = DB::table('entity_page_data')
|
||||
->where('html', 'like', $searchQuery)->count() > 0;
|
||||
|
||||
$inRevision = false;
|
||||
@@ -252,16 +252,48 @@ class ImageService
|
||||
{
|
||||
$disk = $this->storage->getDisk('gallery');
|
||||
|
||||
return $disk->usingSecureImages() && $this->pathAccessible($imagePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given path exists and is accessible depending on the current settings.
|
||||
*/
|
||||
public function pathAccessible(string $imagePath): bool
|
||||
{
|
||||
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check local_secure is active
|
||||
return $disk->usingSecureImages()
|
||||
// Check the image file exists
|
||||
&& $disk->exists($imagePath)
|
||||
// Check the file is likely an image file
|
||||
&& str_starts_with($disk->mimeType($imagePath), 'image/');
|
||||
if ($this->storage->usingSecureImages() && user()->isGuest()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->imageFileExists($imagePath, 'gallery');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given image should be accessible to the current user.
|
||||
*/
|
||||
public function imageAccessible(Image $image): bool
|
||||
{
|
||||
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImage($image)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->storage->usingSecureImages() && user()->isGuest()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->imageFileExists($image->path, $image->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given image path exists for the given image type and that it is likely an image file.
|
||||
*/
|
||||
protected function imageFileExists(string $imagePath, string $imageType): bool
|
||||
{
|
||||
$disk = $this->storage->getDisk($imageType);
|
||||
return $disk->exists($imagePath) && str_starts_with($disk->mimeType($imagePath), 'image/');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,6 +322,11 @@ class ImageService
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->checkUserHasAccessToRelationOfImage($image);
|
||||
}
|
||||
|
||||
protected function checkUserHasAccessToRelationOfImage(Image $image): bool
|
||||
{
|
||||
$imageType = $image->type;
|
||||
|
||||
// Allow user or system (logo) images
|
||||
|
||||
@@ -34,6 +34,15 @@ class ImageStorage
|
||||
return config('filesystems.images') === 'local_secure_restricted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if "local secure" (Fetched behind auth, either with or without permissions enforced)
|
||||
* is currently active in the instance.
|
||||
*/
|
||||
public function usingSecureImages(): bool
|
||||
{
|
||||
return config('filesystems.images') === 'local_secure' || $this->usingSecureRestrictedImages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up an image file name to be both URL and storage safe.
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Users\Controllers;
|
||||
|
||||
use BookStack\Entities\EntityExistsRule;
|
||||
use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
|
||||
@@ -31,8 +31,6 @@ use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Class User.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $slug
|
||||
|
||||
@@ -5,13 +5,13 @@ namespace BookStack\Users;
|
||||
use BookStack\Access\UserInviteException;
|
||||
use BookStack\Access\UserInviteService;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\UserAvatars;
|
||||
use BookStack\Users\Models\Role;
|
||||
use BookStack\Users\Models\User;
|
||||
use DB;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -25,7 +25,6 @@ class UserRepo
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get a user by their email address.
|
||||
*/
|
||||
@@ -159,15 +158,12 @@ class UserRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(User $user, ?int $newOwnerId = null)
|
||||
public function destroy(User $user, ?int $newOwnerId = null): void
|
||||
{
|
||||
$this->ensureDeletable($user);
|
||||
|
||||
$user->socialAccounts()->delete();
|
||||
$user->apiTokens()->delete();
|
||||
$user->favourites()->delete();
|
||||
$user->mfaValues()->delete();
|
||||
$user->watches()->delete();
|
||||
$this->removeUserDependantRelations($user);
|
||||
$this->nullifyUserNonDependantRelations($user);
|
||||
$user->delete();
|
||||
|
||||
// Delete user profile images
|
||||
@@ -176,16 +172,52 @@ class UserRepo
|
||||
// Delete related activities
|
||||
setting()->deleteUserSettings($user->id);
|
||||
|
||||
// Migrate or nullify ownership
|
||||
$newOwner = null;
|
||||
if (!empty($newOwnerId)) {
|
||||
$newOwner = User::query()->find($newOwnerId);
|
||||
if (!is_null($newOwner)) {
|
||||
$this->migrateOwnership($user, $newOwner);
|
||||
}
|
||||
}
|
||||
$this->migrateOwnership($user, $newOwner);
|
||||
|
||||
Activity::add(ActivityType::USER_DELETE, $user);
|
||||
}
|
||||
|
||||
protected function removeUserDependantRelations(User $user): void
|
||||
{
|
||||
$user->apiTokens()->delete();
|
||||
$user->socialAccounts()->delete();
|
||||
$user->favourites()->delete();
|
||||
$user->mfaValues()->delete();
|
||||
$user->watches()->delete();
|
||||
|
||||
$tables = ['email_confirmations', 'user_invites', 'views'];
|
||||
foreach ($tables as $table) {
|
||||
DB::table($table)->where('user_id', '=', $user->id)->delete();
|
||||
}
|
||||
}
|
||||
protected function nullifyUserNonDependantRelations(User $user): void
|
||||
{
|
||||
$toNullify = [
|
||||
'attachments' => ['created_by', 'updated_by'],
|
||||
'comments' => ['created_by', 'updated_by'],
|
||||
'deletions' => ['deleted_by'],
|
||||
'entities' => ['created_by', 'updated_by'],
|
||||
'images' => ['created_by', 'updated_by'],
|
||||
'imports' => ['created_by'],
|
||||
'joint_permissions' => ['owner_id'],
|
||||
'page_revisions' => ['created_by'],
|
||||
'sessions' => ['user_id'],
|
||||
];
|
||||
|
||||
foreach ($toNullify as $table => $columns) {
|
||||
foreach ($columns as $column) {
|
||||
DB::table($table)
|
||||
->where($column, '=', $user->id)
|
||||
->update([$column => null]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotifyException
|
||||
*/
|
||||
@@ -203,13 +235,12 @@ class UserRepo
|
||||
/**
|
||||
* Migrate ownership of items in the system from one user to another.
|
||||
*/
|
||||
protected function migrateOwnership(User $fromUser, User $toUser)
|
||||
protected function migrateOwnership(User $fromUser, User|null $toUser): void
|
||||
{
|
||||
$entities = (new EntityProvider())->all();
|
||||
foreach ($entities as $instance) {
|
||||
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
|
||||
->update(['owned_by' => $toUser->id]);
|
||||
}
|
||||
$newOwnerValue = $toUser ? $toUser->id : null;
|
||||
DB::table('entities')
|
||||
->where('owned_by', '=', $fromUser->id)
|
||||
->update(['owned_by' => $newOwnerValue]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,7 +278,7 @@ class UserRepo
|
||||
*
|
||||
* @throws UserUpdateException
|
||||
*/
|
||||
protected function setUserRoles(User $user, array $roles)
|
||||
protected function setUserRoles(User $user, array $roles): void
|
||||
{
|
||||
$roles = array_filter(array_values($roles));
|
||||
|
||||
@@ -260,7 +291,7 @@ class UserRepo
|
||||
|
||||
/**
|
||||
* Check if the given user is the last admin and their new roles no longer
|
||||
* contains the admin role.
|
||||
* contain the admin role.
|
||||
*/
|
||||
protected function demotingLastAdmin(User $user, array $newRoles): bool
|
||||
{
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"@php artisan view:clear"
|
||||
],
|
||||
"refresh-test-database": [
|
||||
"@putenv APP_TIMEZONE=UTC",
|
||||
"@php artisan migrate:refresh --database=mysql_testing",
|
||||
"@php artisan db:seed --class=DummyContentSeeder --database=mysql_testing"
|
||||
]
|
||||
|
||||
522
composer.lock
generated
522
composer.lock
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user