mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-24 19:07:20 +03:00
Compare commits
130 Commits
v25.11.2
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a12e3a8b7 | ||
|
|
6808292c90 | ||
|
|
c10b0fd5b9 | ||
|
|
1077a4efd0 | ||
|
|
23f3f35f6b | ||
|
|
229a99ba24 | ||
|
|
8e99fc6783 | ||
|
|
80204518a2 | ||
|
|
a8d96fd389 | ||
|
|
9d15c79fee | ||
|
|
e1de1f0583 | ||
|
|
a2017ffa55 | ||
|
|
9646339933 | ||
|
|
e4383765e1 | ||
|
|
5d547fcf4c | ||
|
|
826b36c985 | ||
|
|
3fa1174e7a | ||
|
|
50e8501027 | ||
|
|
8a221f64e4 | ||
|
|
035be66ebc | ||
|
|
227027fc45 | ||
|
|
0f040fe8b1 | ||
|
|
10ebe53bd9 | ||
|
|
5e12b678c7 | ||
|
|
057d7be0bc | ||
|
|
984a73159f | ||
|
|
a20438b901 | ||
|
|
9d3d0a4a07 | ||
|
|
5038d124e1 | ||
|
|
f7890c2dd9 | ||
|
|
45ae03ceac | ||
|
|
aa0a8dda11 | ||
|
|
120ee38383 | ||
|
|
cd84074cdf | ||
|
|
4949520194 | ||
|
|
46dcc30bf7 | ||
|
|
9f7d3b55dd | ||
|
|
3e5e88dc87 | ||
|
|
c77a0fdff3 | ||
|
|
6a63b38bb3 | ||
|
|
1b17bb3929 | ||
|
|
9fcfc762ec | ||
|
|
c32b1686a9 | ||
|
|
36649a6188 | ||
|
|
ff59bbdc07 | ||
|
|
4dc443b7df | ||
|
|
19f02d927e | ||
|
|
da7bedd2e4 | ||
|
|
20db372596 | ||
|
|
43eed1660c | ||
|
|
e6b754fad0 | ||
|
|
018de5def3 | ||
|
|
5c4fc3dc2c | ||
|
|
07ec880e33 | ||
|
|
ab436ed5c3 | ||
|
|
082befb2fc | ||
|
|
b0a8cb0c5d | ||
|
|
b08d1b36de | ||
|
|
88d86df66f | ||
|
|
38d3697246 | ||
|
|
d93354ff0e | ||
|
|
3336e0c6ae | ||
|
|
8fc9a2af4e | ||
|
|
8aec571123 | ||
|
|
382f4db276 | ||
|
|
d504b19143 | ||
|
|
d87e8d05c7 | ||
|
|
0b48361780 | ||
|
|
2de3247ae4 | ||
|
|
48df2be0d8 | ||
|
|
a4c0556551 | ||
|
|
a941d1b403 | ||
|
|
51f9b63db0 | ||
|
|
90fc02c57f | ||
|
|
4aeb571126 | ||
|
|
3d9aba7b1f | ||
|
|
48cdaab690 | ||
|
|
4f760479c3 | ||
|
|
9211062e8e | ||
|
|
221c6c7e9f | ||
|
|
e2f91c2bbb | ||
|
|
147ff00c7a | ||
|
|
1e768ce33f | ||
|
|
313326b32a | ||
|
|
1d87b513be | ||
|
|
9bf9ae9c37 | ||
|
|
50540e23a1 | ||
|
|
3e1b0587ec | ||
|
|
6661ae8178 | ||
|
|
1ee5711435 | ||
|
|
08e7ba7064 | ||
|
|
34e747162f | ||
|
|
10f5ceee35 | ||
|
|
9886bbd3a0 | ||
|
|
92a3c22b4c | ||
|
|
b5246a28f0 | ||
|
|
ab4b1c8efa | ||
|
|
8890746278 | ||
|
|
dfdcfcfdb8 | ||
|
|
ebceba0afe | ||
|
|
65f7b61c1f | ||
|
|
2fde803c76 | ||
|
|
adfac3e30e | ||
|
|
21730aeb39 | ||
|
|
75231d2d4a | ||
|
|
9d732d8dd8 | ||
|
|
9e8088f186 | ||
|
|
cf847974d2 | ||
|
|
3cd3e73f60 | ||
|
|
bb350639c6 | ||
|
|
9de294343d | ||
|
|
98a09bcc37 | ||
|
|
959981a676 | ||
|
|
674bb84fac | ||
|
|
ba675b6349 | ||
|
|
f073994bc3 | ||
|
|
0f40aeb0d3 | ||
|
|
cdd164e3e3 | ||
|
|
c90816987c | ||
|
|
dd393691b1 | ||
|
|
dd5375f480 | ||
|
|
291a807d98 | ||
|
|
e64fc60bdf | ||
|
|
ad582ab9f8 | ||
|
|
870f3c58c0 | ||
|
|
22a7772c3d | ||
|
|
9934f85ba9 | ||
|
|
73c6bf4f8d | ||
|
|
570ded10fa | ||
|
|
b6110ed3cd |
@@ -26,6 +26,13 @@ DB_DATABASE=database_database
|
|||||||
DB_USERNAME=database_username
|
DB_USERNAME=database_username
|
||||||
DB_PASSWORD=database_user_password
|
DB_PASSWORD=database_user_password
|
||||||
|
|
||||||
|
# Storage system to use
|
||||||
|
# By default files are stored on the local filesystem, with images being placed in
|
||||||
|
# public web space so they can be efficiently served directly by the web-server.
|
||||||
|
# For other options with different security levels & considerations, refer to:
|
||||||
|
# https://www.bookstackapp.com/docs/admin/upload-config/
|
||||||
|
STORAGE_TYPE=local
|
||||||
|
|
||||||
# Mail system to use
|
# Mail system to use
|
||||||
# Can be 'smtp' or 'sendmail'
|
# Can be 'smtp' or 'sendmail'
|
||||||
MAIL_DRIVER=smtp
|
MAIL_DRIVER=smtp
|
||||||
|
|||||||
@@ -351,10 +351,25 @@ EXPORT_PDF_COMMAND_TIMEOUT=15
|
|||||||
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
||||||
WKHTMLTOPDF=false
|
WKHTMLTOPDF=false
|
||||||
|
|
||||||
# Allow <script> tags in page content
|
# Allow JavaScript, and other potentiall dangerous content in page content.
|
||||||
|
# This also removes CSP-level JavaScript control.
|
||||||
# Note, if set to 'true' the page editor may still escape scripts.
|
# Note, if set to 'true' the page editor may still escape scripts.
|
||||||
|
# DEPRECATED: Use 'APP_CONTENT_FILTERING' instead as detailed below. Activiting this option
|
||||||
|
# effectively sets APP_CONTENT_FILTERING='' (No filtering)
|
||||||
ALLOW_CONTENT_SCRIPTS=false
|
ALLOW_CONTENT_SCRIPTS=false
|
||||||
|
|
||||||
|
# Control the behaviour of content filtering, primarily used for page content.
|
||||||
|
# This setting is a string of characters which represent different available filters:
|
||||||
|
# - j - Filter out JavaScript and unknown binary data based content
|
||||||
|
# - h - Filter out unexpected, and potentially dangerous, HTML elements
|
||||||
|
# - f - Filter out unexpected form elements
|
||||||
|
# - a - Run content through a more complex allowlist filter
|
||||||
|
# This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
||||||
|
# Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
||||||
|
# Note: The default value will always be the most-strict, so it's advised to leave this unset in your own configuration
|
||||||
|
# to ensure you are always using the full range of filters.
|
||||||
|
APP_CONTENT_FILTERING="jfha"
|
||||||
|
|
||||||
# Indicate if robots/crawlers should crawl your instance.
|
# Indicate if robots/crawlers should crawl your instance.
|
||||||
# Can be 'true', 'false' or 'null'.
|
# Can be 'true', 'false' or 'null'.
|
||||||
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting.
|
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting.
|
||||||
|
|||||||
18
.github/translators.txt
vendored
18
.github/translators.txt
vendored
@@ -512,3 +512,21 @@ David Olsen (dawin) :: Danish
|
|||||||
ltnzr :: French
|
ltnzr :: French
|
||||||
Frank Holler (holler.frank) :: German; German Informal
|
Frank Holler (holler.frank) :: German; German Informal
|
||||||
Korab Arifi (korabidev) :: Albanian
|
Korab Arifi (korabidev) :: Albanian
|
||||||
|
Petr Husák (petrhusak) :: Czech
|
||||||
|
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
|
||||||
|
Amr (amr3k) :: Arabic
|
||||||
|
Tahsin Ahmed (tahsinahmed2012) :: Bengali
|
||||||
|
bojan_che :: Serbian (Cyrillic)
|
||||||
|
setiawan setiawan (culture.setiawan) :: Indonesian
|
||||||
|
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
|
||||||
|
Gabriel Silver (GabrielBSilver) :: Hebrew
|
||||||
|
Tomas Darius Davainis (Tomasdd) :: Lithuanian
|
||||||
|
CriedHero :: Chinese Simplified
|
||||||
|
Henrik (henrik2105) :: Norwegian Bokmal
|
||||||
|
FoW (fofwisdom) :: Korean
|
||||||
|
serinf-lauza :: French
|
||||||
|
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
|
||||||
|
Shadluk Avan (quldosh) :: Uzbek
|
||||||
|
Marci (MartonPoto) :: Hungarian
|
||||||
|
Michał Sadurski (wheeskeey) :: Polish
|
||||||
|
JanDziaslo :: Polish
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2015-2025, Dan Brown and the BookStack project contributors.
|
Copyright (c) 2015-2026, Dan Brown and the BookStack project contributors.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ use Illuminate\Http\Request;
|
|||||||
|
|
||||||
class OidcController extends Controller
|
class OidcController extends Controller
|
||||||
{
|
{
|
||||||
protected OidcService $oidcService;
|
public function __construct(
|
||||||
|
protected OidcService $oidcService
|
||||||
public function __construct(OidcService $oidcService)
|
) {
|
||||||
{
|
|
||||||
$this->oidcService = $oidcService;
|
|
||||||
$this->middleware('guard:oidc');
|
$this->middleware('guard:oidc');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +28,7 @@ class OidcController extends Controller
|
|||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('oidc_state', $loginDetails['state']);
|
session()->put('oidc_state', time() . ':' . $loginDetails['state']);
|
||||||
|
|
||||||
return redirect($loginDetails['url']);
|
return redirect($loginDetails['url']);
|
||||||
}
|
}
|
||||||
@@ -41,10 +39,16 @@ class OidcController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function callback(Request $request)
|
public function callback(Request $request)
|
||||||
{
|
{
|
||||||
$storedState = session()->pull('oidc_state');
|
|
||||||
$responseState = $request->query('state');
|
$responseState = $request->query('state');
|
||||||
|
$splitState = explode(':', session()->pull('oidc_state', ':'), 2);
|
||||||
|
if (count($splitState) !== 2) {
|
||||||
|
$splitState = [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
if ($storedState !== $responseState) {
|
[$storedStateTime, $storedState] = $splitState;
|
||||||
|
$threeMinutesAgo = time() - 3 * 60;
|
||||||
|
|
||||||
|
if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) {
|
||||||
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
|
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
|
||||||
|
|
||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
@@ -62,7 +66,7 @@ class OidcController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log the user out then start the OIDC RP-initiated logout process.
|
* Log the user out, then start the OIDC RP-initiated logout process.
|
||||||
*/
|
*/
|
||||||
public function logout()
|
public function logout()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ use PragmaRX\Google2FA\Support\Constants;
|
|||||||
|
|
||||||
class TotpService
|
class TotpService
|
||||||
{
|
{
|
||||||
protected $google2fa;
|
public function __construct(
|
||||||
|
protected Google2FA $google2fa
|
||||||
public function __construct(Google2FA $google2fa)
|
) {
|
||||||
{
|
|
||||||
$this->google2fa = $google2fa;
|
$this->google2fa = $google2fa;
|
||||||
// Use SHA1 as a default, Personal testing of other options in 2021 found
|
// Use SHA1 as a default, Personal testing of other options in 2021 found
|
||||||
// many apps lack support for other algorithms yet still will scan
|
// many apps lack support for other algorithms yet still will scan
|
||||||
@@ -35,7 +34,7 @@ class TotpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a TOTP URL from secret key.
|
* Generate a TOTP URL from a secret key.
|
||||||
*/
|
*/
|
||||||
public function generateUrl(string $secret, User $user): string
|
public function generateUrl(string $secret, User $user): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use BookStack\Permissions\PermissionApplicator;
|
|||||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||||
use BookStack\Users\Models\OwnableInterface;
|
use BookStack\Users\Models\OwnableInterface;
|
||||||
use BookStack\Util\HtmlContentFilter;
|
use BookStack\Util\HtmlContentFilter;
|
||||||
|
use BookStack\Util\HtmlContentFilterConfig;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@@ -41,7 +42,19 @@ class Comment extends Model implements Loggable, OwnableInterface
|
|||||||
*/
|
*/
|
||||||
public function entity(): MorphTo
|
public function entity(): MorphTo
|
||||||
{
|
{
|
||||||
return $this->morphTo('commentable');
|
// We specifically define null here to avoid the different name (commentable)
|
||||||
|
// being used by Laravel eager loading instead of the method name, which it was doing
|
||||||
|
// in some scenarios like when deserialized when going through the queue system.
|
||||||
|
// So we instead specify the type and id column names to use.
|
||||||
|
// Related to:
|
||||||
|
// https://github.com/laravel/framework/pull/24815
|
||||||
|
// https://github.com/laravel/framework/issues/27342
|
||||||
|
// https://github.com/laravel/framework/issues/47953
|
||||||
|
// (and probably more)
|
||||||
|
|
||||||
|
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
|
||||||
|
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
|
||||||
|
return $this->morphTo(null, 'commentable_type', 'commentable_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +83,8 @@ class Comment extends Model implements Loggable, OwnableInterface
|
|||||||
|
|
||||||
public function safeHtml(): string
|
public function safeHtml(): string
|
||||||
{
|
{
|
||||||
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
||||||
|
return $filter->filterString($this->html ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function jointPermissions(): HasMany
|
public function jointPermissions(): HasMany
|
||||||
|
|||||||
20
app/Activity/Models/MentionHistory.php
Normal file
20
app/Activity/Models/MentionHistory.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $mentionable_type
|
||||||
|
* @property int $mentionable_id
|
||||||
|
* @property int $from_user_id
|
||||||
|
* @property int $to_user_id
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property Carbon $updated_at
|
||||||
|
*/
|
||||||
|
class MentionHistory extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'mention_history';
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
|
|||||||
{
|
{
|
||||||
$users = User::query()->whereIn('id', array_unique($userIds))->get();
|
$users = User::query()->whereIn('id', array_unique($userIds))->get();
|
||||||
|
|
||||||
|
/** @var User $user */
|
||||||
foreach ($users as $user) {
|
foreach ($users as $user) {
|
||||||
// Prevent sending to the user that initiated the activity
|
// Prevent sending to the user that initiated the activity
|
||||||
if ($user->id === $initiator->id) {
|
if ($user->id === $initiator->id) {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Notifications\Handlers;
|
||||||
|
|
||||||
|
use BookStack\Activity\ActivityType;
|
||||||
|
use BookStack\Activity\Models\Activity;
|
||||||
|
use BookStack\Activity\Models\Comment;
|
||||||
|
use BookStack\Activity\Models\Loggable;
|
||||||
|
use BookStack\Activity\Models\MentionHistory;
|
||||||
|
use BookStack\Activity\Notifications\Messages\CommentMentionNotification;
|
||||||
|
use BookStack\Activity\Tools\MentionParser;
|
||||||
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Settings\UserNotificationPreferences;
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class CommentMentionNotificationHandler extends BaseNotificationHandler
|
||||||
|
{
|
||||||
|
public function handle(Activity $activity, Loggable|string $detail, User $user): void
|
||||||
|
{
|
||||||
|
if (!($detail instanceof Comment) || !($detail->entity instanceof Page)) {
|
||||||
|
throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Page $page */
|
||||||
|
$page = $detail->entity;
|
||||||
|
|
||||||
|
$parser = new MentionParser();
|
||||||
|
$mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html);
|
||||||
|
$realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get();
|
||||||
|
|
||||||
|
$receivingNotifications = $realMentionedUsers->filter(function (User $user) {
|
||||||
|
$prefs = new UserNotificationPreferences($user);
|
||||||
|
return $prefs->notifyOnCommentMentions();
|
||||||
|
});
|
||||||
|
$receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$userMentionsToLog = $realMentionedUsers;
|
||||||
|
|
||||||
|
// When an edit, we check our history to see if we've already notified the user about this comment before
|
||||||
|
// so that we can filter them out to avoid double notifications.
|
||||||
|
if ($activity->type === ActivityType::COMMENT_UPDATE) {
|
||||||
|
$previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail);
|
||||||
|
$receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds));
|
||||||
|
$userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) {
|
||||||
|
return !in_array($user->id, $previouslyNotifiedUserIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logMentions($userMentionsToLog, $detail, $user);
|
||||||
|
$this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<User> $mentionedUsers
|
||||||
|
*/
|
||||||
|
protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void
|
||||||
|
{
|
||||||
|
$mentions = [];
|
||||||
|
$now = Carbon::now();
|
||||||
|
|
||||||
|
foreach ($mentionedUsers as $mentionedUser) {
|
||||||
|
$mentions[] = [
|
||||||
|
'mentionable_type' => $comment->getMorphClass(),
|
||||||
|
'mentionable_id' => $comment->id,
|
||||||
|
'from_user_id' => $fromUser->id,
|
||||||
|
'to_user_id' => $mentionedUser->id,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
MentionHistory::query()->insert($mentions);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getPreviouslyNotifiedUserIds(Comment $comment): array
|
||||||
|
{
|
||||||
|
return MentionHistory::query()
|
||||||
|
->where('mentionable_id', $comment->id)
|
||||||
|
->where('mentionable_type', $comment->getMorphClass())
|
||||||
|
->pluck('to_user_id')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Notifications\Messages;
|
||||||
|
|
||||||
|
use BookStack\Activity\Models\Comment;
|
||||||
|
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
||||||
|
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||||
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
|
||||||
|
class CommentMentionNotification extends BaseActivityNotification
|
||||||
|
{
|
||||||
|
public function toMail(User $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
/** @var Comment $comment */
|
||||||
|
$comment = $this->detail;
|
||||||
|
/** @var Page $page */
|
||||||
|
$page = $comment->entity;
|
||||||
|
|
||||||
|
$locale = $notifiable->getLocale();
|
||||||
|
|
||||||
|
$listLines = array_filter([
|
||||||
|
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||||
|
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||||
|
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
||||||
|
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->newMailMessage($locale)
|
||||||
|
->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()]))
|
||||||
|
->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')]))
|
||||||
|
->line(new ListMessageLine($listLines))
|
||||||
|
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
|
||||||
|
->line($this->buildReasonFooterLine($locale));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
|
|||||||
use BookStack\Activity\Models\Activity;
|
use BookStack\Activity\Models\Activity;
|
||||||
use BookStack\Activity\Models\Loggable;
|
use BookStack\Activity\Models\Loggable;
|
||||||
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
||||||
|
use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler;
|
||||||
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
||||||
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
||||||
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
||||||
@@ -48,5 +49,7 @@ class NotificationManager
|
|||||||
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
||||||
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
||||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
|
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
|
||||||
|
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class);
|
||||||
|
$this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
app/Activity/Tools/MentionParser.php
Normal file
28
app/Activity/Tools/MentionParser.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Tools;
|
||||||
|
|
||||||
|
use BookStack\Util\HtmlDocument;
|
||||||
|
use DOMElement;
|
||||||
|
|
||||||
|
class MentionParser
|
||||||
|
{
|
||||||
|
public function parseUserIdsFromHtml(string $html): array
|
||||||
|
{
|
||||||
|
$doc = new HtmlDocument($html);
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
$mentionLinks = $doc->queryXPath('//a[@data-mention-user-id]');
|
||||||
|
|
||||||
|
foreach ($mentionLinks as $link) {
|
||||||
|
if ($link instanceof DOMElement) {
|
||||||
|
$id = intval($link->getAttribute('data-mention-user-id'));
|
||||||
|
if ($id > 0) {
|
||||||
|
$ids[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ class HomeController extends Controller
|
|||||||
if ($homepageOption === 'bookshelves') {
|
if ($homepageOption === 'bookshelves') {
|
||||||
$shelves = $this->queries->shelves->visibleForListWithCover()
|
$shelves = $this->queries->shelves->visibleForListWithCover()
|
||||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||||
->paginate(18);
|
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
|
||||||
$data = array_merge($commonData, ['shelves' => $shelves]);
|
$data = array_merge($commonData, ['shelves' => $shelves]);
|
||||||
|
|
||||||
return view('home.shelves', $data);
|
return view('home.shelves', $data);
|
||||||
@@ -92,7 +92,7 @@ class HomeController extends Controller
|
|||||||
if ($homepageOption === 'books') {
|
if ($homepageOption === 'books') {
|
||||||
$books = $this->queries->books->visibleForListWithCover()
|
$books = $this->queries->books->visibleForListWithCover()
|
||||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||||
->paginate(18);
|
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
|
||||||
$data = array_merge($commonData, ['books' => $books]);
|
$data = array_merge($commonData, ['books' => $books]);
|
||||||
|
|
||||||
return view('home.books', $data);
|
return view('home.books', $data);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace BookStack\App\Providers;
|
namespace BookStack\App\Providers;
|
||||||
|
|
||||||
use BookStack\Access\SocialDriverManager;
|
use BookStack\Access\SocialDriverManager;
|
||||||
|
use BookStack\Activity\Models\Comment;
|
||||||
use BookStack\Activity\Tools\ActivityLogger;
|
use BookStack\Activity\Tools\ActivityLogger;
|
||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
use BookStack\Entities\Models\Bookshelf;
|
use BookStack\Entities\Models\Bookshelf;
|
||||||
@@ -73,6 +74,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
'book' => Book::class,
|
'book' => Book::class,
|
||||||
'chapter' => Chapter::class,
|
'chapter' => Chapter::class,
|
||||||
'page' => Page::class,
|
'page' => Page::class,
|
||||||
|
'comment' => Comment::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace BookStack\App\Providers;
|
|||||||
|
|
||||||
use BookStack\Theming\ThemeEvents;
|
use BookStack\Theming\ThemeEvents;
|
||||||
use BookStack\Theming\ThemeService;
|
use BookStack\Theming\ThemeService;
|
||||||
|
use BookStack\Theming\ThemeViews;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class ThemeServiceProvider extends ServiceProvider
|
class ThemeServiceProvider extends ServiceProvider
|
||||||
@@ -24,7 +26,26 @@ class ThemeServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
// Boot up the theme system
|
// Boot up the theme system
|
||||||
$themeService = $this->app->make(ThemeService::class);
|
$themeService = $this->app->make(ThemeService::class);
|
||||||
|
$viewFactory = $this->app->make('view');
|
||||||
|
$themeViews = new ThemeViews($viewFactory->getFinder());
|
||||||
|
|
||||||
|
// Use a custom include so that we can insert theme views before/after includes.
|
||||||
|
// This is done, even if no theme is active, so that view caching does not create problems
|
||||||
|
// when switching between themes or when switching a theme on/off.
|
||||||
|
$viewFactory->share('__themeViews', $themeViews);
|
||||||
|
Blade::directive('include', function ($expression) {
|
||||||
|
return "<?php echo \$__themeViews->handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!$themeService->getTheme()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$themeService->loadModules();
|
||||||
$themeService->readThemeActions();
|
$themeService->readThemeActions();
|
||||||
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
|
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
|
||||||
|
|
||||||
|
$themeViews->registerViewPathsForTheme($themeService->getModules());
|
||||||
|
$themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ namespace BookStack\App;
|
|||||||
/**
|
/**
|
||||||
* Assigned to models that can have slugs.
|
* Assigned to models that can have slugs.
|
||||||
* Must have the below properties.
|
* Must have the below properties.
|
||||||
|
*
|
||||||
|
* @property string $slug
|
||||||
*/
|
*/
|
||||||
interface SluggableInterface
|
interface SluggableInterface
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Regenerate the slug for this model.
|
|
||||||
*/
|
|
||||||
public function refreshSlug(): string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,7 @@ function setting(?string $key = null, mixed $default = null): mixed
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a path to a theme resource.
|
* Get a path to a theme resource.
|
||||||
* Returns null if a theme is not configured and
|
* Returns null if a theme is not configured, and therefore a full path is not available for use.
|
||||||
* therefore a full path is not available for use.
|
|
||||||
*/
|
*/
|
||||||
function theme_path(string $path = ''): ?string
|
function theme_path(string $path = ''): ?string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,10 +37,15 @@ return [
|
|||||||
// The limit for all uploaded files, including images and attachments in MB.
|
// The limit for all uploaded files, including images and attachments in MB.
|
||||||
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
|
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
|
||||||
|
|
||||||
// Allow <script> tags to entered within page content.
|
// Control the behaviour of content filtering, primarily used for page content.
|
||||||
// <script> tags are escaped by default.
|
// This setting is a string of characters which represent different available filters:
|
||||||
// Even when overridden the WYSIWYG editor may still escape script content.
|
// - j - Filter out JavaScript and unknown binary data based content
|
||||||
'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
|
// - h - Filter out unexpected, and potentially dangerous, HTML elements
|
||||||
|
// - f - Filter out unexpected form elements
|
||||||
|
// - a - Run content through a more complex allowlist filter
|
||||||
|
// This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
||||||
|
// Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
||||||
|
'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'),
|
||||||
|
|
||||||
// Allow server-side fetches to be performed to potentially unknown
|
// Allow server-side fetches to be performed to potentially unknown
|
||||||
// and user-provided locations. Primarily used in exports when loading
|
// and user-provided locations. Primarily used in exports when loading
|
||||||
@@ -48,8 +53,8 @@ return [
|
|||||||
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
|
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
|
||||||
|
|
||||||
// Override the default behaviour for allowing crawlers to crawl the instance.
|
// Override the default behaviour for allowing crawlers to crawl the instance.
|
||||||
// May be ignored if view has be overridden or modified.
|
// May be ignored if the underlying view has been overridden or modified.
|
||||||
// Defaults to null since, if not set, 'app-public' status used instead.
|
// Defaults to null in which case the 'app-public' status is used instead.
|
||||||
'allow_robots' => env('ALLOW_ROBOTS', null),
|
'allow_robots' => env('ALLOW_ROBOTS', null),
|
||||||
|
|
||||||
// Application Base URL, Used by laravel in development commands
|
// Application Base URL, Used by laravel in development commands
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ return [
|
|||||||
'strict' => false,
|
'strict' => false,
|
||||||
'engine' => null,
|
'engine' => null,
|
||||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
// @phpstan-ignore class.notFound
|
||||||
|
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
]) : [],
|
]) : [],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ return [
|
|||||||
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
|
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
|
||||||
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
|
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
|
||||||
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
|
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
|
||||||
|
'notifications#comment-mentions' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,12 +8,6 @@
|
|||||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Join up possible view locations
|
|
||||||
$viewPaths = [realpath(base_path('resources/views'))];
|
|
||||||
if ($theme = env('APP_THEME', false)) {
|
|
||||||
array_unshift($viewPaths, base_path('themes/' . $theme));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
// App theme
|
// App theme
|
||||||
@@ -26,7 +20,7 @@ return [
|
|||||||
// Most templating systems load templates from disk. Here you may specify
|
// Most templating systems load templates from disk. Here you may specify
|
||||||
// an array of paths that should be checked for your views. Of course
|
// an array of paths that should be checked for your views. Of course
|
||||||
// the usual Laravel view path has already been registered for you.
|
// the usual Laravel view path has already been registered for you.
|
||||||
'paths' => $viewPaths,
|
'paths' => [realpath(base_path('resources/views'))],
|
||||||
|
|
||||||
// Compiled View Path
|
// Compiled View Path
|
||||||
// This option determines where all the compiled Blade templates will be
|
// This option determines where all the compiled Blade templates will be
|
||||||
|
|||||||
305
app/Console/Commands/InstallModuleCommand.php
Normal file
305
app/Console/Commands/InstallModuleCommand.php
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Console\Commands;
|
||||||
|
|
||||||
|
use BookStack\Http\HttpRequestService;
|
||||||
|
use BookStack\Theming\ThemeModule;
|
||||||
|
use BookStack\Theming\ThemeModuleException;
|
||||||
|
use BookStack\Theming\ThemeModuleManager;
|
||||||
|
use BookStack\Theming\ThemeModuleZip;
|
||||||
|
use GuzzleHttp\Psr7\Request;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class InstallModuleCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'bookstack:install-module
|
||||||
|
{location : The URL or path of the module file}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Install a module to the currently configured theme';
|
||||||
|
|
||||||
|
protected array $cleanupActions = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$location = $this->argument('location');
|
||||||
|
|
||||||
|
// Get the ZIP file containing the module files
|
||||||
|
$zipPath = $this->getPathToZip($location);
|
||||||
|
if (!$zipPath) {
|
||||||
|
$this->cleanup();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate module zip file (metadata, size, etc...) and get module instance
|
||||||
|
$zip = new ThemeModuleZip($zipPath);
|
||||||
|
$themeModule = $this->validateAndGetModuleInfoFromZip($zip);
|
||||||
|
if (!$themeModule) {
|
||||||
|
$this->cleanup();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the theme folder in use, attempting to create one if no active theme in use
|
||||||
|
$themeFolder = $this->getThemeFolder();
|
||||||
|
if (!$themeFolder) {
|
||||||
|
$this->cleanup();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the modules folder of the theme, attempting to create it if not existing,
|
||||||
|
// and create a new module manager instance.
|
||||||
|
$moduleFolder = $this->getModuleFolder($themeFolder);
|
||||||
|
if (!$moduleFolder) {
|
||||||
|
$this->cleanup();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$manager = new ThemeModuleManager($moduleFolder);
|
||||||
|
|
||||||
|
// Handle existing modules with the same name
|
||||||
|
$exitingModulesWithName = $manager->getByName($themeModule->name);
|
||||||
|
$shouldContinue = $this->handleExistingModulesWithSameName($exitingModulesWithName, $manager);
|
||||||
|
if (!$shouldContinue) {
|
||||||
|
$this->cleanup();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract module ZIP into the theme modules folder
|
||||||
|
try {
|
||||||
|
$newModule = $manager->addFromZip($themeModule->name, $zip);
|
||||||
|
} catch (ThemeModuleException $exception) {
|
||||||
|
$this->error("ERROR: Failed to install module with error: {$exception->getMessage()}");
|
||||||
|
$this->cleanup();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Module \"{$newModule->name}\" ({$newModule->getVersion()}) successfully installed!");
|
||||||
|
$this->info("Install location: {$moduleFolder}/{$newModule->folderName}");
|
||||||
|
$this->cleanup();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ThemeModule[] $existingModules
|
||||||
|
*/
|
||||||
|
protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool
|
||||||
|
{
|
||||||
|
if (count($existingModules) === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->warn("The following modules already exist with the same name:");
|
||||||
|
foreach ($existingModules as $folder => $module) {
|
||||||
|
$this->line("{$module->name} ({$folder}:{$module->getVersion()}) - {$module->description}");
|
||||||
|
}
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
$choices = ['Cancel module install', 'Add alongside existing module'];
|
||||||
|
if (count($existingModules) === 1) {
|
||||||
|
$choices[] = 'Replace existing module';
|
||||||
|
}
|
||||||
|
$choice = $this->choice("What would you like to do?", $choices, 0, null, false);
|
||||||
|
if ($choice === 'Cancel module install') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($choice === 'Replace existing module') {
|
||||||
|
$existingModuleFolder = array_key_first($existingModules);
|
||||||
|
$this->info("Replacing existing module in {$existingModuleFolder} folder");
|
||||||
|
$manager->deleteModuleFolder($existingModuleFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getModuleFolder(string $themeFolder): string|null
|
||||||
|
{
|
||||||
|
$path = $themeFolder . DIRECTORY_SEPARATOR . 'modules';
|
||||||
|
|
||||||
|
if (file_exists($path) && !is_dir($path)) {
|
||||||
|
$this->error("ERROR: Cannot create a modules folder, file already exists at {$path}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
$created = mkdir($path, 0755, true);
|
||||||
|
if (!$created) {
|
||||||
|
$this->error("ERROR: Failed to create a modules folder at {$path}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getThemeFolder(): string|null
|
||||||
|
{
|
||||||
|
$path = theme_path('');
|
||||||
|
if (!$path || !is_dir($path)) {
|
||||||
|
$shouldCreate = $this->confirm('No active theme folder found, would you like to create one?');
|
||||||
|
if (!$shouldCreate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$folder = 'custom';
|
||||||
|
while (file_exists(base_path("themes" . DIRECTORY_SEPARATOR . $folder))) {
|
||||||
|
$folder = 'custom-' . Str::random(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = base_path("themes/{$folder}");
|
||||||
|
$created = mkdir($path, 0755, true);
|
||||||
|
if (!$created) {
|
||||||
|
$this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Created theme folder at {$path}");
|
||||||
|
$this->warn("You will need to set APP_THEME={$folder} in your BookStack env configuration to enable this theme!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeModule|null
|
||||||
|
{
|
||||||
|
if (!$zip->exists()) {
|
||||||
|
$this->error("ERROR: Cannot open ZIP file at {$zip->getPath()}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($zip->getContentsSize() > (50 * 1024 * 1024)) {
|
||||||
|
$this->error("ERROR: Module ZIP file contents are too large. Maximum size is 50MB");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$themeModule = $zip->getModuleInstance();
|
||||||
|
} catch (ThemeModuleException $exception) {
|
||||||
|
$this->error("ERROR: Failed to read module metadata with error: {$exception->getMessage()}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $themeModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function downloadModuleFile(string $location): string|null
|
||||||
|
{
|
||||||
|
$httpRequests = app()->make(HttpRequestService::class);
|
||||||
|
$client = $httpRequests->buildClient(30, ['stream' => true]);
|
||||||
|
$originalUrl = parse_url($location);
|
||||||
|
$currentLocation = $location;
|
||||||
|
$maxRedirects = 3;
|
||||||
|
$redirectCount = 0;
|
||||||
|
|
||||||
|
// Follow redirects up to 3 times for the same hostname
|
||||||
|
do {
|
||||||
|
$resp = $client->sendRequest(new Request('GET', $currentLocation));
|
||||||
|
$statusCode = $resp->getStatusCode();
|
||||||
|
|
||||||
|
if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) {
|
||||||
|
$redirectLocation = $resp->getHeaderLine('Location');
|
||||||
|
if ($redirectLocation) {
|
||||||
|
$redirectUrl = parse_url($redirectLocation);
|
||||||
|
if (
|
||||||
|
($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '')
|
||||||
|
&& ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '')
|
||||||
|
&& ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '')
|
||||||
|
) {
|
||||||
|
$currentLocation = $redirectLocation;
|
||||||
|
$redirectCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
} while (true);
|
||||||
|
|
||||||
|
if ($resp->getStatusCode() >= 300) {
|
||||||
|
$this->error("ERROR: Failed to download module from {$location}");
|
||||||
|
$this->error("Download failed with status code {$resp->getStatusCode()}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_');
|
||||||
|
$fileHandle = fopen($tempFile, 'w');
|
||||||
|
$respBody = $resp->getBody();
|
||||||
|
$size = 0;
|
||||||
|
$maxSize = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
while (!$respBody->eof()) {
|
||||||
|
fwrite($fileHandle, $respBody->read(1024));
|
||||||
|
$size += 1024;
|
||||||
|
if ($size > $maxSize) {
|
||||||
|
fclose($fileHandle);
|
||||||
|
unlink($tempFile);
|
||||||
|
$this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB");
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($fileHandle);
|
||||||
|
|
||||||
|
$this->cleanupActions[] = function () use ($tempFile) {
|
||||||
|
unlink($tempFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
return $tempFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getPathToZip(string $location): string|null
|
||||||
|
{
|
||||||
|
$lowerLocation = strtolower($location);
|
||||||
|
$isRemote = str_starts_with($lowerLocation, 'http://') || str_starts_with($lowerLocation, 'https://');
|
||||||
|
|
||||||
|
if ($isRemote) {
|
||||||
|
// Warning about fetching from source
|
||||||
|
$host = parse_url($location, PHP_URL_HOST);
|
||||||
|
$this->warn("This will download a module from {$host}. Modules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.");
|
||||||
|
$trustHost = $this->confirm('Are you sure you trust this source?');
|
||||||
|
if (!$trustHost) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the connection is http. If so, warn the user.
|
||||||
|
if (str_starts_with($lowerLocation, 'http://')) {
|
||||||
|
$this->warn("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.");
|
||||||
|
if (!$this->confirm('Are you sure you want to continue without HTTPS?')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download ZIP and get its location
|
||||||
|
return $this->downloadModuleFile($location);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file and get full location
|
||||||
|
$zipPath = realpath($location);
|
||||||
|
if (!$zipPath || !is_file($zipPath)) {
|
||||||
|
$this->error("ERROR: Module file not found at {$location}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $zipPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function cleanup(): void
|
||||||
|
{
|
||||||
|
foreach ($this->cleanupActions as $action) {
|
||||||
|
$action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,14 @@ use BookStack\Entities\Models\Book;
|
|||||||
use BookStack\Entities\Models\Chapter;
|
use BookStack\Entities\Models\Chapter;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Queries\BookQueries;
|
use BookStack\Entities\Queries\BookQueries;
|
||||||
|
use BookStack\Entities\Queries\BookshelfQueries;
|
||||||
use BookStack\Entities\Queries\PageQueries;
|
use BookStack\Entities\Queries\PageQueries;
|
||||||
use BookStack\Entities\Repos\BookRepo;
|
use BookStack\Entities\Repos\BookRepo;
|
||||||
use BookStack\Entities\Tools\BookContents;
|
use BookStack\Entities\Tools\BookContents;
|
||||||
use BookStack\Http\ApiController;
|
use BookStack\Http\ApiController;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
@@ -21,6 +24,7 @@ class BookApiController extends ApiController
|
|||||||
protected BookRepo $bookRepo,
|
protected BookRepo $bookRepo,
|
||||||
protected BookQueries $queries,
|
protected BookQueries $queries,
|
||||||
protected PageQueries $pageQueries,
|
protected PageQueries $pageQueries,
|
||||||
|
protected BookshelfQueries $shelfQueries,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,13 +64,20 @@ class BookApiController extends ApiController
|
|||||||
* View the details of a single book.
|
* View the details of a single book.
|
||||||
* The response data will contain a '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
|
* 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.
|
* contents will have a 'type' property to distinguish between pages and chapters.
|
||||||
*/
|
*/
|
||||||
public function read(string $id)
|
public function read(string $id)
|
||||||
{
|
{
|
||||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||||
$book = $this->forJsonDisplay($book);
|
$book = $this->forJsonDisplay($book);
|
||||||
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
|
$book->load([
|
||||||
|
'createdBy',
|
||||||
|
'updatedBy',
|
||||||
|
'ownedBy',
|
||||||
|
'shelves' => function (BelongsToMany $query) {
|
||||||
|
$query->select(['id', 'name', 'slug'])->scopes('visible');
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
$contents = (new BookContents($book))->getTree(true, false)->all();
|
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||||
$contentsApiData = (new ApiEntityListFormatter($contents))
|
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use BookStack\Activity\Models\View;
|
|||||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||||
use BookStack\Entities\Queries\BookQueries;
|
use BookStack\Entities\Queries\BookQueries;
|
||||||
use BookStack\Entities\Queries\BookshelfQueries;
|
use BookStack\Entities\Queries\BookshelfQueries;
|
||||||
|
use BookStack\Entities\Queries\EntityQueries;
|
||||||
use BookStack\Entities\Repos\BookRepo;
|
use BookStack\Entities\Repos\BookRepo;
|
||||||
use BookStack\Entities\Tools\BookContents;
|
use BookStack\Entities\Tools\BookContents;
|
||||||
use BookStack\Entities\Tools\Cloner;
|
use BookStack\Entities\Tools\Cloner;
|
||||||
@@ -31,6 +32,7 @@ class BookController extends Controller
|
|||||||
protected ShelfContext $shelfContext,
|
protected ShelfContext $shelfContext,
|
||||||
protected BookRepo $bookRepo,
|
protected BookRepo $bookRepo,
|
||||||
protected BookQueries $queries,
|
protected BookQueries $queries,
|
||||||
|
protected EntityQueries $entityQueries,
|
||||||
protected BookshelfQueries $shelfQueries,
|
protected BookshelfQueries $shelfQueries,
|
||||||
protected ReferenceFetcher $referenceFetcher,
|
protected ReferenceFetcher $referenceFetcher,
|
||||||
) {
|
) {
|
||||||
@@ -50,7 +52,7 @@ class BookController extends Controller
|
|||||||
|
|
||||||
$books = $this->queries->visibleForListWithCover()
|
$books = $this->queries->visibleForListWithCover()
|
||||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||||
->paginate(18);
|
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
|
||||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
|
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
|
||||||
$popular = $this->queries->popularForList()->take(4)->get();
|
$popular = $this->queries->popularForList()->take(4)->get();
|
||||||
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
|
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
|
||||||
@@ -127,7 +129,16 @@ class BookController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||||
{
|
{
|
||||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
try {
|
||||||
|
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||||
|
} catch (NotFoundException $exception) {
|
||||||
|
$book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
|
||||||
|
if (is_null($book)) {
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
return redirect($book->getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
$bookChildren = (new BookContents($book))->getTree(true);
|
$bookChildren = (new BookContents($book))->getTree(true);
|
||||||
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
||||||
|
|
||||||
@@ -213,9 +224,14 @@ class BookController extends Controller
|
|||||||
{
|
{
|
||||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||||
$this->checkOwnablePermission(Permission::BookDelete, $book);
|
$this->checkOwnablePermission(Permission::BookDelete, $book);
|
||||||
|
$contextShelf = $this->shelfContext->getContextualShelfForBook($book);
|
||||||
|
|
||||||
$this->bookRepo->destroy($book);
|
$this->bookRepo->destroy($book);
|
||||||
|
|
||||||
|
if ($contextShelf) {
|
||||||
|
return redirect($contextShelf->getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
return redirect('/books');
|
return redirect('/books');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityQueries;
|
|||||||
use BookStack\Activity\Models\View;
|
use BookStack\Activity\Models\View;
|
||||||
use BookStack\Entities\Queries\BookQueries;
|
use BookStack\Entities\Queries\BookQueries;
|
||||||
use BookStack\Entities\Queries\BookshelfQueries;
|
use BookStack\Entities\Queries\BookshelfQueries;
|
||||||
|
use BookStack\Entities\Queries\EntityQueries;
|
||||||
use BookStack\Entities\Repos\BookshelfRepo;
|
use BookStack\Entities\Repos\BookshelfRepo;
|
||||||
use BookStack\Entities\Tools\ShelfContext;
|
use BookStack\Entities\Tools\ShelfContext;
|
||||||
use BookStack\Exceptions\ImageUploadException;
|
use BookStack\Exceptions\ImageUploadException;
|
||||||
@@ -23,6 +24,7 @@ class BookshelfController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
protected BookshelfRepo $shelfRepo,
|
protected BookshelfRepo $shelfRepo,
|
||||||
protected BookshelfQueries $queries,
|
protected BookshelfQueries $queries,
|
||||||
|
protected EntityQueries $entityQueries,
|
||||||
protected BookQueries $bookQueries,
|
protected BookQueries $bookQueries,
|
||||||
protected ShelfContext $shelfContext,
|
protected ShelfContext $shelfContext,
|
||||||
protected ReferenceFetcher $referenceFetcher,
|
protected ReferenceFetcher $referenceFetcher,
|
||||||
@@ -43,7 +45,7 @@ class BookshelfController extends Controller
|
|||||||
|
|
||||||
$shelves = $this->queries->visibleForListWithCover()
|
$shelves = $this->queries->visibleForListWithCover()
|
||||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||||
->paginate(18);
|
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
|
||||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
|
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
|
||||||
$popular = $this->queries->popularForList()->get();
|
$popular = $this->queries->popularForList()->get();
|
||||||
$new = $this->queries->visibleForList()
|
$new = $this->queries->visibleForList()
|
||||||
@@ -105,7 +107,16 @@ class BookshelfController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||||
{
|
{
|
||||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
try {
|
||||||
|
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||||
|
} catch (NotFoundException $exception) {
|
||||||
|
$shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
|
||||||
|
if (is_null($shelf)) {
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
return redirect($shelf->getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
|
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
|
||||||
|
|
||||||
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
||||||
|
|||||||
@@ -77,7 +77,15 @@ class ChapterController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function show(string $bookSlug, string $chapterSlug)
|
public function show(string $bookSlug, string $chapterSlug)
|
||||||
{
|
{
|
||||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
try {
|
||||||
|
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||||
|
} catch (NotFoundException $exception) {
|
||||||
|
$chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug);
|
||||||
|
if (is_null($chapter)) {
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
return redirect($chapter->getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
$sidebarTree = (new BookContents($chapter->book))->getTree();
|
$sidebarTree = (new BookContents($chapter->book))->getTree();
|
||||||
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
|
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ use BookStack\Entities\Tools\PageContent;
|
|||||||
use BookStack\Entities\Tools\PageEditActivity;
|
use BookStack\Entities\Tools\PageEditActivity;
|
||||||
use BookStack\Entities\Tools\PageEditorData;
|
use BookStack\Entities\Tools\PageEditorData;
|
||||||
use BookStack\Exceptions\NotFoundException;
|
use BookStack\Exceptions\NotFoundException;
|
||||||
use BookStack\Exceptions\NotifyException;
|
|
||||||
use BookStack\Exceptions\PermissionsException;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\References\ReferenceFetcher;
|
use BookStack\References\ReferenceFetcher;
|
||||||
|
use BookStack\Util\HtmlContentFilter;
|
||||||
|
use BookStack\Util\HtmlContentFilterConfig;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -140,9 +141,7 @@ class PageController extends Controller
|
|||||||
try {
|
try {
|
||||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||||
} catch (NotFoundException $e) {
|
} catch (NotFoundException $e) {
|
||||||
$revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
|
$page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
|
||||||
$page = $revision->page ?? null;
|
|
||||||
|
|
||||||
if (is_null($page)) {
|
if (is_null($page)) {
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
@@ -176,7 +175,7 @@ class PageController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get page from an ajax request.
|
* Get a page from an ajax request.
|
||||||
*
|
*
|
||||||
* @throws NotFoundException
|
* @throws NotFoundException
|
||||||
*/
|
*/
|
||||||
@@ -186,6 +185,10 @@ class PageController extends Controller
|
|||||||
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
|
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
|
||||||
$page->makeHidden(['book']);
|
$page->makeHidden(['book']);
|
||||||
|
|
||||||
|
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||||
|
$filter = new HtmlContentFilter($filterConfig);
|
||||||
|
$page->html = $filter->filterString($page->html);
|
||||||
|
|
||||||
return response()->json($page);
|
return response()->json($page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace BookStack\Entities\Models;
|
namespace BookStack\Entities\Models;
|
||||||
|
|
||||||
use BookStack\References\ReferenceUpdater;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,34 +16,10 @@ abstract class BookChild extends Entity
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Get the book this page sits in.
|
* Get the book this page sits in.
|
||||||
|
* @return BelongsTo<Book, $this>
|
||||||
*/
|
*/
|
||||||
public function book(): BelongsTo
|
public function book(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Book::class)->withTrashed();
|
return $this->belongsTo(Book::class)->withTrashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Change the book that this entity belongs to.
|
|
||||||
*/
|
|
||||||
public function changeBook(int $newBookId): self
|
|
||||||
{
|
|
||||||
$oldUrl = $this->getUrl();
|
|
||||||
$this->book_id = $newBookId;
|
|
||||||
$this->unsetRelation('book');
|
|
||||||
$this->refreshSlug();
|
|
||||||
$this->save();
|
|
||||||
|
|
||||||
if ($oldUrl !== $this->getUrl()) {
|
|
||||||
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update all child pages if a chapter
|
|
||||||
if ($this instanceof Chapter) {
|
|
||||||
foreach ($this->pages()->withTrashed()->get() as $page) {
|
|
||||||
$page->changeBook($newBookId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInter
|
|||||||
|
|
||||||
public float $searchFactor = 1.2;
|
public float $searchFactor = 1.2;
|
||||||
|
|
||||||
protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
|
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
|
||||||
protected $fillable = ['name'];
|
protected $fillable = ['name'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use BookStack\Activity\Models\Viewable;
|
|||||||
use BookStack\Activity\Models\Watch;
|
use BookStack\Activity\Models\Watch;
|
||||||
use BookStack\App\Model;
|
use BookStack\App\Model;
|
||||||
use BookStack\App\SluggableInterface;
|
use BookStack\App\SluggableInterface;
|
||||||
use BookStack\Entities\Tools\SlugGenerator;
|
|
||||||
use BookStack\Permissions\JointPermissionBuilder;
|
use BookStack\Permissions\JointPermissionBuilder;
|
||||||
use BookStack\Permissions\Models\EntityPermission;
|
use BookStack\Permissions\Models\EntityPermission;
|
||||||
use BookStack\Permissions\Models\JointPermission;
|
use BookStack\Permissions\Models\JointPermission;
|
||||||
@@ -405,16 +404,6 @@ abstract class Entity extends Model implements
|
|||||||
app()->make(SearchIndex::class)->indexEntity(clone $this);
|
app()->make(SearchIndex::class)->indexEntity(clone $this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* {@inheritdoc}
|
|
||||||
*/
|
|
||||||
public function refreshSlug(): string
|
|
||||||
{
|
|
||||||
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
|
|
||||||
|
|
||||||
return $this->slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
@@ -441,6 +430,14 @@ abstract class Entity extends Model implements
|
|||||||
return $this->morphMany(Watch::class, 'watchable');
|
return $this->morphMany(Watch::class, 'watchable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the related slug history for this entity.
|
||||||
|
*/
|
||||||
|
public function slugHistory(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(SlugHistory::class, 'sluggable');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ class Page extends BookChild
|
|||||||
return url('/' . implode('/', $parts));
|
return url('/' . implode('/', $parts));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ID-based permalink for this page.
|
||||||
|
*/
|
||||||
|
public function getPermalink(): string
|
||||||
|
{
|
||||||
|
return url("/link/{$this->id}");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get this page for JSON display.
|
* Get this page for JSON display.
|
||||||
*/
|
*/
|
||||||
|
|||||||
28
app/Entities/Models/SlugHistory.php
Normal file
28
app/Entities/Models/SlugHistory.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Entities\Models;
|
||||||
|
|
||||||
|
use BookStack\App\Model;
|
||||||
|
use BookStack\Permissions\Models\JointPermission;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int $sluggable_id
|
||||||
|
* @property string $sluggable_type
|
||||||
|
* @property string $slug
|
||||||
|
* @property ?string $parent_slug
|
||||||
|
*/
|
||||||
|
class SlugHistory extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'slug_history';
|
||||||
|
|
||||||
|
public function jointPermissions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(JointPermission::class, 'entity_id', 'sluggable_id')
|
||||||
|
->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace BookStack\Entities\Queries;
|
|||||||
|
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Models\EntityTable;
|
use BookStack\Entities\Models\EntityTable;
|
||||||
|
use BookStack\Entities\Tools\SlugHistory;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||||
use Illuminate\Database\Query\JoinClause;
|
use Illuminate\Database\Query\JoinClause;
|
||||||
@@ -18,6 +19,7 @@ class EntityQueries
|
|||||||
public ChapterQueries $chapters,
|
public ChapterQueries $chapters,
|
||||||
public PageQueries $pages,
|
public PageQueries $pages,
|
||||||
public PageRevisionQueries $revisions,
|
public PageRevisionQueries $revisions,
|
||||||
|
protected SlugHistory $slugHistory,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,9 +33,30 @@ class EntityQueries
|
|||||||
$explodedId = explode(':', $identifier);
|
$explodedId = explode(':', $identifier);
|
||||||
$entityType = $explodedId[0];
|
$entityType = $explodedId[0];
|
||||||
$entityId = intval($explodedId[1]);
|
$entityId = intval($explodedId[1]);
|
||||||
$queries = $this->getQueriesForType($entityType);
|
|
||||||
|
|
||||||
return $queries->findVisibleById($entityId);
|
return $this->findVisibleById($entityType, $entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an entity by its ID.
|
||||||
|
*/
|
||||||
|
public function findVisibleById(string $type, int $id): ?Entity
|
||||||
|
{
|
||||||
|
$queries = $this->getQueriesForType($type);
|
||||||
|
return $queries->findVisibleById($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an entity by looking up old slugs in the slug history.
|
||||||
|
*/
|
||||||
|
public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity
|
||||||
|
{
|
||||||
|
$id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug);
|
||||||
|
if ($id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->findVisibleById($type, $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use BookStack\Entities\Models\HasCoverInterface;
|
|||||||
use BookStack\Entities\Models\HasDescriptionInterface;
|
use BookStack\Entities\Models\HasDescriptionInterface;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Queries\PageQueries;
|
use BookStack\Entities\Queries\PageQueries;
|
||||||
|
use BookStack\Entities\Tools\SlugGenerator;
|
||||||
|
use BookStack\Entities\Tools\SlugHistory;
|
||||||
use BookStack\Exceptions\ImageUploadException;
|
use BookStack\Exceptions\ImageUploadException;
|
||||||
use BookStack\References\ReferenceStore;
|
use BookStack\References\ReferenceStore;
|
||||||
use BookStack\References\ReferenceUpdater;
|
use BookStack\References\ReferenceUpdater;
|
||||||
@@ -25,6 +27,8 @@ class BaseRepo
|
|||||||
protected ReferenceStore $referenceStore,
|
protected ReferenceStore $referenceStore,
|
||||||
protected PageQueries $pageQueries,
|
protected PageQueries $pageQueries,
|
||||||
protected BookSorter $bookSorter,
|
protected BookSorter $bookSorter,
|
||||||
|
protected SlugGenerator $slugGenerator,
|
||||||
|
protected SlugHistory $slugHistory,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +47,7 @@ class BaseRepo
|
|||||||
'updated_by' => user()->id,
|
'updated_by' => user()->id,
|
||||||
'owned_by' => user()->id,
|
'owned_by' => user()->id,
|
||||||
]);
|
]);
|
||||||
$entity->refreshSlug();
|
$this->refreshSlug($entity);
|
||||||
|
|
||||||
if ($entity instanceof HasDescriptionInterface) {
|
if ($entity instanceof HasDescriptionInterface) {
|
||||||
$this->updateDescription($entity, $input);
|
$this->updateDescription($entity, $input);
|
||||||
@@ -78,7 +82,7 @@ class BaseRepo
|
|||||||
$entity->updated_by = user()->id;
|
$entity->updated_by = user()->id;
|
||||||
|
|
||||||
if ($entity->isDirty('name') || empty($entity->slug)) {
|
if ($entity->isDirty('name') || empty($entity->slug)) {
|
||||||
$entity->refreshSlug();
|
$this->refreshSlug($entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($entity instanceof HasDescriptionInterface) {
|
if ($entity instanceof HasDescriptionInterface) {
|
||||||
@@ -155,4 +159,13 @@ class BaseRepo
|
|||||||
$entity->descriptionInfo()->set('', $input['description']);
|
$entity->descriptionInfo()->set('', $input['description']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the slug for the given entity.
|
||||||
|
*/
|
||||||
|
public function refreshSlug(Entity $entity): void
|
||||||
|
{
|
||||||
|
$this->slugHistory->recordForEntity($entity);
|
||||||
|
$this->slugGenerator->regenerateForEntity($entity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book;
|
|||||||
use BookStack\Entities\Models\Chapter;
|
use BookStack\Entities\Models\Chapter;
|
||||||
use BookStack\Entities\Queries\EntityQueries;
|
use BookStack\Entities\Queries\EntityQueries;
|
||||||
use BookStack\Entities\Tools\BookContents;
|
use BookStack\Entities\Tools\BookContents;
|
||||||
|
use BookStack\Entities\Tools\ParentChanger;
|
||||||
use BookStack\Entities\Tools\TrashCan;
|
use BookStack\Entities\Tools\TrashCan;
|
||||||
use BookStack\Exceptions\MoveOperationException;
|
use BookStack\Exceptions\MoveOperationException;
|
||||||
use BookStack\Exceptions\PermissionsException;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
@@ -21,6 +22,7 @@ class ChapterRepo
|
|||||||
protected BaseRepo $baseRepo,
|
protected BaseRepo $baseRepo,
|
||||||
protected EntityQueries $entityQueries,
|
protected EntityQueries $entityQueries,
|
||||||
protected TrashCan $trashCan,
|
protected TrashCan $trashCan,
|
||||||
|
protected ParentChanger $parentChanger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +99,7 @@ class ChapterRepo
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (new DatabaseTransaction(function () use ($chapter, $parent) {
|
return (new DatabaseTransaction(function () use ($chapter, $parent) {
|
||||||
$chapter = $chapter->changeBook($parent->id);
|
$this->parentChanger->changeBook($chapter, $parent->id);
|
||||||
$chapter->rebuildPermissions();
|
$chapter->rebuildPermissions();
|
||||||
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use BookStack\Entities\Queries\EntityQueries;
|
|||||||
use BookStack\Entities\Tools\BookContents;
|
use BookStack\Entities\Tools\BookContents;
|
||||||
use BookStack\Entities\Tools\PageContent;
|
use BookStack\Entities\Tools\PageContent;
|
||||||
use BookStack\Entities\Tools\PageEditorType;
|
use BookStack\Entities\Tools\PageEditorType;
|
||||||
|
use BookStack\Entities\Tools\ParentChanger;
|
||||||
use BookStack\Entities\Tools\TrashCan;
|
use BookStack\Entities\Tools\TrashCan;
|
||||||
use BookStack\Exceptions\MoveOperationException;
|
use BookStack\Exceptions\MoveOperationException;
|
||||||
use BookStack\Exceptions\PermissionsException;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
@@ -31,6 +32,7 @@ class PageRepo
|
|||||||
protected ReferenceStore $referenceStore,
|
protected ReferenceStore $referenceStore,
|
||||||
protected ReferenceUpdater $referenceUpdater,
|
protected ReferenceUpdater $referenceUpdater,
|
||||||
protected TrashCan $trashCan,
|
protected TrashCan $trashCan,
|
||||||
|
protected ParentChanger $parentChanger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +244,7 @@ class PageRepo
|
|||||||
}
|
}
|
||||||
|
|
||||||
$page->updated_by = user()->id;
|
$page->updated_by = user()->id;
|
||||||
$page->refreshSlug();
|
$this->baseRepo->refreshSlug($page);
|
||||||
$page->save();
|
$page->save();
|
||||||
$page->indexForSearch();
|
$page->indexForSearch();
|
||||||
$this->referenceStore->updateForEntity($page);
|
$this->referenceStore->updateForEntity($page);
|
||||||
@@ -284,7 +286,7 @@ class PageRepo
|
|||||||
return (new DatabaseTransaction(function () use ($page, $parent) {
|
return (new DatabaseTransaction(function () use ($page, $parent) {
|
||||||
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
|
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
|
||||||
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
|
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
|
||||||
$page = $page->changeBook($newBookId);
|
$this->parentChanger->changeBook($page, $newBookId);
|
||||||
$page->rebuildPermissions();
|
$page->rebuildPermissions();
|
||||||
|
|
||||||
Activity::add(ActivityType::PAGE_MOVE, $page);
|
Activity::add(ActivityType::PAGE_MOVE, $page);
|
||||||
|
|||||||
@@ -13,30 +13,47 @@ use BookStack\Entities\Repos\BookRepo;
|
|||||||
use BookStack\Entities\Repos\ChapterRepo;
|
use BookStack\Entities\Repos\ChapterRepo;
|
||||||
use BookStack\Entities\Repos\PageRepo;
|
use BookStack\Entities\Repos\PageRepo;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
|
use BookStack\References\ReferenceChangeContext;
|
||||||
|
use BookStack\References\ReferenceUpdater;
|
||||||
use BookStack\Uploads\Image;
|
use BookStack\Uploads\Image;
|
||||||
use BookStack\Uploads\ImageService;
|
use BookStack\Uploads\ImageService;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
class Cloner
|
class Cloner
|
||||||
{
|
{
|
||||||
|
protected ReferenceChangeContext $referenceChangeContext;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected PageRepo $pageRepo,
|
protected PageRepo $pageRepo,
|
||||||
protected ChapterRepo $chapterRepo,
|
protected ChapterRepo $chapterRepo,
|
||||||
protected BookRepo $bookRepo,
|
protected BookRepo $bookRepo,
|
||||||
protected ImageService $imageService,
|
protected ImageService $imageService,
|
||||||
|
protected ReferenceUpdater $referenceUpdater,
|
||||||
) {
|
) {
|
||||||
|
$this->referenceChangeContext = new ReferenceChangeContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone the given page into the given parent using the provided name.
|
* Clone the given page into the given parent using the provided name.
|
||||||
*/
|
*/
|
||||||
public function clonePage(Page $original, Entity $parent, string $newName): Page
|
public function clonePage(Page $original, Entity $parent, string $newName): Page
|
||||||
|
{
|
||||||
|
$context = $this->newReferenceChangeContext();
|
||||||
|
$page = $this->createPageClone($original, $parent, $newName);
|
||||||
|
$this->referenceUpdater->changeReferencesUsingContext($context);
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createPageClone(Page $original, Entity $parent, string $newName): Page
|
||||||
{
|
{
|
||||||
$copyPage = $this->pageRepo->getNewDraftPage($parent);
|
$copyPage = $this->pageRepo->getNewDraftPage($parent);
|
||||||
$pageData = $this->entityToInputData($original);
|
$pageData = $this->entityToInputData($original);
|
||||||
$pageData['name'] = $newName;
|
$pageData['name'] = $newName;
|
||||||
|
|
||||||
return $this->pageRepo->publishDraft($copyPage, $pageData);
|
$newPage = $this->pageRepo->publishDraft($copyPage, $pageData);
|
||||||
|
$this->referenceChangeContext->add($original, $newPage);
|
||||||
|
|
||||||
|
return $newPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +61,14 @@ class Cloner
|
|||||||
* Clones all child pages.
|
* Clones all child pages.
|
||||||
*/
|
*/
|
||||||
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
|
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
|
||||||
|
{
|
||||||
|
$context = $this->newReferenceChangeContext();
|
||||||
|
$chapter = $this->createChapterClone($original, $parent, $newName);
|
||||||
|
$this->referenceUpdater->changeReferencesUsingContext($context);
|
||||||
|
return $chapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter
|
||||||
{
|
{
|
||||||
$chapterDetails = $this->entityToInputData($original);
|
$chapterDetails = $this->entityToInputData($original);
|
||||||
$chapterDetails['name'] = $newName;
|
$chapterDetails['name'] = $newName;
|
||||||
@@ -53,10 +78,12 @@ class Cloner
|
|||||||
if (userCan(Permission::PageCreate, $copyChapter)) {
|
if (userCan(Permission::PageCreate, $copyChapter)) {
|
||||||
/** @var Page $page */
|
/** @var Page $page */
|
||||||
foreach ($original->getVisiblePages() as $page) {
|
foreach ($original->getVisiblePages() as $page) {
|
||||||
$this->clonePage($page, $copyChapter, $page->name);
|
$this->createPageClone($page, $copyChapter, $page->name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->referenceChangeContext->add($original, $copyChapter);
|
||||||
|
|
||||||
return $copyChapter;
|
return $copyChapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +92,14 @@ class Cloner
|
|||||||
* Clones all child chapters and pages.
|
* Clones all child chapters and pages.
|
||||||
*/
|
*/
|
||||||
public function cloneBook(Book $original, string $newName): Book
|
public function cloneBook(Book $original, string $newName): Book
|
||||||
|
{
|
||||||
|
$context = $this->newReferenceChangeContext();
|
||||||
|
$book = $this->createBookClone($original, $newName);
|
||||||
|
$this->referenceUpdater->changeReferencesUsingContext($context);
|
||||||
|
return $book;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createBookClone(Book $original, string $newName): Book
|
||||||
{
|
{
|
||||||
$bookDetails = $this->entityToInputData($original);
|
$bookDetails = $this->entityToInputData($original);
|
||||||
$bookDetails['name'] = $newName;
|
$bookDetails['name'] = $newName;
|
||||||
@@ -76,11 +111,11 @@ class Cloner
|
|||||||
$directChildren = $original->getDirectVisibleChildren();
|
$directChildren = $original->getDirectVisibleChildren();
|
||||||
foreach ($directChildren as $child) {
|
foreach ($directChildren as $child) {
|
||||||
if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
|
if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
|
||||||
$this->cloneChapter($child, $copyBook, $child->name);
|
$this->createChapterClone($child, $copyBook, $child->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) {
|
if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) {
|
||||||
$this->clonePage($child, $copyBook, $child->name);
|
$this->createPageClone($child, $copyBook, $child->name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +127,8 @@ class Cloner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->referenceChangeContext->add($original, $copyBook);
|
||||||
|
|
||||||
return $copyBook;
|
return $copyBook;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,4 +192,10 @@ class Cloner
|
|||||||
|
|
||||||
return $tags;
|
return $tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function newReferenceChangeContext(): ReferenceChangeContext
|
||||||
|
{
|
||||||
|
$this->referenceChangeContext = new ReferenceChangeContext();
|
||||||
|
return $this->referenceChangeContext;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use BookStack\Entities\Models\Book;
|
|||||||
use BookStack\Entities\Models\Bookshelf;
|
use BookStack\Entities\Models\Bookshelf;
|
||||||
use BookStack\Entities\Models\Chapter;
|
use BookStack\Entities\Models\Chapter;
|
||||||
use BookStack\Util\HtmlContentFilter;
|
use BookStack\Util\HtmlContentFilter;
|
||||||
|
use BookStack\Util\HtmlContentFilterConfig;
|
||||||
|
|
||||||
class EntityHtmlDescription
|
class EntityHtmlDescription
|
||||||
{
|
{
|
||||||
@@ -50,7 +51,13 @@ class EntityHtmlDescription
|
|||||||
return $html;
|
return $html;
|
||||||
}
|
}
|
||||||
|
|
||||||
return HtmlContentFilter::removeScriptsFromHtmlString($html);
|
$isEmpty = empty(trim(strip_tags($html)));
|
||||||
|
if ($isEmpty) {
|
||||||
|
return '<p></p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
||||||
|
return $filter->filterString($html);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPlain(): string
|
public function getPlain(): string
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ class HierarchyTransformer
|
|||||||
protected BookRepo $bookRepo,
|
protected BookRepo $bookRepo,
|
||||||
protected BookshelfRepo $shelfRepo,
|
protected BookshelfRepo $shelfRepo,
|
||||||
protected Cloner $cloner,
|
protected Cloner $cloner,
|
||||||
protected TrashCan $trashCan
|
protected TrashCan $trashCan,
|
||||||
|
protected ParentChanger $parentChanger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ class HierarchyTransformer
|
|||||||
foreach ($chapter->pages as $page) {
|
foreach ($chapter->pages as $page) {
|
||||||
$page->chapter_id = 0;
|
$page->chapter_id = 0;
|
||||||
$page->save();
|
$page->save();
|
||||||
$page->changeBook($book->id);
|
$this->parentChanger->changeBook($page, $book->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->trashCan->destroyEntity($chapter);
|
$this->trashCan->destroyEntity($chapter);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace BookStack\Entities\Tools;
|
namespace BookStack\Entities\Tools;
|
||||||
|
|
||||||
|
use BookStack\App\AppVersion;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
use BookStack\Entities\Queries\PageQueries;
|
use BookStack\Entities\Queries\PageQueries;
|
||||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||||
@@ -13,6 +14,7 @@ use BookStack\Uploads\ImageRepo;
|
|||||||
use BookStack\Uploads\ImageService;
|
use BookStack\Uploads\ImageService;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
use BookStack\Util\HtmlContentFilter;
|
use BookStack\Util\HtmlContentFilter;
|
||||||
|
use BookStack\Util\HtmlContentFilterConfig;
|
||||||
use BookStack\Util\HtmlDocument;
|
use BookStack\Util\HtmlDocument;
|
||||||
use BookStack\Util\WebSafeMimeSniffer;
|
use BookStack\Util\WebSafeMimeSniffer;
|
||||||
use Closure;
|
use Closure;
|
||||||
@@ -317,11 +319,30 @@ class PageContent
|
|||||||
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
|
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config('app.allow_content_scripts')) {
|
$cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml());
|
||||||
HtmlContentFilter::removeScriptsFromDocument($doc);
|
$cached = cache()->get($cacheKey, null);
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $doc->getBodyInnerHtml();
|
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||||
|
$filter = new HtmlContentFilter($filterConfig);
|
||||||
|
$filtered = $filter->filterDocument($doc);
|
||||||
|
|
||||||
|
$cacheTime = 86400 * 7; // 1 week
|
||||||
|
cache()->put($cacheKey, $filtered, $cacheTime);
|
||||||
|
|
||||||
|
return $filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getContentCacheKey(string $html): string
|
||||||
|
{
|
||||||
|
$contentHash = md5($html);
|
||||||
|
$contentId = $this->page->id;
|
||||||
|
$contentTime = $this->page->updated_at?->timestamp ?? time();
|
||||||
|
$appVersion = AppVersion::get();
|
||||||
|
$filterConfig = config('app.content_filtering') ?? '';
|
||||||
|
return "page-content-cache::{$filterConfig}::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use BookStack\Entities\Queries\EntityQueries;
|
|||||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
|
use BookStack\Util\HtmlContentFilter;
|
||||||
|
use BookStack\Util\HtmlContentFilterConfig;
|
||||||
|
|
||||||
class PageEditorData
|
class PageEditorData
|
||||||
{
|
{
|
||||||
@@ -47,6 +49,7 @@ class PageEditorData
|
|||||||
$isDraftRevision = false;
|
$isDraftRevision = false;
|
||||||
$this->warnings = [];
|
$this->warnings = [];
|
||||||
$editActivity = new PageEditActivity($page);
|
$editActivity = new PageEditActivity($page);
|
||||||
|
$lastEditorId = $page->updated_by ?? user()->id;
|
||||||
|
|
||||||
if ($editActivity->hasActiveEditing()) {
|
if ($editActivity->hasActiveEditing()) {
|
||||||
$this->warnings[] = $editActivity->activeEditingMessage();
|
$this->warnings[] = $editActivity->activeEditingMessage();
|
||||||
@@ -58,11 +61,20 @@ class PageEditorData
|
|||||||
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
|
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
|
||||||
$isDraftRevision = true;
|
$isDraftRevision = true;
|
||||||
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
|
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
|
||||||
|
$lastEditorId = $userDraft->created_by;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get editor type and handle changes
|
||||||
$editorType = $this->getEditorType($page);
|
$editorType = $this->getEditorType($page);
|
||||||
$this->updateContentForEditor($page, $editorType);
|
$this->updateContentForEditor($page, $editorType);
|
||||||
|
|
||||||
|
// Filter HTML content if required
|
||||||
|
if ($editorType->isHtmlBased() && !old('html') && $lastEditorId !== user()->id) {
|
||||||
|
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||||
|
$filter = new HtmlContentFilter($filterConfig);
|
||||||
|
$page->html = $filter->filterString($page->html);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'book' => $page->book,
|
'book' => $page->book,
|
||||||
|
|||||||
40
app/Entities/Tools/ParentChanger.php
Normal file
40
app/Entities/Tools/ParentChanger.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Entities\Tools;
|
||||||
|
|
||||||
|
use BookStack\Entities\Models\BookChild;
|
||||||
|
use BookStack\Entities\Models\Chapter;
|
||||||
|
use BookStack\References\ReferenceUpdater;
|
||||||
|
|
||||||
|
class ParentChanger
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected SlugGenerator $slugGenerator,
|
||||||
|
protected ReferenceUpdater $referenceUpdater
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the parent book of a chapter or page.
|
||||||
|
*/
|
||||||
|
public function changeBook(BookChild $child, int $newBookId): void
|
||||||
|
{
|
||||||
|
$oldUrl = $child->getUrl();
|
||||||
|
|
||||||
|
$child->book_id = $newBookId;
|
||||||
|
$child->unsetRelation('book');
|
||||||
|
$this->slugGenerator->regenerateForEntity($child);
|
||||||
|
$child->save();
|
||||||
|
|
||||||
|
if ($oldUrl !== $child->getUrl()) {
|
||||||
|
$this->referenceUpdater->updateEntityReferences($child, $oldUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all child pages if a chapter
|
||||||
|
if ($child instanceof Chapter) {
|
||||||
|
foreach ($child->pages()->withTrashed()->get() as $page) {
|
||||||
|
$this->changeBook($page, $newBookId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@ namespace BookStack\Entities\Tools;
|
|||||||
use BookStack\App\Model;
|
use BookStack\App\Model;
|
||||||
use BookStack\App\SluggableInterface;
|
use BookStack\App\SluggableInterface;
|
||||||
use BookStack\Entities\Models\BookChild;
|
use BookStack\Entities\Models\BookChild;
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class SlugGenerator
|
class SlugGenerator
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Generate a fresh slug for the given entity.
|
* Generate a fresh slug for the given item.
|
||||||
* The slug will be generated so that it doesn't conflict within the same parent item.
|
* The slug will be generated so that it doesn't conflict within the same parent item.
|
||||||
*/
|
*/
|
||||||
public function generate(SluggableInterface&Model $model, string $slugSource): string
|
public function generate(SluggableInterface&Model $model, string $slugSource): string
|
||||||
@@ -23,6 +25,26 @@ class SlugGenerator
|
|||||||
return $slug;
|
return $slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate the slug for the given entity.
|
||||||
|
*/
|
||||||
|
public function regenerateForEntity(Entity $entity): string
|
||||||
|
{
|
||||||
|
$entity->slug = $this->generate($entity, $entity->name);
|
||||||
|
|
||||||
|
return $entity->slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate the slug for a user.
|
||||||
|
*/
|
||||||
|
public function regenerateForUser(User $user): string
|
||||||
|
{
|
||||||
|
$user->slug = $this->generate($user, $user->name);
|
||||||
|
|
||||||
|
return $user->slug;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a name as a URL slug.
|
* Format a name as a URL slug.
|
||||||
*/
|
*/
|
||||||
|
|||||||
97
app/Entities/Tools/SlugHistory.php
Normal file
97
app/Entities/Tools/SlugHistory.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Entities\Tools;
|
||||||
|
|
||||||
|
use BookStack\Entities\Models\Book;
|
||||||
|
use BookStack\Entities\Models\BookChild;
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
|
use BookStack\Entities\Models\EntityTable;
|
||||||
|
use BookStack\Entities\Models\SlugHistory as SlugHistoryModel;
|
||||||
|
use BookStack\Permissions\PermissionApplicator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class SlugHistory
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected PermissionApplicator $permissions,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the current slugs for the given entity.
|
||||||
|
*/
|
||||||
|
public function recordForEntity(Entity $entity): void
|
||||||
|
{
|
||||||
|
if (!$entity->id || !$entity->slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parentSlug = null;
|
||||||
|
if ($entity instanceof BookChild) {
|
||||||
|
$parentSlug = $entity->book()->first()?->slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latest = $this->getLatestEntryForEntity($entity);
|
||||||
|
if ($latest && $latest->slug === $entity->slug && $latest->parent_slug === $parentSlug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$info = [
|
||||||
|
'sluggable_type' => $entity->getMorphClass(),
|
||||||
|
'sluggable_id' => $entity->id,
|
||||||
|
'slug' => $entity->slug,
|
||||||
|
'parent_slug' => $parentSlug,
|
||||||
|
];
|
||||||
|
|
||||||
|
$entry = new SlugHistoryModel();
|
||||||
|
$entry->forceFill($info);
|
||||||
|
$entry->save();
|
||||||
|
|
||||||
|
if ($entity instanceof Book) {
|
||||||
|
$this->recordForBookChildren($entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function recordForBookChildren(Book $book): void
|
||||||
|
{
|
||||||
|
$query = EntityTable::query()
|
||||||
|
->select(['type', 'id', 'slug', DB::raw("'{$book->slug}' as parent_slug"), DB::raw('now() as created_at'), DB::raw('now() as updated_at')])
|
||||||
|
->where('book_id', '=', $book->id)
|
||||||
|
->whereNotNull('book_id');
|
||||||
|
|
||||||
|
SlugHistoryModel::query()->insertUsing(
|
||||||
|
['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
|
||||||
|
$query
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the latest visible entry for an entity which uses the given slug(s) in the history.
|
||||||
|
*/
|
||||||
|
public function lookupEntityIdUsingSlugs(string $type, string $slug, string $parentSlug = ''): ?int
|
||||||
|
{
|
||||||
|
$query = SlugHistoryModel::query()
|
||||||
|
->where('sluggable_type', '=', $type)
|
||||||
|
->where('slug', '=', $slug);
|
||||||
|
|
||||||
|
if ($parentSlug) {
|
||||||
|
$query->where('parent_slug', '=', $parentSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->permissions->restrictEntityRelationQuery($query, 'slug_history', 'sluggable_id', 'sluggable_type');
|
||||||
|
|
||||||
|
/** @var SlugHistoryModel|null $result */
|
||||||
|
$result = $query->orderBy('created_at', 'desc')->first();
|
||||||
|
|
||||||
|
return $result?->sluggable_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getLatestEntryForEntity(Entity $entity): SlugHistoryModel|null
|
||||||
|
{
|
||||||
|
return SlugHistoryModel::query()
|
||||||
|
->where('sluggable_type', '=', $entity->getMorphClass())
|
||||||
|
->where('sluggable_id', '=', $entity->id)
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -388,7 +388,7 @@ class TrashCan
|
|||||||
/**
|
/**
|
||||||
* Update entity relations to remove or update outstanding connections.
|
* Update entity relations to remove or update outstanding connections.
|
||||||
*/
|
*/
|
||||||
protected function destroyCommonRelations(Entity $entity)
|
protected function destroyCommonRelations(Entity $entity): void
|
||||||
{
|
{
|
||||||
Activity::removeEntity($entity);
|
Activity::removeEntity($entity);
|
||||||
$entity->views()->delete();
|
$entity->views()->delete();
|
||||||
@@ -402,6 +402,7 @@ class TrashCan
|
|||||||
$entity->watches()->delete();
|
$entity->watches()->delete();
|
||||||
$entity->referencesTo()->delete();
|
$entity->referencesTo()->delete();
|
||||||
$entity->referencesFrom()->delete();
|
$entity->referencesFrom()->delete();
|
||||||
|
$entity->slugHistory()->delete();
|
||||||
|
|
||||||
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
|
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
|
||||||
$imageService = app()->make(ImageService::class);
|
$imageService = app()->make(ImageService::class);
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ class ZipExportReader
|
|||||||
{
|
{
|
||||||
$this->open();
|
$this->open();
|
||||||
|
|
||||||
|
$info = $this->zip->statName('data.json');
|
||||||
|
if ($info === false) {
|
||||||
|
throw new ZipExportException(trans('errors.import_zip_cant_decode_data'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000;
|
||||||
|
if ($info['size'] > $maxSize) {
|
||||||
|
throw new ZipExportException(trans('errors.import_zip_data_too_large'));
|
||||||
|
}
|
||||||
|
|
||||||
// Validate json data exists, including metadata
|
// Validate json data exists, including metadata
|
||||||
$jsonData = $this->zip->getFromName('data.json') ?: '';
|
$jsonData = $this->zip->getFromName('data.json') ?: '';
|
||||||
$importData = json_decode($jsonData, true);
|
$importData = json_decode($jsonData, true);
|
||||||
@@ -73,6 +83,17 @@ class ZipExportReader
|
|||||||
return $this->zip->statName("files/{$fileName}") !== false;
|
return $this->zip->statName("files/{$fileName}") !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function fileWithinSizeLimit(string $fileName): bool
|
||||||
|
{
|
||||||
|
$fileInfo = $this->zip->statName("files/{$fileName}");
|
||||||
|
if ($fileInfo === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000;
|
||||||
|
return $fileInfo['size'] <= $maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return false|resource
|
* @return false|resource
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ class ZipFileReferenceRule implements ValidationRule
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritDoc
|
* @inheritDoc
|
||||||
*/
|
*/
|
||||||
@@ -23,6 +22,13 @@ class ZipFileReferenceRule implements ValidationRule
|
|||||||
$fail('validation.zip_file')->translate();
|
$fail('validation.zip_file')->translate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$this->context->zipReader->fileWithinSizeLimit($value)) {
|
||||||
|
$fail('validation.zip_file_size')->translate([
|
||||||
|
'attribute' => $value,
|
||||||
|
'size' => config('app.upload_limit'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if (!empty($this->acceptedMimes)) {
|
if (!empty($this->acceptedMimes)) {
|
||||||
$fileMime = $this->context->zipReader->sniffFileMime($value);
|
$fileMime = $this->context->zipReader->sniffFileMime($value);
|
||||||
if (!in_array($fileMime, $this->acceptedMimes)) {
|
if (!in_array($fileMime, $this->acceptedMimes)) {
|
||||||
|
|||||||
@@ -265,6 +265,12 @@ class ZipImportRunner
|
|||||||
|
|
||||||
protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile
|
protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile
|
||||||
{
|
{
|
||||||
|
if (!$reader->fileWithinSizeLimit($fileName)) {
|
||||||
|
throw new ZipImportException([
|
||||||
|
"File $fileName exceeds app upload limit."
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$tempPath = tempnam(sys_get_temp_dir(), 'bszipextract');
|
$tempPath = tempnam(sys_get_temp_dir(), 'bszipextract');
|
||||||
$fileStream = $reader->streamFile($fileName);
|
$fileStream = $reader->streamFile($fileName);
|
||||||
$tempStream = fopen($tempPath, 'wb');
|
$tempStream = fopen($tempPath, 'wb');
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ApiAuthenticate
|
|||||||
public function handle(Request $request, Closure $next)
|
public function handle(Request $request, Closure $next)
|
||||||
{
|
{
|
||||||
// Validate the token and it's users API access
|
// Validate the token and it's users API access
|
||||||
$this->ensureAuthorizedBySessionOrToken();
|
$this->ensureAuthorizedBySessionOrToken($request);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
@@ -28,22 +28,28 @@ class ApiAuthenticate
|
|||||||
*
|
*
|
||||||
* @throws ApiAuthException
|
* @throws ApiAuthException
|
||||||
*/
|
*/
|
||||||
protected function ensureAuthorizedBySessionOrToken(): void
|
protected function ensureAuthorizedBySessionOrToken(Request $request): void
|
||||||
{
|
{
|
||||||
// Return if the user is already found to be signed in via session-based auth.
|
// Use the active user session already exists.
|
||||||
// This is to make it easy to browser the API via browser after just logging into the system.
|
// This is to make it easy to explore API endpoints via the UI.
|
||||||
if (!user()->isGuest() || session()->isStarted()) {
|
if (session()->isStarted()) {
|
||||||
|
// Ensure the user has API access permission
|
||||||
if (!$this->sessionUserHasApiAccess()) {
|
if (!$this->sessionUserHasApiAccess()) {
|
||||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only allow GET requests for cookie-based API usage
|
||||||
|
if ($request->method() !== 'GET') {
|
||||||
|
throw new ApiAuthException(trans('errors.api_cookie_auth_only_get'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set our api guard to be the default for this request lifecycle.
|
// Set our api guard to be the default for this request lifecycle.
|
||||||
auth()->shouldUse('api');
|
auth()->shouldUse('api');
|
||||||
|
|
||||||
// Validate the token and it's users API access
|
// Validate the token and its users API access
|
||||||
auth()->authenticate();
|
auth()->authenticate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ use Illuminate\Session\Middleware\StartSession as Middleware;
|
|||||||
class StartSessionExtended extends Middleware
|
class StartSessionExtended extends Middleware
|
||||||
{
|
{
|
||||||
protected static array $pathPrefixesExcludedFromHistory = [
|
protected static array $pathPrefixesExcludedFromHistory = [
|
||||||
'uploads/images/'
|
'uploads/images/',
|
||||||
|
'dist/',
|
||||||
|
'manifest.json',
|
||||||
|
'opensearch.xml',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
45
app/References/ReferenceChangeContext.php
Normal file
45
app/References/ReferenceChangeContext.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\References;
|
||||||
|
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
|
|
||||||
|
class ReferenceChangeContext
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Entity pairs where the first is the old entity and the second is the new entity.
|
||||||
|
* @var array<array{0: Entity, 1: Entity}>
|
||||||
|
*/
|
||||||
|
protected array $changes = [];
|
||||||
|
|
||||||
|
public function add(Entity $oldEntity, Entity $newEntity): void
|
||||||
|
{
|
||||||
|
$this->changes[] = [$oldEntity, $newEntity];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the new entities from the changes.
|
||||||
|
*/
|
||||||
|
public function getNewEntities(): array
|
||||||
|
{
|
||||||
|
return array_column($this->changes, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the old entities from the changes.
|
||||||
|
*/
|
||||||
|
public function getOldEntities(): array
|
||||||
|
{
|
||||||
|
return array_column($this->changes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNewForOld(Entity $oldEntity): ?Entity
|
||||||
|
{
|
||||||
|
foreach ($this->changes as [$old, $new]) {
|
||||||
|
if ($old->id === $oldEntity->id && $old->type === $oldEntity->type) {
|
||||||
|
return $new;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ namespace BookStack\References;
|
|||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
use BookStack\Entities\Models\HasDescriptionInterface;
|
use BookStack\Entities\Models\HasDescriptionInterface;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Models\EntityContainerData;
|
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
use BookStack\Entities\Repos\RevisionRepo;
|
use BookStack\Entities\Repos\RevisionRepo;
|
||||||
use BookStack\Util\HtmlDocument;
|
use BookStack\Util\HtmlDocument;
|
||||||
@@ -30,6 +29,47 @@ class ReferenceUpdater
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change existing references for a range of entities using the given context.
|
||||||
|
*/
|
||||||
|
public function changeReferencesUsingContext(ReferenceChangeContext $context): void
|
||||||
|
{
|
||||||
|
$bindings = [];
|
||||||
|
foreach ($context->getOldEntities() as $old) {
|
||||||
|
$bindings[] = $old->getMorphClass();
|
||||||
|
$bindings[] = $old->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No targets to update within the context, so no need to continue.
|
||||||
|
if (count($bindings) < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$toReferenceQuery = '(to_type, to_id) IN (' . rtrim(str_repeat('(?,?),', count($bindings) / 2), ',') . ')';
|
||||||
|
|
||||||
|
// Cycle each new entity in the context
|
||||||
|
foreach ($context->getNewEntities() as $new) {
|
||||||
|
// For each, get all references from it which lead to other items within the context of the change
|
||||||
|
$newReferencesInContext = $new->referencesFrom()->whereRaw($toReferenceQuery, $bindings)->get();
|
||||||
|
// For each reference, update the URL and the reference entry
|
||||||
|
foreach ($newReferencesInContext as $reference) {
|
||||||
|
$oldToEntity = $reference->to;
|
||||||
|
$newToEntity = $context->getNewForOld($oldToEntity);
|
||||||
|
if ($newToEntity === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->updateReferencesWithinEntity($new, $oldToEntity->getUrl(), $newToEntity->getUrl());
|
||||||
|
if ($newToEntity instanceof Page && $oldToEntity instanceof Page) {
|
||||||
|
$this->updateReferencesWithinEntity($new, $oldToEntity->getPermalink(), $newToEntity->getPermalink());
|
||||||
|
}
|
||||||
|
$reference->to_id = $newToEntity->id;
|
||||||
|
$reference->to_type = $newToEntity->getMorphClass();
|
||||||
|
$reference->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Reference[]
|
* @return Reference[]
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ class SearchController extends Controller
|
|||||||
$searchOpts = SearchOptions::fromRequest($request);
|
$searchOpts = SearchOptions::fromRequest($request);
|
||||||
$fullSearchString = $searchOpts->toString();
|
$fullSearchString = $searchOpts->toString();
|
||||||
$page = intval($request->get('page', '0')) ?: 1;
|
$page = intval($request->get('page', '0')) ?: 1;
|
||||||
|
$count = setting()->getInteger('lists-page-count-search', 18, 1, 1000);
|
||||||
|
|
||||||
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
|
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, $count);
|
||||||
$formatter->format($results['results']->all(), $searchOpts);
|
$formatter->format($results['results']->all(), $searchOpts);
|
||||||
$paginator = new LengthAwarePaginator($results['results'], $results['total'], 20, $page);
|
$paginator = new LengthAwarePaginator($results['results'], $results['total'], $count, $page);
|
||||||
$paginator->setPath('/search');
|
$paginator->setPath(url('/search'));
|
||||||
$paginator->appends($request->except('page'));
|
$paginator->appends($request->except('page'));
|
||||||
|
|
||||||
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
|
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
|
||||||
@@ -77,8 +78,9 @@ class SearchController extends Controller
|
|||||||
|
|
||||||
// Search for entities otherwise show most popular
|
// Search for entities otherwise show most popular
|
||||||
if ($searchTerm !== false) {
|
if ($searchTerm !== false) {
|
||||||
$searchTerm .= ' {type:' . implode('|', $entityTypes) . '}';
|
$options = SearchOptions::fromString($searchTerm);
|
||||||
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20)['results'];
|
$options->setFilter('type', implode('|', $entityTypes));
|
||||||
|
$entities = $this->searchRunner->searchEntities($options, 'all', 1, 20)['results'];
|
||||||
} else {
|
} else {
|
||||||
$entities = $queryPopular->run(20, 0, $entityTypes);
|
$entities = $queryPopular->run(20, 0, $entityTypes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,4 +82,12 @@ class SearchOptionSet
|
|||||||
$values = array_values(array_filter($this->options, fn (SearchOption $option) => !$option->negated));
|
$values = array_values(array_filter($this->options, fn (SearchOption $option) => !$option->negated));
|
||||||
return new self($values);
|
return new self($values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return self<T>
|
||||||
|
*/
|
||||||
|
public function limit(int $limit): self
|
||||||
|
{
|
||||||
|
return new self(array_slice(array_values($this->options), 0, $limit));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class SearchOptions
|
|||||||
{
|
{
|
||||||
$instance = new self();
|
$instance = new self();
|
||||||
$instance->addOptionsFromString($search);
|
$instance->addOptionsFromString($search);
|
||||||
|
$instance->limitOptions();
|
||||||
return $instance;
|
return $instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +88,8 @@ class SearchOptions
|
|||||||
$instance->filters = $instance->filters->merge($extras->filters);
|
$instance->filters = $instance->filters->merge($extras->filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$instance->limitOptions();
|
||||||
|
|
||||||
return $instance;
|
return $instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +150,25 @@ class SearchOptions
|
|||||||
$this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
|
$this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limit the amount of search options to reasonable levels.
|
||||||
|
* Provides higher limits to logged-in users since that signals a slightly
|
||||||
|
* higher level of trust.
|
||||||
|
*/
|
||||||
|
protected function limitOptions(): void
|
||||||
|
{
|
||||||
|
$userLoggedIn = !user()->isGuest();
|
||||||
|
$searchLimit = $userLoggedIn ? 10 : 5;
|
||||||
|
$exactLimit = $userLoggedIn ? 4 : 2;
|
||||||
|
$tagLimit = $userLoggedIn ? 8 : 4;
|
||||||
|
$filterLimit = $userLoggedIn ? 10 : 5;
|
||||||
|
|
||||||
|
$this->searches = $this->searches->limit($searchLimit);
|
||||||
|
$this->exacts = $this->exacts->limit($exactLimit);
|
||||||
|
$this->tags = $this->tags->limit($tagLimit);
|
||||||
|
$this->filters = $this->filters->limit($filterLimit);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode backslash escaping within the input string.
|
* Decode backslash escaping within the input string.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class AppSettingsStore
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function storeFromUpdateRequest(Request $request, string $category)
|
public function storeFromUpdateRequest(Request $request, string $category): void
|
||||||
{
|
{
|
||||||
$this->storeSimpleSettings($request);
|
$this->storeSimpleSettings($request);
|
||||||
if ($category === 'customization') {
|
if ($category === 'customization') {
|
||||||
@@ -76,7 +76,7 @@ class AppSettingsStore
|
|||||||
protected function storeSimpleSettings(Request $request): void
|
protected function storeSimpleSettings(Request $request): void
|
||||||
{
|
{
|
||||||
foreach ($request->all() as $name => $value) {
|
foreach ($request->all() as $name => $value) {
|
||||||
if (strpos($name, 'setting-') !== 0) {
|
if (!str_starts_with($name, 'setting-')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ class AppSettingsStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function destroyExistingSettingImage(string $settingKey)
|
protected function destroyExistingSettingImage(string $settingKey): void
|
||||||
{
|
{
|
||||||
$existingVal = setting()->get($settingKey);
|
$existingVal = setting()->get($settingKey);
|
||||||
if ($existingVal) {
|
if ($existingVal) {
|
||||||
|
|||||||
@@ -28,6 +28,21 @@ class SettingService
|
|||||||
return $this->formatValue($value, $default);
|
return $this->formatValue($value, $default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a setting from the database as an integer.
|
||||||
|
* Returns the default value if not found or not an integer, and clamps the value to the given min/max range.
|
||||||
|
*/
|
||||||
|
public function getInteger(string $key, int $default, int $min = 0, int $max = PHP_INT_MAX): int
|
||||||
|
{
|
||||||
|
$value = $this->get($key, $default);
|
||||||
|
if (!is_numeric($value)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
$int = intval($value);
|
||||||
|
return max($min, min($max, $int));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a value from the session instead of the main store option.
|
* Get a value from the session instead of the main store option.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ class UserNotificationPreferences
|
|||||||
return $this->getNotificationSetting('comment-replies');
|
return $this->getNotificationSetting('comment-replies');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function notifyOnCommentMentions(): bool
|
||||||
|
{
|
||||||
|
return $this->getNotificationSetting('comment-mentions');
|
||||||
|
}
|
||||||
|
|
||||||
public function updateFromSettingsArray(array $settings)
|
public function updateFromSettingsArray(array $settings)
|
||||||
{
|
{
|
||||||
$allowList = ['own-page-changes', 'own-page-comments', 'comment-replies'];
|
$allowList = ['own-page-changes', 'own-page-comments', 'comment-replies', 'comment-mentions'];
|
||||||
foreach ($settings as $setting => $status) {
|
foreach ($settings as $setting => $status) {
|
||||||
if (!in_array($setting, $allowList)) {
|
if (!in_array($setting, $allowList)) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ use BookStack\Entities\Models\Chapter;
|
|||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
use BookStack\Entities\Queries\EntityQueries;
|
use BookStack\Entities\Queries\EntityQueries;
|
||||||
|
use BookStack\Entities\Tools\ParentChanger;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
|
|
||||||
class BookSorter
|
class BookSorter
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected EntityQueries $queries,
|
protected EntityQueries $queries,
|
||||||
|
protected ParentChanger $parentChanger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +157,7 @@ class BookSorter
|
|||||||
|
|
||||||
// Action the required changes
|
// Action the required changes
|
||||||
if ($bookChanged) {
|
if ($bookChanged) {
|
||||||
$model = $model->changeBook($newBook->id);
|
$this->parentChanger->changeBook($model, $newBook->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($model instanceof Page && $chapterChanged) {
|
if ($model instanceof Page && $chapterChanged) {
|
||||||
|
|||||||
@@ -4,25 +4,16 @@ namespace BookStack\Theming;
|
|||||||
|
|
||||||
use BookStack\Util\CspService;
|
use BookStack\Util\CspService;
|
||||||
use BookStack\Util\HtmlContentFilter;
|
use BookStack\Util\HtmlContentFilter;
|
||||||
|
use BookStack\Util\HtmlContentFilterConfig;
|
||||||
use BookStack\Util\HtmlNonceApplicator;
|
use BookStack\Util\HtmlNonceApplicator;
|
||||||
use Illuminate\Contracts\Cache\Repository as Cache;
|
use Illuminate\Contracts\Cache\Repository as Cache;
|
||||||
|
|
||||||
class CustomHtmlHeadContentProvider
|
class CustomHtmlHeadContentProvider
|
||||||
{
|
{
|
||||||
/**
|
public function __construct(
|
||||||
* @var CspService
|
protected CspService $cspService,
|
||||||
*/
|
protected Cache $cache
|
||||||
protected $cspService;
|
) {
|
||||||
|
|
||||||
/**
|
|
||||||
* @var Cache
|
|
||||||
*/
|
|
||||||
protected $cache;
|
|
||||||
|
|
||||||
public function __construct(CspService $cspService, Cache $cache)
|
|
||||||
{
|
|
||||||
$this->cspService = $cspService;
|
|
||||||
$this->cache = $cache;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +41,8 @@ class CustomHtmlHeadContentProvider
|
|||||||
$hash = md5($content);
|
$hash = md5($content);
|
||||||
|
|
||||||
return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) {
|
return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) {
|
||||||
return HtmlContentFilter::removeScriptsFromHtmlString($content);
|
$config = new HtmlContentFilterConfig(filterOutNonContentElements: false, useAllowListFilter: false);
|
||||||
|
return (new HtmlContentFilter($config))->filterString($content);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,21 +5,22 @@ namespace BookStack\Theming;
|
|||||||
use BookStack\Facades\Theme;
|
use BookStack\Facades\Theme;
|
||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
use BookStack\Util\FilePathNormalizer;
|
use BookStack\Util\FilePathNormalizer;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
class ThemeController extends Controller
|
class ThemeController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Serve a public file from the configured theme.
|
* Serve a public file from the configured theme.
|
||||||
*/
|
*/
|
||||||
public function publicFile(string $theme, string $path)
|
public function publicFile(string $theme, string $path): StreamedResponse
|
||||||
{
|
{
|
||||||
$cleanPath = FilePathNormalizer::normalize($path);
|
$cleanPath = FilePathNormalizer::normalize($path);
|
||||||
if ($theme !== Theme::getTheme() || !$cleanPath) {
|
if ($theme !== Theme::getTheme() || !$cleanPath) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$filePath = theme_path("public/{$cleanPath}");
|
$filePath = Theme::findFirstFile("public/{$cleanPath}");
|
||||||
if (!file_exists($filePath)) {
|
if (!$filePath) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,16 @@ class ThemeEvents
|
|||||||
*/
|
*/
|
||||||
const ROUTES_REGISTER_WEB_AUTH = 'routes_register_web_auth';
|
const ROUTES_REGISTER_WEB_AUTH = 'routes_register_web_auth';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme register views event.
|
||||||
|
* Called by the theme system when a theme is active, so that custom view templates can be registered
|
||||||
|
* to be rendered in addition to existing app views.
|
||||||
|
*
|
||||||
|
* @param \BookStack\Theming\ThemeViews $themeViews
|
||||||
|
*/
|
||||||
|
const THEME_REGISTER_VIEWS = 'theme_register_views';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web before middleware action.
|
* Web before middleware action.
|
||||||
* Runs before the request is handled but after all other middleware apart from those
|
* Runs before the request is handled but after all other middleware apart from those
|
||||||
|
|||||||
59
app/Theming/ThemeModule.php
Normal file
59
app/Theming/ThemeModule.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Theming;
|
||||||
|
|
||||||
|
readonly class ThemeModule
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
public string $description,
|
||||||
|
public string $version,
|
||||||
|
public string $folderName,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ThemeModule instance from JSON data.
|
||||||
|
*
|
||||||
|
* @throws ThemeModuleException
|
||||||
|
*/
|
||||||
|
public static function fromJson(array $data, string $folderName): self
|
||||||
|
{
|
||||||
|
if (empty($data['name']) || !is_string($data['name'])) {
|
||||||
|
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'name' property");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($data['description']) || !is_string($data['description'])) {
|
||||||
|
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'description' property");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($data['version']) || !is_string($data['version'])) {
|
||||||
|
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'version' property");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^v?\d+\.\d+\.\d+(-.*)?$/', $data['version'])) {
|
||||||
|
throw new ThemeModuleException("Module in folder \"{$folderName}\" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
name: $data['name'],
|
||||||
|
description: $data['description'],
|
||||||
|
version: $data['version'],
|
||||||
|
folderName: $folderName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a path for a file within this module.
|
||||||
|
*/
|
||||||
|
public function path($path = ''): string
|
||||||
|
{
|
||||||
|
$component = trim($path, '/');
|
||||||
|
return theme_path("modules/{$this->folderName}/{$component}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersion(): string
|
||||||
|
{
|
||||||
|
return str_starts_with($this->version, 'v') ? $this->version : 'v' . $this->version;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/Theming/ThemeModuleException.php
Normal file
7
app/Theming/ThemeModuleException.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Theming;
|
||||||
|
|
||||||
|
class ThemeModuleException extends \Exception
|
||||||
|
{
|
||||||
|
}
|
||||||
133
app/Theming/ThemeModuleManager.php
Normal file
133
app/Theming/ThemeModuleManager.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Theming;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ThemeModuleManager
|
||||||
|
{
|
||||||
|
/** @var array<string, ThemeModule>|null */
|
||||||
|
protected array|null $loadedModules = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected string $modulesFolderPath
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ThemeModule>
|
||||||
|
*/
|
||||||
|
public function getByName(string $name): array
|
||||||
|
{
|
||||||
|
return array_filter($this->load(), fn(ThemeModule $module) => $module->name === $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteModuleFolder(string $moduleFolderName): void
|
||||||
|
{
|
||||||
|
$modules = $this->load();
|
||||||
|
$module = $modules[$moduleFolderName] ?? null;
|
||||||
|
if (!$module) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$moduleFolderPath = $module->path('');
|
||||||
|
if (!file_exists($moduleFolderPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->deleteDirectoryRecursively($moduleFolderPath);
|
||||||
|
unset($this->loadedModules[$moduleFolderName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ThemeModuleException
|
||||||
|
*/
|
||||||
|
public function addFromZip(string $name, ThemeModuleZip $zip): ThemeModule
|
||||||
|
{
|
||||||
|
$baseFolderName = Str::limit(Str::slug($name), 20);
|
||||||
|
$folderName = $baseFolderName;
|
||||||
|
while (!$baseFolderName || file_exists($this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName)) {
|
||||||
|
$folderName = ($baseFolderName ?: 'mod') . '-' . Str::random(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
$folderPath = $this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName;
|
||||||
|
$zip->extractTo($folderPath);
|
||||||
|
|
||||||
|
$module = $this->loadFromFolder($folderName);
|
||||||
|
if (!$module) {
|
||||||
|
throw new ThemeModuleException("Failed to load module from zip file after extraction");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $module;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function deleteDirectoryRecursively(string $path): void
|
||||||
|
{
|
||||||
|
$items = array_diff(scandir($path), ['.', '..']);
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$itemPath = $path . DIRECTORY_SEPARATOR . $item;
|
||||||
|
if (is_dir($itemPath)) {
|
||||||
|
$this->deleteDirectoryRecursively($itemPath);
|
||||||
|
} else {
|
||||||
|
$deleted = unlink($itemPath);
|
||||||
|
if (!$deleted) {
|
||||||
|
throw new ThemeModuleException("Failed to delete file at \"{$itemPath}\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rmdir($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function load(): array
|
||||||
|
{
|
||||||
|
if ($this->loadedModules !== null) {
|
||||||
|
return $this->loadedModules;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($this->modulesFolderPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subFolders = array_filter(scandir($this->modulesFolderPath), function ($item) {
|
||||||
|
return $item !== '.' && $item !== '..' && is_dir($this->modulesFolderPath . DIRECTORY_SEPARATOR . $item);
|
||||||
|
});
|
||||||
|
|
||||||
|
$modules = [];
|
||||||
|
|
||||||
|
foreach ($subFolders as $folderName) {
|
||||||
|
$module = $this->loadFromFolder($folderName);
|
||||||
|
if ($module) {
|
||||||
|
$modules[$folderName] = $module;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadedModules = $modules;
|
||||||
|
|
||||||
|
return $modules;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadFromFolder(string $folderName): ThemeModule|null
|
||||||
|
{
|
||||||
|
$moduleJsonFile = $this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName . DIRECTORY_SEPARATOR . 'bookstack-module.json';
|
||||||
|
if (!file_exists($moduleJsonFile)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$jsonContent = file_get_contents($moduleJsonFile);
|
||||||
|
$jsonData = json_decode($jsonContent, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
throw new ThemeModuleException("Invalid JSON in module file at \"{$moduleJsonFile}\": " . json_last_error_msg());
|
||||||
|
}
|
||||||
|
|
||||||
|
$module = ThemeModule::fromJson($jsonData, $folderName);
|
||||||
|
} catch (ThemeModuleException $exception) {
|
||||||
|
throw $exception;
|
||||||
|
} catch (\Exception $exception) {
|
||||||
|
throw new ThemeModuleException("Failed loading module from \"{$moduleJsonFile}\" with error: {$exception->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $module;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/Theming/ThemeModuleZip.php
Normal file
98
app/Theming/ThemeModuleZip.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Theming;
|
||||||
|
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
readonly class ThemeModuleZip
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected string $path
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extractTo(string $destinationPath): void
|
||||||
|
{
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($this->path);
|
||||||
|
$zip->extractTo($destinationPath);
|
||||||
|
$zip->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the module's JSON metadata to read it into a ThemeModule instance.
|
||||||
|
* @throws ThemeModuleException
|
||||||
|
*/
|
||||||
|
public function getModuleInstance(): ThemeModule
|
||||||
|
{
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$open = $zip->open($this->path);
|
||||||
|
if ($open !== true) {
|
||||||
|
throw new ThemeModuleException("Unable to open zip file at {$this->path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$moduleJsonText = $zip->getFromName('bookstack-module.json');
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
if ($moduleJsonText === false) {
|
||||||
|
throw new ThemeModuleException("bookstack-module.json not found within module ZIP at {$this->path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$moduleJson = json_decode($moduleJsonText, true);
|
||||||
|
if ($moduleJson === null) {
|
||||||
|
throw new ThemeModuleException("Could not read JSON from bookstack-module.json within module ZIP at {$this->path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ThemeModule::fromJson($moduleJson, '_temp');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to the zip file.
|
||||||
|
*/
|
||||||
|
public function getPath(): string
|
||||||
|
{
|
||||||
|
return $this->path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the zip file exists and that it appears to be a valid zip file.
|
||||||
|
*/
|
||||||
|
public function exists(): bool
|
||||||
|
{
|
||||||
|
if (!file_exists($this->path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$open = $zip->open($this->path, ZipArchive::RDONLY);
|
||||||
|
if ($open === true) {
|
||||||
|
$zip->close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the total size of the zip file contents when uncompressed.
|
||||||
|
*/
|
||||||
|
public function getContentsSize(): int
|
||||||
|
{
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
|
||||||
|
if ($zip->open($this->path) !== true) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalSize = 0;
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$stat = $zip->statIndex($i);
|
||||||
|
if ($stat !== false) {
|
||||||
|
$totalSize += $stat['size'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return $totalSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use BookStack\Access\SocialDriverManager;
|
|||||||
use BookStack\Exceptions\ThemeException;
|
use BookStack\Exceptions\ThemeException;
|
||||||
use Illuminate\Console\Application;
|
use Illuminate\Console\Application;
|
||||||
use Illuminate\Console\Application as Artisan;
|
use Illuminate\Console\Application as Artisan;
|
||||||
|
use Illuminate\View\FileViewFinder;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
|
||||||
class ThemeService
|
class ThemeService
|
||||||
@@ -15,6 +16,11 @@ class ThemeService
|
|||||||
*/
|
*/
|
||||||
protected array $listeners = [];
|
protected array $listeners = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, ThemeModule>
|
||||||
|
*/
|
||||||
|
protected array $modules = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the currently configured theme.
|
* Get the currently configured theme.
|
||||||
* Returns an empty string if not configured.
|
* Returns an empty string if not configured.
|
||||||
@@ -76,20 +82,71 @@ class ThemeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read any actions from the set theme path if the 'functions.php' file exists.
|
* Read any actions from the 'functions.php' file of the active theme or its modules.
|
||||||
*/
|
*/
|
||||||
public function readThemeActions(): void
|
public function readThemeActions(): void
|
||||||
{
|
{
|
||||||
$themeActionsFile = theme_path('functions.php');
|
$moduleFunctionFiles = array_map(function (ThemeModule $module): string {
|
||||||
if ($themeActionsFile && file_exists($themeActionsFile)) {
|
return $module->path('functions.php');
|
||||||
|
}, $this->modules);
|
||||||
|
$allFunctionFiles = array_merge(array_values($moduleFunctionFiles), [theme_path('functions.php')]);
|
||||||
|
$filteredFunctionFiles = array_filter($allFunctionFiles, function (string $file): bool {
|
||||||
|
return $file && file_exists($file);
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach ($filteredFunctionFiles as $functionFile) {
|
||||||
try {
|
try {
|
||||||
require $themeActionsFile;
|
require $functionFile;
|
||||||
} catch (\Error $exception) {
|
} catch (\Error $exception) {
|
||||||
throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}");
|
throw new ThemeException("Failed loading theme functions file at \"{$functionFile}\" with error: {$exception->getMessage()}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the modules folder and load in any valid theme modules.
|
||||||
|
* @throws ThemeModuleException
|
||||||
|
*/
|
||||||
|
public function loadModules(): void
|
||||||
|
{
|
||||||
|
$modulesFolder = theme_path('modules');
|
||||||
|
if (!$modulesFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->modules = (new ThemeModuleManager($modulesFolder))->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all loaded theme modules.
|
||||||
|
* @return array<string, ThemeModule>
|
||||||
|
*/
|
||||||
|
public function getModules(): array
|
||||||
|
{
|
||||||
|
return $this->modules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look for a specific file within the theme or its modules.
|
||||||
|
* Returns the first file found or null if not found.
|
||||||
|
*/
|
||||||
|
public function findFirstFile(string $path): ?string
|
||||||
|
{
|
||||||
|
$themePath = theme_path($path);
|
||||||
|
if (file_exists($themePath)) {
|
||||||
|
return $themePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->modules as $module) {
|
||||||
|
$customizedFile = $module->path($path);
|
||||||
|
if (file_exists($customizedFile)) {
|
||||||
|
return $customizedFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see SocialDriverManager::addSocialDriver
|
* @see SocialDriverManager::addSocialDriver
|
||||||
*/
|
*/
|
||||||
|
|||||||
115
app/Theming/ThemeViews.php
Normal file
115
app/Theming/ThemeViews.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Theming;
|
||||||
|
|
||||||
|
use BookStack\Exceptions\ThemeException;
|
||||||
|
use Illuminate\View\FileViewFinder;
|
||||||
|
|
||||||
|
class ThemeViews
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, array<string, int>>
|
||||||
|
*/
|
||||||
|
protected array $beforeViews = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array<string, int>>
|
||||||
|
*/
|
||||||
|
protected array $afterViews = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected FileViewFinder $finder
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register any extra paths for where we may expect views to be located
|
||||||
|
* with the FileViewFinder, to make custom views available for use.
|
||||||
|
* @param ThemeModule[] $modules
|
||||||
|
*/
|
||||||
|
public function registerViewPathsForTheme(array $modules): void
|
||||||
|
{
|
||||||
|
foreach ($modules as $module) {
|
||||||
|
$moduleViewsPath = $module->path('views');
|
||||||
|
if (file_exists($moduleViewsPath) && is_dir($moduleViewsPath)) {
|
||||||
|
$this->finder->prependLocation($moduleViewsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->finder->prependLocation(theme_path());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide the response for a blade template view include.
|
||||||
|
*/
|
||||||
|
public function handleViewInclude(string $viewPath, array $data = [], array $mergeData = []): string
|
||||||
|
{
|
||||||
|
if (!$this->hasRegisteredViews()) {
|
||||||
|
return view()->make($viewPath, $data, $mergeData)->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains('book-tree', $viewPath)) {
|
||||||
|
dd($viewPath, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$viewsContent = [
|
||||||
|
...$this->renderViewSets($this->beforeViews[$viewPath] ?? [], $data, $mergeData),
|
||||||
|
view()->make($viewPath, $data, $mergeData)->render(),
|
||||||
|
...$this->renderViewSets($this->afterViews[$viewPath] ?? [], $data, $mergeData),
|
||||||
|
];
|
||||||
|
|
||||||
|
return implode("\n", $viewsContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom view to be rendered before the given target view is included in the template system.
|
||||||
|
*/
|
||||||
|
public function renderBefore(string $targetView, string $localView, int $priority = 50): void
|
||||||
|
{
|
||||||
|
$this->registerAdjacentView($this->beforeViews, $targetView, $localView, $priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom view to be rendered after the given target view is included in the template system.
|
||||||
|
*/
|
||||||
|
public function renderAfter(string $targetView, string $localView, int $priority = 50): void
|
||||||
|
{
|
||||||
|
$this->registerAdjacentView($this->afterViews, $targetView, $localView, $priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasRegisteredViews(): bool
|
||||||
|
{
|
||||||
|
return !empty($this->beforeViews) || !empty($this->afterViews);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function registerAdjacentView(array &$location, string $targetView, string $localView, int $priority = 50): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$viewPath = $this->finder->find($localView);
|
||||||
|
} catch (\InvalidArgumentException $exception) {
|
||||||
|
throw new ThemeException("Expected registered view file with name \"{$localView}\" could not be found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($location[$targetView])) {
|
||||||
|
$location[$targetView] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$location[$targetView][$viewPath] = $priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $viewSet
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
protected function renderViewSets(array $viewSet, array $data, array $mergeData): array
|
||||||
|
{
|
||||||
|
$paths = array_keys($viewSet);
|
||||||
|
usort($paths, function (string $a, string $b) use ($viewSet) {
|
||||||
|
return $viewSet[$a] <=> $viewSet[$b];
|
||||||
|
});
|
||||||
|
|
||||||
|
return array_map(function (string $viewPath) use ($data, $mergeData) {
|
||||||
|
return view()->file($viewPath, $data, $mergeData)->render();
|
||||||
|
}, $paths);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace BookStack\Translation;
|
namespace BookStack\Translation;
|
||||||
|
|
||||||
|
use BookStack\Facades\Theme;
|
||||||
use Illuminate\Translation\FileLoader as BaseLoader;
|
use Illuminate\Translation\FileLoader as BaseLoader;
|
||||||
|
|
||||||
class FileLoader extends BaseLoader
|
class FileLoader extends BaseLoader
|
||||||
@@ -12,11 +13,6 @@ class FileLoader extends BaseLoader
|
|||||||
* Extends Laravel's translation FileLoader to look in multiple directories
|
* Extends Laravel's translation FileLoader to look in multiple directories
|
||||||
* so that we can load in translation overrides from the theme file if wanted.
|
* so that we can load in translation overrides from the theme file if wanted.
|
||||||
*
|
*
|
||||||
* Note: As of using Laravel 10, this may now be redundant since Laravel's
|
|
||||||
* file loader supports multiple paths. This needs further testing though
|
|
||||||
* to confirm if Laravel works how we expect, since we specifically need
|
|
||||||
* the theme folder to be able to partially override core lang files.
|
|
||||||
*
|
|
||||||
* @param string $locale
|
* @param string $locale
|
||||||
* @param string $group
|
* @param string $group
|
||||||
* @param string|null $namespace
|
* @param string|null $namespace
|
||||||
@@ -32,9 +28,18 @@ class FileLoader extends BaseLoader
|
|||||||
if (is_null($namespace) || $namespace === '*') {
|
if (is_null($namespace) || $namespace === '*') {
|
||||||
$themePath = theme_path('lang');
|
$themePath = theme_path('lang');
|
||||||
$themeTranslations = $themePath ? $this->loadPaths([$themePath], $locale, $group) : [];
|
$themeTranslations = $themePath ? $this->loadPaths([$themePath], $locale, $group) : [];
|
||||||
$originalTranslations = $this->loadPaths($this->paths, $locale, $group);
|
|
||||||
|
|
||||||
return array_merge($originalTranslations, $themeTranslations);
|
$modules = Theme::getModules();
|
||||||
|
$moduleTranslations = [];
|
||||||
|
foreach ($modules as $module) {
|
||||||
|
$modulePath = $module->path('lang');
|
||||||
|
if (file_exists($modulePath)) {
|
||||||
|
$moduleTranslations = array_merge($moduleTranslations, $this->loadPaths([$modulePath], $locale, $group));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalTranslations = $this->loadPaths($this->paths, $locale, $group);
|
||||||
|
return array_merge($originalTranslations, $moduleTranslations, $themeTranslations);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->loadNamespaced($locale, $group, $namespace);
|
return $this->loadNamespaced($locale, $group, $namespace);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class ImageResizer
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the thumbnail for an image.
|
* Get the thumbnail for an image.
|
||||||
* If $keepRatio is true only the width will be used.
|
* If $keepRatio is true, only the width will be used.
|
||||||
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
|
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
|
||||||
*
|
*
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
@@ -84,7 +84,7 @@ class ImageResizer
|
|||||||
return $this->storage->getPublicUrl($cachedThumbPath);
|
return $this->storage->getPublicUrl($cachedThumbPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If thumbnail has already been generated, serve that and cache path
|
// If a thumbnail has already been generated, serve that and cache path
|
||||||
$disk = $this->storage->getDisk($image->type);
|
$disk = $this->storage->getDisk($image->type);
|
||||||
if (!$shouldCreate && $disk->exists($thumbFilePath)) {
|
if (!$shouldCreate && $disk->exists($thumbFilePath)) {
|
||||||
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
|
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
|
||||||
@@ -110,7 +110,7 @@ class ImageResizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resize the image of given data to the specified size, and return the new image data.
|
* Resize the image of given data to the specified size and return the new image data.
|
||||||
* Format will remain the same as the input format, unless specified.
|
* Format will remain the same as the input format, unless specified.
|
||||||
*
|
*
|
||||||
* @throws ImageUploadException
|
* @throws ImageUploadException
|
||||||
@@ -125,6 +125,7 @@ class ImageResizer
|
|||||||
try {
|
try {
|
||||||
$thumb = $this->interventionFromImageData($imageData, $format);
|
$thumb = $this->interventionFromImageData($imageData, $format);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
Log::error('Failed to resize image with error:' . $e->getMessage());
|
||||||
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
|
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,17 +155,21 @@ class ImageResizer
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an intervention image instance from the given image data.
|
* Create an intervention image instance from the given image data.
|
||||||
* Performs some manual library usage to ensure image is specifically loaded
|
* Performs some manual library usage to ensure the image is specifically loaded
|
||||||
* from given binary data instead of data being misinterpreted.
|
* from given binary data instead of data being misinterpreted.
|
||||||
*/
|
*/
|
||||||
protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
|
protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
|
||||||
{
|
{
|
||||||
|
if (!extension_loaded('gd')) {
|
||||||
|
throw new ImageUploadException('The PHP "gd" extension is required to resize images, but is missing.');
|
||||||
|
}
|
||||||
|
|
||||||
$manager = new ImageManager(
|
$manager = new ImageManager(
|
||||||
new Driver(),
|
new Driver(),
|
||||||
autoOrientation: false,
|
autoOrientation: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure gif images are decoded natively instead of deferring to intervention GIF
|
// Ensure GIF images are decoded natively instead of deferring to intervention GIF
|
||||||
// handling since we don't need the added animation support.
|
// handling since we don't need the added animation support.
|
||||||
$isGif = $fileType === 'gif';
|
$isGif = $fileType === 'gif';
|
||||||
$decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class;
|
$decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class;
|
||||||
@@ -223,7 +228,7 @@ class ImageResizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the image is a gif. Returns true if it is, else false.
|
* Checks if the image is a GIF. Returns true if it is, else false.
|
||||||
*/
|
*/
|
||||||
protected function isGif(Image $image): bool
|
protected function isGif(Image $image): bool
|
||||||
{
|
{
|
||||||
@@ -250,7 +255,7 @@ class ImageResizer
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the given avif image data represents an animated image.
|
* Check if the given avif image data represents an animated image.
|
||||||
* This is based up the answer here: https://stackoverflow.com/a/79457313
|
* This is based upon the answer here: https://stackoverflow.com/a/79457313
|
||||||
*/
|
*/
|
||||||
protected function isAnimatedAvifData(string &$imageData): bool
|
protected function isAnimatedAvifData(string &$imageData): bool
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ class ImageService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->storage->usingSecureImages() && user()->isGuest()) {
|
if ($this->blockedBySecureImages()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,13 +280,24 @@ class ImageService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->storage->usingSecureImages() && user()->isGuest()) {
|
if ($this->blockedBySecureImages()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->imageFileExists($image->path, $image->type);
|
return $this->imageFileExists($image->path, $image->type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user should be blocked from accessing images based on if secure images are enabled
|
||||||
|
* and if public access is enabled for the application.
|
||||||
|
*/
|
||||||
|
protected function blockedBySecureImages(): bool
|
||||||
|
{
|
||||||
|
$enforced = $this->storage->usingSecureImages() && !setting('app-public');
|
||||||
|
|
||||||
|
return $enforced && user()->isGuest();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the given image path exists for the given image type and that it is likely an image file.
|
* Check if the given image path exists for the given image type and that it is likely an image file.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class ImageStorage
|
|||||||
return 'local';
|
return 'local';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename local_secure options to get our image specific storage driver which
|
// Rename local_secure options to get our image-specific storage driver, which
|
||||||
// is scoped to the relevant image directories.
|
// is scoped to the relevant image directories.
|
||||||
if ($localSecureInUse) {
|
if ($localSecureInUse) {
|
||||||
return 'local_secure_images';
|
return 'local_secure_images';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace BookStack\Users\Controllers;
|
|||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class UserSearchController extends Controller
|
class UserSearchController extends Controller
|
||||||
@@ -34,8 +35,43 @@ class UserSearchController extends Controller
|
|||||||
$query->where('name', 'like', '%' . $search . '%');
|
$query->where('name', 'like', '%' . $search . '%');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var Collection<User> $users */
|
||||||
|
$users = $query->get();
|
||||||
|
|
||||||
return view('form.user-select-list', [
|
return view('form.user-select-list', [
|
||||||
'users' => $query->get(),
|
'users' => $users,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search users in the system, with the response formatted
|
||||||
|
* for use in a list of mentions.
|
||||||
|
*/
|
||||||
|
public function forMentions(Request $request)
|
||||||
|
{
|
||||||
|
$hasPermission = !user()->isGuest() && (
|
||||||
|
userCan(Permission::CommentCreateAll)
|
||||||
|
|| userCan(Permission::CommentUpdate)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$hasPermission) {
|
||||||
|
$this->showPermissionError();
|
||||||
|
}
|
||||||
|
|
||||||
|
$search = $request->get('search', '');
|
||||||
|
$query = User::query()
|
||||||
|
->orderBy('name', 'asc')
|
||||||
|
->take(20);
|
||||||
|
|
||||||
|
if (!empty($search)) {
|
||||||
|
$query->where('name', 'like', '%' . $search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Collection<User> $users */
|
||||||
|
$users = $query->get();
|
||||||
|
|
||||||
|
return view('form.user-mention-list', [
|
||||||
|
'users' => $users,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use BookStack\Activity\Models\Watch;
|
|||||||
use BookStack\Api\ApiToken;
|
use BookStack\Api\ApiToken;
|
||||||
use BookStack\App\Model;
|
use BookStack\App\Model;
|
||||||
use BookStack\App\SluggableInterface;
|
use BookStack\App\SluggableInterface;
|
||||||
use BookStack\Entities\Tools\SlugGenerator;
|
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Translation\LocaleDefinition;
|
use BookStack\Translation\LocaleDefinition;
|
||||||
use BookStack\Translation\LocaleManager;
|
use BookStack\Translation\LocaleManager;
|
||||||
@@ -358,14 +357,4 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
|||||||
{
|
{
|
||||||
return "({$this->id}) {$this->name}";
|
return "({$this->id}) {$this->name}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* {@inheritdoc}
|
|
||||||
*/
|
|
||||||
public function refreshSlug(): string
|
|
||||||
{
|
|
||||||
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
|
|
||||||
|
|
||||||
return $this->slug;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace BookStack\Users;
|
|||||||
use BookStack\Access\UserInviteException;
|
use BookStack\Access\UserInviteException;
|
||||||
use BookStack\Access\UserInviteService;
|
use BookStack\Access\UserInviteService;
|
||||||
use BookStack\Activity\ActivityType;
|
use BookStack\Activity\ActivityType;
|
||||||
|
use BookStack\Entities\Tools\SlugGenerator;
|
||||||
use BookStack\Exceptions\NotifyException;
|
use BookStack\Exceptions\NotifyException;
|
||||||
use BookStack\Exceptions\UserUpdateException;
|
use BookStack\Exceptions\UserUpdateException;
|
||||||
use BookStack\Facades\Activity;
|
use BookStack\Facades\Activity;
|
||||||
@@ -21,7 +22,8 @@ class UserRepo
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected UserAvatars $userAvatar,
|
protected UserAvatars $userAvatar,
|
||||||
protected UserInviteService $inviteService
|
protected UserInviteService $inviteService,
|
||||||
|
protected SlugGenerator $slugGenerator,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ class UserRepo
|
|||||||
$user->email_confirmed = $emailConfirmed;
|
$user->email_confirmed = $emailConfirmed;
|
||||||
$user->external_auth_id = $data['external_auth_id'] ?? '';
|
$user->external_auth_id = $data['external_auth_id'] ?? '';
|
||||||
|
|
||||||
$user->refreshSlug();
|
$this->slugGenerator->regenerateForUser($user);
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
if (!empty($data['language'])) {
|
if (!empty($data['language'])) {
|
||||||
@@ -109,7 +111,7 @@ class UserRepo
|
|||||||
{
|
{
|
||||||
if (!empty($data['name'])) {
|
if (!empty($data['name'])) {
|
||||||
$user->name = $data['name'];
|
$user->name = $data['name'];
|
||||||
$user->refreshSlug();
|
$this->slugGenerator->regenerateForUser($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($data['email']) && $manageUsersAllowed) {
|
if (!empty($data['email']) && $manageUsersAllowed) {
|
||||||
|
|||||||
150
app/Util/ConfiguredHtmlPurifier.php
Normal file
150
app/Util/ConfiguredHtmlPurifier.php
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Util;
|
||||||
|
|
||||||
|
use BookStack\App\AppVersion;
|
||||||
|
use HTMLPurifier;
|
||||||
|
use HTMLPurifier_Config;
|
||||||
|
use HTMLPurifier_DefinitionCache_Serializer;
|
||||||
|
use HTMLPurifier_HTML5Config;
|
||||||
|
use HTMLPurifier_HTMLDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a configured HTML Purifier instance.
|
||||||
|
* https://github.com/ezyang/htmlpurifier
|
||||||
|
* Also uses this to extend support to HTML5 elements:
|
||||||
|
* https://github.com/xemlock/htmlpurifier-html5
|
||||||
|
*/
|
||||||
|
class ConfiguredHtmlPurifier
|
||||||
|
{
|
||||||
|
protected HTMLPurifier $purifier;
|
||||||
|
protected static bool $cachedChecked = false;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
// This is done by the web-server at run-time, with the existing
|
||||||
|
// storage/framework/cache folder to ensure we're using a server-writable folder.
|
||||||
|
$cachePath = storage_path('framework/cache/purifier');
|
||||||
|
$this->createCacheFolderIfNeeded($cachePath);
|
||||||
|
|
||||||
|
$config = HTMLPurifier_HTML5Config::createDefault();
|
||||||
|
$this->setConfig($config, $cachePath);
|
||||||
|
$this->resetCacheIfNeeded($config);
|
||||||
|
|
||||||
|
$htmlDef = $config->getDefinition('HTML', true, true);
|
||||||
|
if ($htmlDef instanceof HTMLPurifier_HTMLDefinition) {
|
||||||
|
$this->configureDefinition($htmlDef);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->purifier = new HTMLPurifier($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createCacheFolderIfNeeded(string $cachePath): void
|
||||||
|
{
|
||||||
|
if (!file_exists($cachePath)) {
|
||||||
|
mkdir($cachePath, 0777, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resetCacheIfNeeded(HTMLPurifier_Config $config): void
|
||||||
|
{
|
||||||
|
if (self::$cachedChecked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cachedForVersion = cache('htmlpurifier::cache-version');
|
||||||
|
$appVersion = AppVersion::get();
|
||||||
|
if ($cachedForVersion !== $appVersion) {
|
||||||
|
foreach (['HTML', 'CSS', 'URI'] as $name) {
|
||||||
|
$cache = new HTMLPurifier_DefinitionCache_Serializer($name);
|
||||||
|
$cache->flush($config);
|
||||||
|
}
|
||||||
|
cache()->set('htmlpurifier::cache-version', $appVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$cachedChecked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setConfig(HTMLPurifier_Config $config, string $cachePath): void
|
||||||
|
{
|
||||||
|
$config->set('Cache.SerializerPath', $cachePath);
|
||||||
|
$config->set('Core.AllowHostnameUnderscore', true);
|
||||||
|
$config->set('CSS.AllowTricky', true);
|
||||||
|
$config->set('HTML.SafeIframe', true);
|
||||||
|
$config->set('Attr.EnableID', true);
|
||||||
|
$config->set('Attr.ID.HTML5', true);
|
||||||
|
$config->set('Output.FixInnerHTML', false);
|
||||||
|
$config->set('URI.SafeIframeRegexp', '%^(http://|https://|//)%');
|
||||||
|
$config->set('URI.AllowedSchemes', [
|
||||||
|
'http' => true,
|
||||||
|
'https' => true,
|
||||||
|
'mailto' => true,
|
||||||
|
'ftp' => true,
|
||||||
|
'nntp' => true,
|
||||||
|
'news' => true,
|
||||||
|
'tel' => true,
|
||||||
|
'file' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// $config->set('Cache.DefinitionImpl', null); // Disable cache during testing
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureDefinition(HTMLPurifier_HTMLDefinition $definition): void
|
||||||
|
{
|
||||||
|
// Allow the object element
|
||||||
|
$definition->addElement(
|
||||||
|
'object',
|
||||||
|
'Inline',
|
||||||
|
'Flow',
|
||||||
|
'Common',
|
||||||
|
[
|
||||||
|
'data' => 'URI',
|
||||||
|
'type' => 'Text',
|
||||||
|
'width' => 'Length',
|
||||||
|
'height' => 'Length',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Allow the embed element
|
||||||
|
$definition->addElement(
|
||||||
|
'embed',
|
||||||
|
'Inline',
|
||||||
|
'Empty',
|
||||||
|
'Common',
|
||||||
|
[
|
||||||
|
'src' => 'URI',
|
||||||
|
'type' => 'Text',
|
||||||
|
'width' => 'Length',
|
||||||
|
'height' => 'Length',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Allow checkbox inputs
|
||||||
|
$definition->addElement(
|
||||||
|
'input',
|
||||||
|
'Formctrl',
|
||||||
|
'Empty',
|
||||||
|
'Common',
|
||||||
|
[
|
||||||
|
'checked' => 'Bool#checked',
|
||||||
|
'disabled' => 'Bool#disabled',
|
||||||
|
'name' => 'Text',
|
||||||
|
'readonly' => 'Bool#readonly',
|
||||||
|
'type' => 'Enum#checkbox',
|
||||||
|
'value' => 'Text',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Allow the drawio-diagram attribute on div elements
|
||||||
|
$definition->addAttribute(
|
||||||
|
'div',
|
||||||
|
'drawio-diagram',
|
||||||
|
'Number',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purify(string $html): string
|
||||||
|
{
|
||||||
|
return $this->purifier->purify($html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ class CspService
|
|||||||
*/
|
*/
|
||||||
protected function getScriptSrc(): string
|
protected function getScriptSrc(): string
|
||||||
{
|
{
|
||||||
if (config('app.allow_content_scripts')) {
|
if ($this->scriptFilteringDisabled()) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ class CspService
|
|||||||
*/
|
*/
|
||||||
protected function getObjectSrc(): string
|
protected function getObjectSrc(): string
|
||||||
{
|
{
|
||||||
if (config('app.allow_content_scripts')) {
|
if ($this->scriptFilteringDisabled()) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +124,11 @@ class CspService
|
|||||||
return "base-uri 'self'";
|
return "base-uri 'self'";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function scriptFilteringDisabled(): bool
|
||||||
|
{
|
||||||
|
return !HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'))->filterOutJavaScript;
|
||||||
|
}
|
||||||
|
|
||||||
protected function getAllowedIframeHosts(): array
|
protected function getAllowedIframeHosts(): array
|
||||||
{
|
{
|
||||||
$hosts = config('app.iframe_hosts') ?? '';
|
$hosts = config('app.iframe_hosts') ?? '';
|
||||||
|
|||||||
@@ -8,10 +8,46 @@ use DOMNodeList;
|
|||||||
|
|
||||||
class HtmlContentFilter
|
class HtmlContentFilter
|
||||||
{
|
{
|
||||||
/**
|
public function __construct(
|
||||||
* Remove all the script elements from the given HTML document.
|
protected HtmlContentFilterConfig $config
|
||||||
*/
|
) {
|
||||||
public static function removeScriptsFromDocument(HtmlDocument $doc)
|
}
|
||||||
|
|
||||||
|
public function filterDocument(HtmlDocument $doc): string
|
||||||
|
{
|
||||||
|
if ($this->config->filterOutJavaScript) {
|
||||||
|
$this->filterOutScriptsFromDocument($doc);
|
||||||
|
}
|
||||||
|
if ($this->config->filterOutFormElements) {
|
||||||
|
$this->filterOutFormElementsFromDocument($doc);
|
||||||
|
}
|
||||||
|
if ($this->config->filterOutBadHtmlElements) {
|
||||||
|
$this->filterOutBadHtmlElementsFromDocument($doc);
|
||||||
|
}
|
||||||
|
if ($this->config->filterOutNonContentElements) {
|
||||||
|
$this->filterOutNonContentElementsFromDocument($doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filtered = $doc->getBodyInnerHtml();
|
||||||
|
if ($this->config->useAllowListFilter) {
|
||||||
|
$filtered = $this->applyAllowListFiltering($filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filterString(string $html): string
|
||||||
|
{
|
||||||
|
return $this->filterDocument(new HtmlDocument($html));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function applyAllowListFiltering(string $html): string
|
||||||
|
{
|
||||||
|
$purifier = new ConfiguredHtmlPurifier();
|
||||||
|
return $purifier->purify($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function filterOutScriptsFromDocument(HtmlDocument $doc): void
|
||||||
{
|
{
|
||||||
// Remove standard script tags
|
// Remove standard script tags
|
||||||
$scriptElems = $doc->queryXPath('//script');
|
$scriptElems = $doc->queryXPath('//script');
|
||||||
@@ -21,21 +57,21 @@ class HtmlContentFilter
|
|||||||
$badLinks = $doc->queryXPath('//*[' . static::xpathContains('@href', 'javascript:') . ']');
|
$badLinks = $doc->queryXPath('//*[' . static::xpathContains('@href', 'javascript:') . ']');
|
||||||
static::removeNodes($badLinks);
|
static::removeNodes($badLinks);
|
||||||
|
|
||||||
// Remove forms with calls to JavaScript URI
|
// Remove elements with form-like attributes with calls to JavaScript URI
|
||||||
$badForms = $doc->queryXPath('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
|
$badForms = $doc->queryXPath('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
|
||||||
static::removeNodes($badForms);
|
static::removeNodes($badForms);
|
||||||
|
|
||||||
// Remove meta tag to prevent external redirects
|
// Remove data or JavaScript iFrames & embeds
|
||||||
$metaTags = $doc->queryXPath('//meta[' . static::xpathContains('@content', 'url') . ']');
|
|
||||||
static::removeNodes($metaTags);
|
|
||||||
|
|
||||||
// Remove data or JavaScript iFrames
|
|
||||||
$badIframes = $doc->queryXPath('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
|
$badIframes = $doc->queryXPath('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
|
||||||
static::removeNodes($badIframes);
|
static::removeNodes($badIframes);
|
||||||
|
|
||||||
|
// Remove data or JavaScript objects
|
||||||
|
$badObjects = $doc->queryXPath('//*[' . static::xpathContains('@data', 'data:') . '] | //*[' . static::xpathContains('@data', 'javascript:') . ']');
|
||||||
|
static::removeNodes($badObjects);
|
||||||
|
|
||||||
// Remove attributes, within svg children, hiding JavaScript or data uris.
|
// Remove attributes, within svg children, hiding JavaScript or data uris.
|
||||||
// A bunch of svg element and attribute combinations expose xss possibilities.
|
// A bunch of svg element and attribute combinations expose xss possibilities.
|
||||||
// For example, SVG animate tag can exploit javascript in values.
|
// For example, SVG animate tag can exploit JavaScript in values.
|
||||||
$badValuesAttrs = $doc->queryXPath('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']');
|
$badValuesAttrs = $doc->queryXPath('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']');
|
||||||
static::removeAttributes($badValuesAttrs);
|
static::removeAttributes($badValuesAttrs);
|
||||||
|
|
||||||
@@ -49,23 +85,52 @@ class HtmlContentFilter
|
|||||||
static::removeAttributes($onAttributes);
|
static::removeAttributes($onAttributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function filterOutFormElementsFromDocument(HtmlDocument $doc): void
|
||||||
* Remove scripts from the given HTML string.
|
|
||||||
*/
|
|
||||||
public static function removeScriptsFromHtmlString(string $html): string
|
|
||||||
{
|
{
|
||||||
if (empty($html)) {
|
// Remove form elements
|
||||||
return $html;
|
$formElements = ['form', 'fieldset', 'button', 'textarea', 'select'];
|
||||||
|
foreach ($formElements as $formElement) {
|
||||||
|
$matchingFormElements = $doc->queryXPath('//' . $formElement);
|
||||||
|
static::removeNodes($matchingFormElements);
|
||||||
}
|
}
|
||||||
|
|
||||||
$doc = new HtmlDocument($html);
|
// Remove non-checkbox inputs
|
||||||
static::removeScriptsFromDocument($doc);
|
$inputsToRemove = $doc->queryXPath('//input');
|
||||||
|
/** @var DOMElement $input */
|
||||||
|
foreach ($inputsToRemove as $input) {
|
||||||
|
$type = strtolower($input->getAttribute('type'));
|
||||||
|
if ($type !== 'checkbox') {
|
||||||
|
$input->parentNode->removeChild($input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $doc->getBodyInnerHtml();
|
// Remove form attributes
|
||||||
|
$formAttrs = ['form', 'formaction', 'formmethod', 'formtarget'];
|
||||||
|
foreach ($formAttrs as $formAttr) {
|
||||||
|
$matchingFormAttrs = $doc->queryXPath('//@' . $formAttr);
|
||||||
|
static::removeAttributes($matchingFormAttrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function filterOutBadHtmlElementsFromDocument(HtmlDocument $doc): void
|
||||||
|
{
|
||||||
|
// Remove meta tag to prevent external redirects
|
||||||
|
$metaTags = $doc->queryXPath('//meta[' . static::xpathContains('@content', 'url') . ']');
|
||||||
|
static::removeNodes($metaTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function filterOutNonContentElementsFromDocument(HtmlDocument $doc): void
|
||||||
|
{
|
||||||
|
// Remove non-content elements
|
||||||
|
$formElements = ['link', 'style', 'meta', 'title', 'template'];
|
||||||
|
foreach ($formElements as $formElement) {
|
||||||
|
$matchingFormElements = $doc->queryXPath('//' . $formElement);
|
||||||
|
static::removeNodes($matchingFormElements);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a xpath contains statement with a translation automatically built within
|
* Create an x-path 'contains' statement with a translation automatically built within
|
||||||
* to affectively search in a cases-insensitive manner.
|
* to affectively search in a cases-insensitive manner.
|
||||||
*/
|
*/
|
||||||
protected static function xpathContains(string $property, string $value): string
|
protected static function xpathContains(string $property, string $value): string
|
||||||
@@ -99,4 +164,34 @@ class HtmlContentFilter
|
|||||||
$parentNode->removeAttribute($attrName);
|
$parentNode->removeAttribute($attrName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias using the old method name to avoid potential compatibility breaks during patch release.
|
||||||
|
* To remove in future feature release.
|
||||||
|
* @deprecated Use filterDocument instead.
|
||||||
|
*/
|
||||||
|
public static function removeScriptsFromDocument(HtmlDocument $doc): void
|
||||||
|
{
|
||||||
|
$config = new HtmlContentFilterConfig(
|
||||||
|
filterOutNonContentElements: false,
|
||||||
|
useAllowListFilter: false,
|
||||||
|
);
|
||||||
|
$filter = new self($config);
|
||||||
|
$filter->filterDocument($doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias using the old method name to avoid potential compatibility breaks during patch release.
|
||||||
|
* To remove in future feature release.
|
||||||
|
* @deprecated Use filterString instead.
|
||||||
|
*/
|
||||||
|
public static function removeScriptsFromHtmlString(string $html): string
|
||||||
|
{
|
||||||
|
$config = new HtmlContentFilterConfig(
|
||||||
|
filterOutNonContentElements: false,
|
||||||
|
useAllowListFilter: false,
|
||||||
|
);
|
||||||
|
$filter = new self($config);
|
||||||
|
return $filter->filterString($html);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
app/Util/HtmlContentFilterConfig.php
Normal file
31
app/Util/HtmlContentFilterConfig.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Util;
|
||||||
|
|
||||||
|
readonly class HtmlContentFilterConfig
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public bool $filterOutJavaScript = true,
|
||||||
|
public bool $filterOutBadHtmlElements = true,
|
||||||
|
public bool $filterOutFormElements = true,
|
||||||
|
public bool $filterOutNonContentElements = true,
|
||||||
|
public bool $useAllowListFilter = true,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an instance from a config string, where the string
|
||||||
|
* is a combination of characters to enable filters.
|
||||||
|
*/
|
||||||
|
public static function fromConfigString(string $config): self
|
||||||
|
{
|
||||||
|
$config = strtolower($config);
|
||||||
|
return new self(
|
||||||
|
filterOutJavaScript: str_contains($config, 'j'),
|
||||||
|
filterOutBadHtmlElements: str_contains($config, 'h'),
|
||||||
|
filterOutFormElements: str_contains($config, 'f'),
|
||||||
|
filterOutNonContentElements: str_contains($config, 'h'),
|
||||||
|
useAllowListFilter: str_contains($config, 'a'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ class HtmlDescriptionFilter
|
|||||||
*/
|
*/
|
||||||
protected static array $allowedAttrsByElements = [
|
protected static array $allowedAttrsByElements = [
|
||||||
'p' => [],
|
'p' => [],
|
||||||
'a' => ['href', 'title', 'target'],
|
'a' => ['href', 'title', 'target', 'data-mention-user-id'],
|
||||||
'ol' => [],
|
'ol' => [],
|
||||||
'ul' => [],
|
'ul' => [],
|
||||||
'li' => [],
|
'li' => [],
|
||||||
|
|||||||
@@ -103,7 +103,13 @@ class HtmlDocument
|
|||||||
*/
|
*/
|
||||||
public function getBody(): DOMNode
|
public function getBody(): DOMNode
|
||||||
{
|
{
|
||||||
return $this->document->getElementsByTagName('body')[0];
|
$bodies = $this->document->getElementsByTagName('body');
|
||||||
|
|
||||||
|
if ($bodies->length === 0) {
|
||||||
|
return new DOMElement('body', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bodies[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace BookStack\Util;
|
namespace BookStack\Util;
|
||||||
|
|
||||||
|
use BookStack\Facades\Theme;
|
||||||
|
|
||||||
class SvgIcon
|
class SvgIcon
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -23,12 +25,9 @@ class SvgIcon
|
|||||||
$attrString .= $attrName . '="' . $attr . '" ';
|
$attrString .= $attrName . '="' . $attr . '" ';
|
||||||
}
|
}
|
||||||
|
|
||||||
$iconPath = resource_path('icons/' . $this->name . '.svg');
|
$defaultIconPath = resource_path('icons/' . $this->name . '.svg');
|
||||||
$themeIconPath = theme_path('icons/' . $this->name . '.svg');
|
$iconPath = Theme::findFirstFile("icons/{$this->name}.svg") ?? $defaultIconPath;
|
||||||
|
if (!file_exists($iconPath)) {
|
||||||
if ($themeIconPath && file_exists($themeIconPath)) {
|
|
||||||
$iconPath = $themeIconPath;
|
|
||||||
} elseif (!file_exists($iconPath)) {
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -19,6 +19,7 @@
|
|||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"bacon/bacon-qr-code": "^3.0",
|
"bacon/bacon-qr-code": "^3.0",
|
||||||
"dompdf/dompdf": "^3.1",
|
"dompdf/dompdf": "^3.1",
|
||||||
|
"ezyang/htmlpurifier": "^4.19",
|
||||||
"guzzlehttp/guzzle": "^7.4",
|
"guzzlehttp/guzzle": "^7.4",
|
||||||
"intervention/image": "^3.5",
|
"intervention/image": "^3.5",
|
||||||
"knplabs/knp-snappy": "^1.5",
|
"knplabs/knp-snappy": "^1.5",
|
||||||
@@ -29,16 +30,17 @@
|
|||||||
"league/flysystem-aws-s3-v3": "^3.0",
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
"league/html-to-markdown": "^5.0.0",
|
"league/html-to-markdown": "^5.0.0",
|
||||||
"league/oauth2-client": "^2.6",
|
"league/oauth2-client": "^2.6",
|
||||||
"onelogin/php-saml": "^4.0",
|
"onelogin/php-saml": "^4.3.1",
|
||||||
"phpseclib/phpseclib": "^3.0",
|
"phpseclib/phpseclib": "^3.0",
|
||||||
"pragmarx/google2fa": "^8.0",
|
"pragmarx/google2fa": "^9.0",
|
||||||
"predis/predis": "^3.2",
|
"predis/predis": "^3.2",
|
||||||
"socialiteproviders/discord": "^4.1",
|
"socialiteproviders/discord": "^4.1",
|
||||||
"socialiteproviders/gitlab": "^4.1",
|
"socialiteproviders/gitlab": "^4.1",
|
||||||
"socialiteproviders/microsoft-azure": "^5.1",
|
"socialiteproviders/microsoft-azure": "^5.1",
|
||||||
"socialiteproviders/okta": "^4.2",
|
"socialiteproviders/okta": "^4.2",
|
||||||
"socialiteproviders/twitch": "^5.3",
|
"socialiteproviders/twitch": "^5.3",
|
||||||
"ssddanbrown/htmldiff": "^2.0.0"
|
"ssddanbrown/htmldiff": "^2.0.0",
|
||||||
|
"xemlock/htmlpurifier-html5": "^0.1.12"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.21",
|
"fakerphp/faker": "^1.21",
|
||||||
@@ -47,7 +49,7 @@
|
|||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
"larastan/larastan": "^v3.0",
|
"larastan/larastan": "^v3.0",
|
||||||
"phpunit/phpunit": "^11.5",
|
"phpunit/phpunit": "^11.5",
|
||||||
"squizlabs/php_codesniffer": "^3.7",
|
"squizlabs/php_codesniffer": "^4.0.1",
|
||||||
"ssddanbrown/asserthtml": "^3.1"
|
"ssddanbrown/asserthtml": "^3.1"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|||||||
1540
composer.lock
generated
1540
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
project_id: "377219"
|
||||||
project_identifier: bookstack
|
project_identifier: bookstack
|
||||||
base_path: .
|
base_path: .
|
||||||
preserve_hierarchy: false
|
preserve_hierarchy: false
|
||||||
|
|||||||
29
database/factories/Entities/Models/SlugHistoryFactory.php
Normal file
29
database/factories/Entities/Models/SlugHistoryFactory.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories\Entities\Models;
|
||||||
|
|
||||||
|
use BookStack\Entities\Models\Book;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Entities\Models\SlugHistory>
|
||||||
|
*/
|
||||||
|
class SlugHistoryFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = \BookStack\Entities\Models\SlugHistory::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sluggable_id' => Book::factory(),
|
||||||
|
'sluggable_type' => 'book',
|
||||||
|
'slug' => $this->faker->slug(),
|
||||||
|
'parent_slug' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Create the table for storing slug history
|
||||||
|
Schema::create('slug_history', function (Blueprint $table) {
|
||||||
|
$table->increments('id');
|
||||||
|
$table->string('sluggable_type', 10)->index();
|
||||||
|
$table->unsignedBigInteger('sluggable_id')->index();
|
||||||
|
$table->string('slug')->index();
|
||||||
|
$table->string('parent_slug')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migrate in slugs from page revisions
|
||||||
|
$revisionSlugQuery = DB::table('page_revisions')
|
||||||
|
->select([
|
||||||
|
DB::raw('\'page\' as sluggable_type'),
|
||||||
|
'page_id as sluggable_id',
|
||||||
|
'slug',
|
||||||
|
'book_slug as parent_slug',
|
||||||
|
DB::raw('min(created_at) as created_at'),
|
||||||
|
DB::raw('min(updated_at) as updated_at'),
|
||||||
|
])
|
||||||
|
->where('type', '=', 'version')
|
||||||
|
->groupBy(['sluggable_id', 'slug', 'parent_slug']);
|
||||||
|
|
||||||
|
DB::table('slug_history')->insertUsing(
|
||||||
|
['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
|
||||||
|
$revisionSlugQuery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('slug_history');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('mention_history', function (Blueprint $table) {
|
||||||
|
$table->increments('id');
|
||||||
|
$table->string('mentionable_type', 50)->index();
|
||||||
|
$table->unsignedBigInteger('mentionable_id')->index();
|
||||||
|
$table->unsignedInteger('from_user_id');
|
||||||
|
$table->unsignedInteger('to_user_id');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('mention_history');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('views', function (Blueprint $table) {
|
||||||
|
$table->index('viewable_type', 'views_viewable_type_index');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('views', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('views_viewable_type_index');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
const esbuild = require('esbuild');
|
import * as esbuild from 'esbuild';
|
||||||
const path = require('path');
|
import * as path from 'node:path';
|
||||||
const fs = require('fs');
|
import * as fs from 'node:fs';
|
||||||
|
import * as process from "node:process";
|
||||||
|
|
||||||
// Check if we're building for production
|
// Check if we're building for production
|
||||||
// (Set via passing `production` as first argument)
|
// (Set via passing `production` as first argument)
|
||||||
const isProd = process.argv[2] === 'production';
|
const mode = process.argv[2];
|
||||||
|
const isProd = mode === 'production';
|
||||||
|
const __dirname = import.meta.dirname;
|
||||||
|
|
||||||
// Gather our input files
|
// Gather our input files
|
||||||
const entryPoints = {
|
const entryPoints = {
|
||||||
@@ -17,11 +20,16 @@ const entryPoints = {
|
|||||||
wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'),
|
wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Watch styles so we can reload on change
|
||||||
|
if (mode === 'watch') {
|
||||||
|
entryPoints['styles-dummy'] = path.join(__dirname, '../../public/dist/styles.css');
|
||||||
|
}
|
||||||
|
|
||||||
// Locate our output directory
|
// Locate our output directory
|
||||||
const outdir = path.join(__dirname, '../../public/dist');
|
const outdir = path.join(__dirname, '../../public/dist');
|
||||||
|
|
||||||
// Build via esbuild
|
// Define the options for esbuild
|
||||||
esbuild.build({
|
const options = {
|
||||||
bundle: true,
|
bundle: true,
|
||||||
metafile: true,
|
metafile: true,
|
||||||
entryPoints,
|
entryPoints,
|
||||||
@@ -33,6 +41,7 @@ esbuild.build({
|
|||||||
minify: isProd,
|
minify: isProd,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
loader: {
|
loader: {
|
||||||
|
'.html': 'copy',
|
||||||
'.svg': 'text',
|
'.svg': 'text',
|
||||||
},
|
},
|
||||||
absWorkingDir: path.join(__dirname, '../..'),
|
absWorkingDir: path.join(__dirname, '../..'),
|
||||||
@@ -45,6 +54,34 @@ esbuild.build({
|
|||||||
js: '// See the "/licenses" URI for full package license details',
|
js: '// See the "/licenses" URI for full package license details',
|
||||||
css: '/* See the "/licenses" URI for full package license details */',
|
css: '/* See the "/licenses" URI for full package license details */',
|
||||||
},
|
},
|
||||||
}).then(result => {
|
};
|
||||||
|
|
||||||
|
if (mode === 'watch') {
|
||||||
|
options.inject = [
|
||||||
|
path.join(__dirname, './livereload.js'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await esbuild.context(options);
|
||||||
|
|
||||||
|
if (mode === 'watch') {
|
||||||
|
// Watch for changes and rebuild on change
|
||||||
|
ctx.watch({});
|
||||||
|
let {hosts, port} = await ctx.serve({
|
||||||
|
servedir: path.join(__dirname, '../../public'),
|
||||||
|
cors: {
|
||||||
|
origin: '*',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Build with meta output for analysis
|
||||||
|
const result = await ctx.rebuild();
|
||||||
|
const outputs = result.metafile.outputs;
|
||||||
|
const files = Object.keys(outputs);
|
||||||
|
for (const file of files) {
|
||||||
|
const output = outputs[file];
|
||||||
|
console.log(`Written: ${file} @ ${Math.round(output.bytes / 1000)}kB`);
|
||||||
|
}
|
||||||
fs.writeFileSync('esbuild-meta.json', JSON.stringify(result.metafile));
|
fs.writeFileSync('esbuild-meta.json', JSON.stringify(result.metafile));
|
||||||
}).catch(() => process.exit(1));
|
process.exit(0);
|
||||||
|
}
|
||||||
35
dev/build/livereload.js
Normal file
35
dev/build/livereload.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
if (!window.__dev_reload_listening) {
|
||||||
|
listen();
|
||||||
|
window.__dev_reload_listening = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function listen() {
|
||||||
|
console.log('Listening for livereload events...');
|
||||||
|
new EventSource("http://127.0.0.1:8000/esbuild").addEventListener('change', e => {
|
||||||
|
const { added, removed, updated } = JSON.parse(e.data);
|
||||||
|
|
||||||
|
if (!added.length && !removed.length && updated.length > 0) {
|
||||||
|
const updatedPath = updated.filter(path => path.endsWith('.css'))[0]
|
||||||
|
if (!updatedPath) return;
|
||||||
|
|
||||||
|
const links = [...document.querySelectorAll("link[rel='stylesheet']")];
|
||||||
|
for (const link of links) {
|
||||||
|
const url = new URL(link.href);
|
||||||
|
const name = updatedPath.replace('-dummy', '');
|
||||||
|
|
||||||
|
if (url.pathname.endsWith(name)) {
|
||||||
|
const next = link.cloneNode();
|
||||||
|
next.href = name + '?version=' + Math.random().toString(36).slice(2);
|
||||||
|
next.onload = function() {
|
||||||
|
link.remove();
|
||||||
|
};
|
||||||
|
link.after(next);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
location.reload()
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ RUN apt-get update && \
|
|||||||
wait-for-it && \
|
wait-for-it && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Mark /app as safe for Git >= 2.35.2
|
||||||
|
RUN git config --system --add safe.directory /app
|
||||||
|
|
||||||
# Install PHP extensions
|
# Install PHP extensions
|
||||||
RUN docker-php-ext-configure ldap --with-libdir="lib/$(gcc -dumpmachine)" && \
|
RUN docker-php-ext-configure ldap --with-libdir="lib/$(gcc -dumpmachine)" && \
|
||||||
docker-php-ext-configure gd --with-freetype --with-jpeg && \
|
docker-php-ext-configure gd --with-freetype --with-jpeg && \
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ ARG BRANCH=development
|
|||||||
RUN mkdir -p /var/www && \
|
RUN mkdir -p /var/www && \
|
||||||
git clone https://github.com/bookstackapp/bookstack.git --branch "$BRANCH" --single-branch /var/www/bookstack && \
|
git clone https://github.com/bookstackapp/bookstack.git --branch "$BRANCH" --single-branch /var/www/bookstack && \
|
||||||
cd /var/www/bookstack && \
|
cd /var/www/bookstack && \
|
||||||
wget https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer -O - -q | php -- --quiet --filename=composer && \
|
wget https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer -O - -q | php -- --quiet --filename=composer && \
|
||||||
php composer install
|
php composer install
|
||||||
|
|
||||||
# Set the BookStack dir as the default working dir
|
# Set the BookStack dir as the default working dir
|
||||||
|
|||||||
32
dev/docker/db-testing/readme.md
Normal file
32
dev/docker/db-testing/readme.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Database Testing Suite
|
||||||
|
|
||||||
|
This docker setup is designed to run BookStack's test suite against each major database version we support
|
||||||
|
across MySQL and MariaDB to ensure compatibility and highlight any potential issues before a release.
|
||||||
|
This is a fairly slow and heavy process, so is designed to just be run manually before a release which
|
||||||
|
makes changes to the database schema, or a release which makes significant changes to database queries.
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
Everything is ran via the `run.sh` script. This will:
|
||||||
|
|
||||||
|
- Optionally, accept a branch of BookStack to use for testing.
|
||||||
|
- Build the docker image from the `Dockerfile`.
|
||||||
|
- This will include a built-in copy of the chosen BookStack branch.
|
||||||
|
- Cycle through each major supported database version:
|
||||||
|
- Migrate and seed the database.
|
||||||
|
- Run the full PHP test suite.
|
||||||
|
|
||||||
|
If there's a failure for a database version, the script will prompt if you'd like to continue or stop testing.
|
||||||
|
|
||||||
|
This script should be ran from this `db-testing` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enter this directory
|
||||||
|
cd dev/docker/db-testing
|
||||||
|
|
||||||
|
# Runs for the 'development' branch by default
|
||||||
|
./run.sh
|
||||||
|
|
||||||
|
# Run for a specific branch
|
||||||
|
./run.sh v25-11
|
||||||
|
```
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
BRANCH=${1:-development}
|
BRANCH=${1:-development}
|
||||||
|
|
||||||
# Build the container with a known name
|
# Build the container with a known name
|
||||||
docker build --build-arg BRANCH="$BRANCH" -t bookstack:db-testing .
|
docker build --no-cache --build-arg BRANCH="$BRANCH" -t bookstack:db-testing .
|
||||||
if [ $? -eq 1 ]; then
|
if [ $? -eq 1 ]; then
|
||||||
echo "Failed to build app container for testing"
|
echo "Failed to build app container for testing"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -11,11 +11,9 @@ fi
|
|||||||
|
|
||||||
# List of database containers to test against
|
# List of database containers to test against
|
||||||
containers=(
|
containers=(
|
||||||
"mysql:5.7"
|
|
||||||
"mysql:8.0"
|
"mysql:8.0"
|
||||||
"mysql:8.4"
|
"mysql:8.4"
|
||||||
"mysql:9.5"
|
"mysql:9.5"
|
||||||
"mariadb:10.2"
|
|
||||||
"mariadb:10.6"
|
"mariadb:10.6"
|
||||||
"mariadb:10.11"
|
"mariadb:10.11"
|
||||||
"mariadb:11.4"
|
"mariadb:11.4"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
All development on BookStack is currently done on the `development` branch.
|
All development on BookStack is currently done on the `development` branch.
|
||||||
When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
|
When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
|
||||||
|
|
||||||
* [Node.js](https://nodejs.org/en/) v20.0+
|
* [Node.js](https://nodejs.org/en/) v22.0+
|
||||||
|
|
||||||
## Building CSS & JavaScript Assets
|
## Building CSS & JavaScript Assets
|
||||||
|
|
||||||
|
|||||||
@@ -161,3 +161,7 @@ window.$components.firstOnElement(element, name);
|
|||||||
There are a range of available events that are emitted as part of a public & supported API for accessing or extending JavaScript libraries & components used in the system.
|
There are a range of available events that are emitted as part of a public & supported API for accessing or extending JavaScript libraries & components used in the system.
|
||||||
|
|
||||||
Details on these events can be found in the [JavaScript Public Events file](javascript-public-events.md).
|
Details on these events can be found in the [JavaScript Public Events file](javascript-public-events.md).
|
||||||
|
|
||||||
|
## WYSIWYG Editor API
|
||||||
|
|
||||||
|
Details on the API for our custom-built WYSIWYG editor can be found in the [WYSIWYG JavaScript API file](./wysiwyg-js-api.md).
|
||||||
@@ -60,7 +60,7 @@ This event is called when the markdown editor loads, post configuration but befo
|
|||||||
|
|
||||||
#### Event Data
|
#### Event Data
|
||||||
|
|
||||||
- `markdownIt` - A references to the [MarkdownIt](https://markdown-it.github.io/markdown-it/#MarkdownIt) instance used to render markdown to HTML (Just for the preview).
|
- `markdownIt` - A reference to the [MarkdownIt](https://markdown-it.github.io/markdown-it/#MarkdownIt) instance used to render markdown to HTML (Just for the preview).
|
||||||
- `displayEl` - The IFrame Element that wraps the HTML preview display.
|
- `displayEl` - The IFrame Element that wraps the HTML preview display.
|
||||||
- `cmEditorView` - The CodeMirror [EditorView](https://codemirror.net/docs/ref/#view.EditorView) instance used for the markdown input editor.
|
- `cmEditorView` - The CodeMirror [EditorView](https://codemirror.net/docs/ref/#view.EditorView) instance used for the markdown input editor.
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ window.addEventListener('editor-markdown::setup', event => {
|
|||||||
This event is called as the embedded diagrams.net drawing editor loads, to allow configuration of the diagrams.net interface.
|
This event is called as the embedded diagrams.net drawing editor loads, to allow configuration of the diagrams.net interface.
|
||||||
See [this diagrams.net page](https://www.diagrams.net/doc/faq/configure-diagram-editor) for details on the available options for the configure event.
|
See [this diagrams.net page](https://www.diagrams.net/doc/faq/configure-diagram-editor) for details on the available options for the configure event.
|
||||||
|
|
||||||
If using a custom diagrams.net instance, via the `DRAWIO` option, you will need to ensure your DRAWIO option URL has the `configure=1` query parameter.
|
If using a custom diagrams.net instance, via the `DRAWIO` option, you will need to ensure your DRAWIO option URL has the `configure=1` query parameter.
|
||||||
|
|
||||||
#### Event Data
|
#### Event Data
|
||||||
|
|
||||||
@@ -134,6 +134,47 @@ window.addEventListener('editor-tinymce::setup', event => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `editor-wysiwyg::post-init`
|
||||||
|
|
||||||
|
This is called after the (new custom-built Lexical-based) WYSIWYG editor has been initialised.
|
||||||
|
|
||||||
|
#### Event Data
|
||||||
|
|
||||||
|
- `usage` - A string label to identify the usage type of the WYSIWYG editor in BookStack.
|
||||||
|
- `api` - An instance to the WYSIWYG editor API, as documented in the [WYSIWYG JavaScript API file](./wysiwyg-js-api.md).
|
||||||
|
|
||||||
|
##### Example
|
||||||
|
|
||||||
|
The below example shows how you'd use this API to create a button, with that button added to the main toolbar of the page editor, which inserts bold "Hello!" text on press:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show Example</summary>
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
window.addEventListener('editor-wysiwyg::post-init', event => {
|
||||||
|
const {usage, api} = event.detail;
|
||||||
|
// Check that it's the page editor which is being loaded
|
||||||
|
if (usage !== 'page-editor') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a custom button which inserts bold hello text on press
|
||||||
|
const button = api.ui.createButton({
|
||||||
|
label: 'Greet',
|
||||||
|
action: () => {
|
||||||
|
api.content.insertHtml(`<strong>Hello!</strong>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the button to the start of the first section within the main toolbar
|
||||||
|
const toolbar = api.ui.getMainToolbar();
|
||||||
|
if (toolbar) {
|
||||||
|
toolbar.getSections()[0]?.addButton(button, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
### `library-cm6::configure-theme`
|
### `library-cm6::configure-theme`
|
||||||
|
|
||||||
This event is called whenever a CodeMirror instance is loaded, as a method to configure the theme used by CodeMirror. This applies to all CodeMirror instances including in-page code blocks, editors using in BookStack settings, and the Page markdown editor.
|
This event is called whenever a CodeMirror instance is loaded, as a method to configure the theme used by CodeMirror. This applies to all CodeMirror instances including in-page code blocks, editors using in BookStack settings, and the Page markdown editor.
|
||||||
@@ -142,7 +183,7 @@ This event is called whenever a CodeMirror instance is loaded, as a method to co
|
|||||||
|
|
||||||
- `darkModeActive` - A boolean to indicate if the current view/page is being loaded with dark mode active.
|
- `darkModeActive` - A boolean to indicate if the current view/page is being loaded with dark mode active.
|
||||||
- `registerViewTheme(builder)` - A method that can be called to register a new view (CodeMirror UI) theme.
|
- `registerViewTheme(builder)` - A method that can be called to register a new view (CodeMirror UI) theme.
|
||||||
- `builder` - A function that will return an object that will be passed into the CodeMirror [EditorView.theme()](https://codemirror.net/docs/ref/#view.EditorView^theme) function as a StyleSpec.
|
- `builder` - A function that will return an object that will be passed into the CodeMirror [EditorView.theme()](https://codemirror.net/docs/ref/#view.EditorView^theme) function as a StyleSpec.
|
||||||
- `registerHighlightStyle(builder)` - A method that can be called to register a new HighlightStyle (code highlighting) theme.
|
- `registerHighlightStyle(builder)` - A method that can be called to register a new HighlightStyle (code highlighting) theme.
|
||||||
- `builder` - A function, that receives a reference to [Tag.tags](https://lezer.codemirror.net/docs/ref/#highlight.tags) and returns an array of [TagStyle](https://codemirror.net/docs/ref/#language.TagStyle) objects.
|
- `builder` - A function, that receives a reference to [Tag.tags](https://lezer.codemirror.net/docs/ref/#highlight.tags) and returns an array of [TagStyle](https://codemirror.net/docs/ref/#language.TagStyle) objects.
|
||||||
|
|
||||||
@@ -301,7 +342,7 @@ This event is called just after any CodeMirror instances are initialised so that
|
|||||||
|
|
||||||
##### Example
|
##### Example
|
||||||
|
|
||||||
The below shows how you'd prepend some default text to all content (page) code blocks.
|
The below example shows how you'd prepend some default text to all content (page) code blocks.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show Example</summary>
|
<summary>Show Example</summary>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user