mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-10-12 02:01:37 +00:00
Merge branch 'development' into enableservices-api-search-book-chapter-endpoints
This commit is contained in:
+21
-7
@@ -8,18 +8,32 @@ since we aim to provide a fairly stable upgrade path for new versions.
|
||||
|
||||
## Security Notifications
|
||||
|
||||
If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates).
|
||||
If you'd like to be notified of new potential security concerns, you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates).
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you've found an issue that likely has no impact to existing users (For example, an issue only in the development branch)
|
||||
If you've found an issue that likely has no impact on existing users (For example, an issue only in the development branch)
|
||||
feel free to raise it via a standard Codeberg bug report issue.
|
||||
|
||||
If the issue could have a security impact to BookStack instances,
|
||||
If the issue could have a security impact on BookStack instances,
|
||||
please directly contact the lead maintainer via email Dan Brown using the [details found here](https://www.bookstackapp.com/links/contact/).
|
||||
|
||||
Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability
|
||||
can often take a little time due to the amount of preparation required, to ensure the vulnerability has
|
||||
been covered, and to create the content required to adequately notify the user-base.
|
||||
When contacting us, please note any names (and optionally any profile/company/website links) that you'd like to be used in
|
||||
any attribution within our release notes and content.
|
||||
|
||||
Thank you for keeping BookStack instances safe!
|
||||
Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability
|
||||
can often take a little time due to the amount of preparation required to ensure the vulnerability has
|
||||
been covered and to create the content required to adequately notify the user-base.
|
||||
|
||||
Thank you for keeping BookStack instances safe!
|
||||
|
||||
### CVE Creation
|
||||
|
||||
We're generally happy for (and prefer) researchers to raise CVEs for issues they've discovered.
|
||||
We ask that you first confirm with us to ensure the vulnerability is valid and that it hasn't yet been discovered
|
||||
and reported by someone else.
|
||||
|
||||
We can raise CVEs ourselves, but we would only go to the effort for security issues with a significant level of risk to users.
|
||||
We typically won't pursue CVEs if there's a lesser level of risk.
|
||||
|
||||
_Note: Our reporting may change as the Cyber Resilience Act comes into effect._
|
||||
@@ -552,3 +552,5 @@ Tim (timakai) :: Dutch; German Informal; French; Romanian; Catalan; Czech; Danis
|
||||
dadda123 :: Swedish
|
||||
Julien Muggli (JulienMuggli) :: French
|
||||
nomoreshow :: Turkish
|
||||
Qasem Talaee (qasem_talaee) :: Persian
|
||||
ddeicide :: Bulgarian
|
||||
|
||||
@@ -48,7 +48,7 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
/**
|
||||
* Retrieve a user by the given credentials.
|
||||
*/
|
||||
public function retrieveByCredentials(array $credentials): ?Authenticatable
|
||||
public function retrieveByCredentials(array $credentials): ?User
|
||||
{
|
||||
return $this->userRepo->getByExternalAuthId($credentials['external_auth_id']);
|
||||
}
|
||||
|
||||
@@ -50,8 +50,12 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
/**
|
||||
* Create a new authentication guard.
|
||||
*/
|
||||
public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService)
|
||||
{
|
||||
public function __construct(
|
||||
string $name,
|
||||
UserProvider $provider,
|
||||
Session $session,
|
||||
RegistrationService $registrationService
|
||||
) {
|
||||
$this->name = $name;
|
||||
$this->session = $session;
|
||||
$this->provider = $provider;
|
||||
|
||||
@@ -83,6 +83,10 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
}
|
||||
}
|
||||
|
||||
if (!($user instanceof User)) {
|
||||
throw new LoginAttemptException('Could not find or create a user for LDAP login.');
|
||||
}
|
||||
|
||||
// Sync LDAP groups if required
|
||||
if ($this->ldapService->shouldSyncGroups()) {
|
||||
$this->ldapService->syncGroups($user, $username);
|
||||
|
||||
+2
-2
@@ -48,7 +48,7 @@ class Ldap
|
||||
}
|
||||
|
||||
/**
|
||||
* Search LDAP tree using the provided filter.
|
||||
* Search the LDAP tree using the provided filter.
|
||||
*
|
||||
* @param resource|\LDAP\Connection $ldapConnection
|
||||
*
|
||||
@@ -95,7 +95,7 @@ class Ldap
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind to LDAP directory.
|
||||
* Bind to the LDAP directory.
|
||||
*
|
||||
* @param resource|\LDAP\Connection $ldapConnection
|
||||
*/
|
||||
|
||||
@@ -346,13 +346,15 @@ class LdapService
|
||||
}
|
||||
|
||||
$userGroups = $this->extractGroupsFromSearchResponseEntry($user);
|
||||
$allGroups = $this->getGroupsRecursive($userGroups, []);
|
||||
$filteredGroups = $this->filterGroups($userGroups);
|
||||
$allGroups = $this->getGroupsRecursive($filteredGroups, []);
|
||||
$formattedGroups = $this->extractGroupNamesFromLdapGroupDns($allGroups);
|
||||
|
||||
if ($this->config['dump_user_groups']) {
|
||||
throw new JsonDebugException([
|
||||
'details_from_ldap' => $user,
|
||||
'parsed_direct_user_groups' => $userGroups,
|
||||
'parsed_filtered_user_groups' => $filteredGroups,
|
||||
'parsed_recursive_user_groups' => $allGroups,
|
||||
'parsed_resulting_group_names' => $formattedGroups,
|
||||
]);
|
||||
@@ -367,7 +369,7 @@ class LdapService
|
||||
|
||||
foreach ($groupDNs as $groupDN) {
|
||||
$exploded = $this->ldap->explodeDn($groupDN, 1);
|
||||
if ($exploded !== false && count($exploded) > 0) {
|
||||
if ($exploded !== false && $exploded['count'] > 0) {
|
||||
$names[] = $exploded[0];
|
||||
}
|
||||
}
|
||||
@@ -390,7 +392,8 @@ class LdapService
|
||||
}
|
||||
|
||||
$parentGroups = $this->getParentsOfGroup($groupDN);
|
||||
$groupsToAdd = array_merge($groupsToAdd, $parentGroups);
|
||||
$parentGroupsFiltered = $this->filterGroups($parentGroups);
|
||||
$groupsToAdd = array_merge($groupsToAdd, $parentGroupsFiltered);
|
||||
$checked[] = $groupDN;
|
||||
}
|
||||
|
||||
@@ -403,6 +406,20 @@ class LdapService
|
||||
return $this->getGroupsRecursive($uniqueDNs, $checked);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $groupDNs
|
||||
* @return string[]
|
||||
*/
|
||||
protected function filterGroups(array $groupDNs): array
|
||||
{
|
||||
$filtered = array_filter($groupDNs, function (string $groupDN) {
|
||||
$exploded = $this->ldap->explodeDn($groupDN, 1);
|
||||
return $exploded !== false && $exploded['count'] > 0;
|
||||
});
|
||||
|
||||
return array_values($filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws LdapException
|
||||
*/
|
||||
|
||||
@@ -131,8 +131,6 @@ class OidcJwtWithClaims implements ProvidesClaims
|
||||
}
|
||||
}, $this->keys);
|
||||
|
||||
$parsedKeys = array_filter($parsedKeys);
|
||||
|
||||
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
|
||||
/** @var OidcJwtSigningKey $parsedKey */
|
||||
foreach ($parsedKeys as $parsedKey) {
|
||||
|
||||
@@ -80,6 +80,7 @@ class OidcService
|
||||
$provider->setPkceCode($pkceCode);
|
||||
|
||||
// Try to exchange authorization code for access token
|
||||
/** @var OidcAccessToken $accessToken */
|
||||
$accessToken = $provider->getAccessToken('authorization_code', [
|
||||
'code' => $authorizationCode,
|
||||
]);
|
||||
|
||||
@@ -55,8 +55,10 @@ class SocialAuthService
|
||||
*/
|
||||
public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser
|
||||
{
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
|
||||
// Check social account has not already been used
|
||||
if (SocialAccount::query()->where('driver_id', '=', $socialUser->getId())->exists()) {
|
||||
if ($this->getSocialAccountById($socialDriver, $socialUser->getId())) {
|
||||
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount' => $socialDriver]), '/login');
|
||||
}
|
||||
|
||||
@@ -69,6 +71,19 @@ class SocialAuthService
|
||||
return $socialUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing social account by its ID.
|
||||
*/
|
||||
protected function getSocialAccountById(string $socialDriver, string $socialId): SocialAccount|null
|
||||
{
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
|
||||
return SocialAccount::query()
|
||||
->where('driver', '=', $socialDriver)
|
||||
->where('driver_id', '=', $socialId)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the social user details via the social driver.
|
||||
*
|
||||
@@ -93,7 +108,7 @@ class SocialAuthService
|
||||
$socialId = $socialUser->getId();
|
||||
|
||||
// Get any attached social accounts or users
|
||||
$socialAccount = SocialAccount::query()->where('driver_id', '=', $socialId)->first();
|
||||
$socialAccount = $this->getSocialAccountById($socialDriver, $socialId);
|
||||
$isLoggedIn = auth()->check();
|
||||
$currentUser = user();
|
||||
$titleCaseDriver = Str::title($socialDriver);
|
||||
@@ -153,7 +168,7 @@ class SocialAuthService
|
||||
public function newSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
|
||||
{
|
||||
return new SocialAccount([
|
||||
'driver' => $socialDriver,
|
||||
'driver' => trim(strtolower($socialDriver)),
|
||||
'driver_id' => $socialUser->getId(),
|
||||
'avatar' => $socialUser->getAvatar(),
|
||||
]);
|
||||
@@ -164,6 +179,7 @@ class SocialAuthService
|
||||
*/
|
||||
public function detachSocialAccount(string $socialDriver): void
|
||||
{
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
@@ -27,6 +28,7 @@ class ActivityQueries
|
||||
*/
|
||||
public function latest(int $count = 20, int $page = 0): array
|
||||
{
|
||||
/** @var Collection<int, Activity> $activityList */
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
@@ -83,6 +85,7 @@ class ActivityQueries
|
||||
*/
|
||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
/** @var Collection<int, Activity> $activityList */
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
@@ -40,6 +41,9 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
|
||||
/**
|
||||
* Get the entity that this comment belongs to.
|
||||
* It's only pages right now hence the typing below.
|
||||
* Would need a deeper audit if that changes as many areas assume this is always a page.
|
||||
* @return MorphTo<Page, $this>
|
||||
*/
|
||||
public function entity(): MorphTo
|
||||
{
|
||||
@@ -55,7 +59,9 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
|
||||
// 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');
|
||||
/** @var MorphTo<Page, $this> $relation */
|
||||
$relation = $this->morphTo(null, 'commentable_type', 'commentable_id');
|
||||
return $relation;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,8 +57,10 @@ class WebhookFormatter
|
||||
}
|
||||
|
||||
/**
|
||||
* @template TModel of Model
|
||||
*
|
||||
* @param callable(string, Model):bool $condition
|
||||
* @param callable(Model):void $format
|
||||
* @param callable(TModel):void $format
|
||||
*/
|
||||
public function addModelFormatter(callable $condition, callable $format): void
|
||||
{
|
||||
@@ -73,13 +75,17 @@ class WebhookFormatter
|
||||
// Load entity owner, creator, updater details
|
||||
$this->addModelFormatter(
|
||||
fn ($event, $model) => ($model instanceof Entity),
|
||||
fn ($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy'])
|
||||
function (Entity $model) {
|
||||
$model->load(['ownedBy', 'createdBy', 'updatedBy']);
|
||||
}
|
||||
);
|
||||
|
||||
// Load revision detail for page update and create events
|
||||
$this->addModelFormatter(
|
||||
fn ($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)),
|
||||
fn ($model) => $model->load('currentRevision')
|
||||
function (Page $model) {
|
||||
$model->load('currentRevision');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,20 @@
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Http\ApiController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ApiDocsController extends ApiController
|
||||
{
|
||||
protected static array $gettingStartedSectionsById = [
|
||||
'authentication' => 'Authentication',
|
||||
'request-format' => 'Request Format',
|
||||
'listing-endpoints' => 'Listing Endpoints',
|
||||
'error-handling' => 'Error Handling',
|
||||
'rate-limits' => 'Rate Limits',
|
||||
'content-security' => 'Content Security',
|
||||
];
|
||||
|
||||
/**
|
||||
* Load the docs page for the API.
|
||||
*/
|
||||
@@ -16,6 +27,7 @@ class ApiDocsController extends ApiController
|
||||
|
||||
return view('api-docs.index', [
|
||||
'docs' => $docs,
|
||||
'gettingStartedSections' => static::$gettingStartedSectionsById,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -29,10 +41,36 @@ class ApiDocsController extends ApiController
|
||||
return response()->json($docs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the API docs as a cleaner HTML file or as JSON with embedded HTML guidance.
|
||||
* Provide a ?format=json query parameter to download as JSON.
|
||||
*/
|
||||
public function download(Request $request)
|
||||
{
|
||||
$format = $request->query('format') ?? 'html';
|
||||
$docs = ApiDocsGenerator::generateConsideringCache();
|
||||
$downloadName = Str::slug(strtolower(setting('app-name')) . '-api-docs');
|
||||
|
||||
if ($format === 'json') {
|
||||
$docs->prepend(
|
||||
view('api-docs.parts.getting-started')->render(),
|
||||
'getting-started-guide'
|
||||
);
|
||||
return $this->createDownload()->directly(json_encode($docs), "{$downloadName}.json");
|
||||
}
|
||||
|
||||
$responseData = view('api-docs.download', [
|
||||
'docs' => $docs,
|
||||
'gettingStartedSections' => static::$gettingStartedSectionsById,
|
||||
])->render();
|
||||
|
||||
return $this->createDownload()->directly($responseData, "{$downloadName}.html");
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to the API docs page.
|
||||
* Required as a controller method, instead of the Route::redirect helper,
|
||||
* to ensure the URL is generated correctly.
|
||||
* Required as a controller method, instead of the Route::redirect helper,
|
||||
* to ensure the URL is generated correctly.
|
||||
*/
|
||||
public function redirect()
|
||||
{
|
||||
|
||||
@@ -138,8 +138,8 @@ class ApiDocsGenerator
|
||||
return $validation;
|
||||
}
|
||||
|
||||
if (is_object($validation) && method_exists($validation, '__toString')) {
|
||||
return strval($validation);
|
||||
if (is_object($validation) && $validation instanceof \Stringable) {
|
||||
return $validation->__toString();
|
||||
}
|
||||
|
||||
if ($validation instanceof Password) {
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
namespace BookStack\App;
|
||||
|
||||
use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Queries\QueryRecentlyViewed;
|
||||
use BookStack\Entities\Queries\QueryTopFavourites;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
@@ -24,49 +21,19 @@ class HomeController extends Controller
|
||||
*/
|
||||
public function index(
|
||||
Request $request,
|
||||
ActivityQueries $activities,
|
||||
QueryRecentlyViewed $recentlyViewed,
|
||||
QueryTopFavourites $topFavourites,
|
||||
) {
|
||||
$activity = $activities->latest(10);
|
||||
$draftPages = [];
|
||||
|
||||
if ($this->isSignedIn()) {
|
||||
$draftPages = $this->queries->pages->currentUserDraftsForList()
|
||||
->orderBy('updated_at', 'desc')
|
||||
->with('book')
|
||||
->take(6)
|
||||
->get();
|
||||
}
|
||||
|
||||
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
|
||||
$recents = $this->isSignedIn() ?
|
||||
$recentlyViewed->run(12 * $recentFactor, 1)
|
||||
: $this->queries->books->visibleForList()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
|
||||
$favourites = $topFavourites->run(6);
|
||||
$recentlyUpdatedPages = $this->queries->pages->visibleForList()
|
||||
->where('draft', false)
|
||||
->orderBy('updated_at', 'desc')
|
||||
->take($favourites->count() > 0 ? 5 : 10)
|
||||
->get();
|
||||
|
||||
$homepageOptions = ['default', 'books', 'bookshelves', 'page'];
|
||||
$homepageOption = setting('app-homepage-type', 'default');
|
||||
if (!in_array($homepageOption, $homepageOptions)) {
|
||||
$homepageOption = 'default';
|
||||
$homepageType = setting('app-homepage-type');
|
||||
if (!in_array($homepageType, ['default', 'books', 'bookshelves', 'page'])) {
|
||||
$homepageType = 'default';
|
||||
}
|
||||
|
||||
$commonData = [
|
||||
'activity' => $activity,
|
||||
'recents' => $recents,
|
||||
'recentlyUpdatedPages' => $recentlyUpdatedPages,
|
||||
'draftPages' => $draftPages,
|
||||
'favourites' => $favourites,
|
||||
'homeView' => $homepageType,
|
||||
];
|
||||
|
||||
// Add required list ordering & sorting for books & shelves views.
|
||||
if ($homepageOption === 'bookshelves' || $homepageOption === 'books') {
|
||||
$key = $homepageOption;
|
||||
if ($homepageType === 'bookshelves' || $homepageType === 'books') {
|
||||
$key = $homepageType;
|
||||
$view = setting()->getForCurrentUser($key . '_view_type');
|
||||
$listOptions = SimpleListOptions::fromRequest($request, $key)->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
@@ -80,7 +47,7 @@ class HomeController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
if ($homepageOption === 'bookshelves') {
|
||||
if ($homepageType === 'bookshelves') {
|
||||
$shelves = $this->queries->shelves->visibleForListWithCover()
|
||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
|
||||
@@ -89,7 +56,7 @@ class HomeController extends Controller
|
||||
return view('home.shelves', $data);
|
||||
}
|
||||
|
||||
if ($homepageOption === 'books') {
|
||||
if ($homepageType === 'books') {
|
||||
$books = $this->queries->books->visibleForListWithCover()
|
||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
|
||||
@@ -98,7 +65,7 @@ class HomeController extends Controller
|
||||
return view('home.books', $data);
|
||||
}
|
||||
|
||||
if ($homepageOption === 'page') {
|
||||
if ($homepageType === 'page') {
|
||||
$homepageSetting = setting('app-homepage', '0:');
|
||||
$id = intval(explode(':', $homepageSetting)[0]);
|
||||
/** @var Page $customHomepage */
|
||||
|
||||
@@ -7,6 +7,7 @@ use BookStack\Theming\ThemeService;
|
||||
use BookStack\Theming\ThemeViews;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\View\FileViewFinder;
|
||||
|
||||
class ThemeServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -27,7 +28,11 @@ class ThemeServiceProvider extends ServiceProvider
|
||||
// Boot up the theme system
|
||||
$themeService = $this->app->make(ThemeService::class);
|
||||
$viewFactory = $this->app->make('view');
|
||||
$themeViews = new ThemeViews($viewFactory->getFinder());
|
||||
$viewFinder = $viewFactory->getFinder();
|
||||
if (!($viewFinder instanceof FileViewFinder)) {
|
||||
throw new \Exception('Only the file view finder is supported for the theme system');
|
||||
}
|
||||
$themeViews = new ThemeViews($viewFinder);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Entities\BreadcrumbsViewComposer;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Util\DateFormatter;
|
||||
use BookStack\View\ViewBlockManager;
|
||||
use BookStack\View\ViewBlockPreferences;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\View;
|
||||
@@ -18,6 +22,10 @@ class ViewTweaksServiceProvider extends ServiceProvider
|
||||
$app['config']->get('app.display_timezone'),
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->singleton(ViewBlockManager::class, function ($app) {
|
||||
return new ViewBlockManager(new ViewBlockPreferences());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +40,10 @@ class ViewTweaksServiceProvider extends ServiceProvider
|
||||
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
|
||||
|
||||
// View Globals
|
||||
$viewBlockManager = $this->app->make(ViewBlockManager::class);
|
||||
View::share('dates', $this->app->make(DateFormatter::class));
|
||||
View::share('viewBlocks', $viewBlockManager);
|
||||
Theme::dispatch(ThemeEvents::VIEW_BLOCKS_REGISTER, $viewBlockManager);
|
||||
|
||||
// Custom blade view directives
|
||||
Blade::directive('icon', function ($expression) {
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ use BookStack\Facades\Theme;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Settings\SettingService;
|
||||
use BookStack\Users\Models\OwnableInterface;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,7 @@ function user(): User
|
||||
* Check if the current user has a permission. If an ownable element
|
||||
* is passed in the jointPermissions are checked against that particular item.
|
||||
*/
|
||||
function userCan(string|Permission $permission, ?Model $ownable = null): bool
|
||||
function userCan(string|Permission $permission, (Model&OwnableInterface)|null $ownable = null): bool
|
||||
{
|
||||
if (is_null($ownable)) {
|
||||
return user()->can($permission);
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ return [
|
||||
'auto_detect_locale' => env('APP_AUTO_LANG_PUBLIC', true),
|
||||
|
||||
// Encryption key
|
||||
'key' => env('APP_KEY', 'AbAZchsay4uBTU33RubBzLKw203yqSqr'),
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
// Encryption cipher
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
@@ -32,6 +32,7 @@ return [
|
||||
'page-draft-color-dark' => '#a66ce8',
|
||||
'app-custom-head' => false,
|
||||
'registration-enabled' => false,
|
||||
'app-homepage-type' => 'default',
|
||||
|
||||
// User-level default settings
|
||||
'user' => [
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
@@ -19,7 +17,6 @@ use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\Util\DatabaseTransaction;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -34,7 +31,6 @@ class BookController extends Controller
|
||||
protected BookQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected BookshelfQueries $shelfQueries,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -53,9 +49,6 @@ class BookController extends Controller
|
||||
$books = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
|
||||
$popular = $this->queries->popularForList()->take(4)->get();
|
||||
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
|
||||
|
||||
$this->shelfContext->clearShelfContext();
|
||||
|
||||
@@ -63,9 +56,6 @@ class BookController extends Controller
|
||||
|
||||
return view('books.index', [
|
||||
'books' => $books,
|
||||
'recents' => $recents,
|
||||
'popular' => $popular,
|
||||
'new' => $new,
|
||||
'view' => $view,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
@@ -127,7 +117,7 @@ class BookController extends Controller
|
||||
/**
|
||||
* Display the specified book.
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
public function show(Request $request, string $slug)
|
||||
{
|
||||
try {
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
@@ -140,7 +130,6 @@ class BookController extends Controller
|
||||
}
|
||||
|
||||
$bookChildren = (new BookContents($book))->getTree(true);
|
||||
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
||||
|
||||
View::incrementFor($book);
|
||||
if ($request->has('shelf')) {
|
||||
@@ -153,10 +142,6 @@ class BookController extends Controller
|
||||
'book' => $book,
|
||||
'current' => $book,
|
||||
'bookChildren' => $bookChildren,
|
||||
'bookParentShelves' => $bookParentShelves,
|
||||
'watchOptions' => new UserEntityWatchOptions(user(), $book),
|
||||
'activity' => $activities->entityActivity($book, 20, 1),
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
@@ -13,7 +12,6 @@ use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -27,7 +25,6 @@ class BookshelfController extends Controller
|
||||
protected EntityQueries $entityQueries,
|
||||
protected BookQueries $bookQueries,
|
||||
protected ShelfContext $shelfContext,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -46,21 +43,12 @@ class BookshelfController extends Controller
|
||||
$shelves = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
|
||||
$popular = $this->queries->popularForList()->get();
|
||||
$new = $this->queries->visibleForList()
|
||||
->orderBy('created_at', 'desc')
|
||||
->take(4)
|
||||
->get();
|
||||
|
||||
$this->shelfContext->clearShelfContext();
|
||||
$this->setPageTitle(trans('entities.shelves'));
|
||||
|
||||
return view('shelves.index', [
|
||||
'shelves' => $shelves,
|
||||
'recents' => $recents,
|
||||
'popular' => $popular,
|
||||
'new' => $new,
|
||||
'view' => $view,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
@@ -105,7 +93,7 @@ class BookshelfController extends Controller
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
public function show(Request $request, string $slug)
|
||||
{
|
||||
try {
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
@@ -144,9 +132,7 @@ class BookshelfController extends Controller
|
||||
'shelf' => $shelf,
|
||||
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
|
||||
'view' => $view,
|
||||
'activity' => $activities->entityActivity($shelf, 20, 1),
|
||||
'listOptions' => $listOptions,
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($shelf),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Queries\ChapterQueries;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
@@ -18,7 +17,6 @@ use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\Util\DatabaseTransaction;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -30,7 +28,6 @@ class ChapterController extends Controller
|
||||
protected ChapterRepo $chapterRepo,
|
||||
protected ChapterQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -87,10 +84,10 @@ class ChapterController extends Controller
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
||||
$sidebarTree = (new BookContents($chapter->book))->getTree();
|
||||
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
|
||||
|
||||
$nextPreviousLocator = new NextPreviousContentLocator($chapter, $sidebarTree);
|
||||
$bookTree = (new BookContents($chapter->book))->getTree();
|
||||
$nextPreviousLocator = new NextPreviousContentLocator($chapter, $bookTree);
|
||||
View::incrementFor($chapter);
|
||||
|
||||
$this->setPageTitle($chapter->getShortName());
|
||||
@@ -99,12 +96,10 @@ class ChapterController extends Controller
|
||||
'book' => $chapter->book,
|
||||
'chapter' => $chapter,
|
||||
'current' => $chapter,
|
||||
'sidebarTree' => $sidebarTree,
|
||||
'watchOptions' => new UserEntityWatchOptions(user(), $chapter),
|
||||
'pages' => $pages,
|
||||
'next' => $nextPreviousLocator->getNext(),
|
||||
'previous' => $nextPreviousLocator->getPrevious(),
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter),
|
||||
'bookTree' => $bookTree,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\Tools\CommentTree;
|
||||
@@ -10,7 +12,9 @@ use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class PageApiController extends ApiController
|
||||
{
|
||||
@@ -23,6 +27,7 @@ class PageApiController extends ApiController
|
||||
'markdown' => ['required_without:html', 'string'],
|
||||
'tags' => ['array'],
|
||||
'priority' => ['integer'],
|
||||
'changelog' => ['string', 'min:1', 'max:180'],
|
||||
],
|
||||
'update' => [
|
||||
'book_id' => ['integer'],
|
||||
@@ -32,6 +37,7 @@ class PageApiController extends ApiController
|
||||
'markdown' => ['string'],
|
||||
'tags' => ['array'],
|
||||
'priority' => ['integer'],
|
||||
'changelog' => ['string', 'min:1', 'max:180'],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -45,7 +51,7 @@ class PageApiController extends ApiController
|
||||
/**
|
||||
* Get a listing of pages visible to the user.
|
||||
*/
|
||||
public function list()
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
$pages = $this->queries->visibleForList()
|
||||
->addSelect(['created_by', 'updated_by', 'revision_count', 'editor']);
|
||||
@@ -69,9 +75,9 @@ class PageApiController extends ApiController
|
||||
* Any images included via base64 data URIs will be extracted and saved as gallery
|
||||
* images against the page during upload.
|
||||
*/
|
||||
public function create(Request $request)
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$this->validate($request, $this->rules['create']);
|
||||
$validated = $this->validate($request, $this->rules['create']);
|
||||
|
||||
if ($request->has('chapter_id')) {
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id')));
|
||||
@@ -81,7 +87,7 @@ class PageApiController extends ApiController
|
||||
$this->checkOwnablePermission(Permission::PageCreate, $parent);
|
||||
|
||||
$draft = $this->pageRepo->getNewDraftPage($parent);
|
||||
$this->pageRepo->publishDraft($draft, $request->only(array_keys($this->rules['create'])));
|
||||
$this->pageRepo->publishDraft($draft, $validated);
|
||||
|
||||
return response()->json($draft->forJsonDisplay());
|
||||
}
|
||||
@@ -102,9 +108,9 @@ class PageApiController extends ApiController
|
||||
* Comments for the page are provided in a tree-structure representing the hierarchy of top-level
|
||||
* comments and replies, for both archived and active comments.
|
||||
*/
|
||||
public function read(string $id)
|
||||
public function read(string $id): JsonResponse
|
||||
{
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$page = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
|
||||
$page = $page->forJsonDisplay();
|
||||
$commentTree = (new CommentTree($page));
|
||||
@@ -124,11 +130,11 @@ class PageApiController extends ApiController
|
||||
* Providing a 'book_id' or 'chapter_id' property will essentially move
|
||||
* the page into that parent element if you have permissions to do so.
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$requestData = $this->validate($request, $this->rules['update']);
|
||||
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$page = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
|
||||
$parent = null;
|
||||
@@ -161,9 +167,9 @@ class PageApiController extends ApiController
|
||||
* Delete a page.
|
||||
* This will typically send the page to the recycle bin.
|
||||
*/
|
||||
public function delete(string $id)
|
||||
public function delete(string $id): Response
|
||||
{
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$page = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission(Permission::PageDelete, $page);
|
||||
|
||||
$this->pageRepo->destroy($page);
|
||||
|
||||
@@ -4,9 +4,9 @@ namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Activity\Tools\CommentTree;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
@@ -20,11 +20,11 @@ use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
@@ -35,7 +35,6 @@ class PageController extends Controller
|
||||
protected PageRepo $pageRepo,
|
||||
protected PageQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected ReferenceFetcher $referenceFetcher
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -95,7 +94,7 @@ class PageController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Show form to continue editing a draft page.
|
||||
* Show a form to continue editing a draft page.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
@@ -103,6 +102,7 @@ class PageController extends Controller
|
||||
{
|
||||
$draft = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission(Permission::PageCreate, $draft->getParent());
|
||||
$this->ensureDraftAccess($draft);
|
||||
|
||||
$editorData = new PageEditorData($draft, $this->entityQueries, $request->query('editor', ''));
|
||||
$this->setPageTitle(trans('entities.pages_edit_draft'));
|
||||
@@ -124,6 +124,7 @@ class PageController extends Controller
|
||||
|
||||
$draftPage = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent());
|
||||
$this->ensureDraftAccess($draftPage);
|
||||
|
||||
$page = $this->pageRepo->publishDraft($draftPage, $request->all());
|
||||
|
||||
@@ -151,11 +152,10 @@ class PageController extends Controller
|
||||
|
||||
$pageContent = (new PageContent($page));
|
||||
$page->html = $pageContent->render();
|
||||
$pageNav = $pageContent->getNavigation($page->html);
|
||||
|
||||
$sidebarTree = (new BookContents($page->book))->getTree();
|
||||
$bookTree = (new BookContents($page->book))->getTree();
|
||||
$commentTree = (new CommentTree($page));
|
||||
$nextPreviousLocator = new NextPreviousContentLocator($page, $sidebarTree);
|
||||
$nextPreviousLocator = new NextPreviousContentLocator($page, $bookTree);
|
||||
|
||||
View::incrementFor($page);
|
||||
$this->setPageTitle($page->getShortName());
|
||||
@@ -164,13 +164,10 @@ class PageController extends Controller
|
||||
'page' => $page,
|
||||
'book' => $page->book,
|
||||
'current' => $page,
|
||||
'sidebarTree' => $sidebarTree,
|
||||
'bookTree' => $bookTree,
|
||||
'commentTree' => $commentTree,
|
||||
'pageNav' => $pageNav,
|
||||
'watchOptions' => new UserEntityWatchOptions(user(), $page),
|
||||
'next' => $nextPreviousLocator->getNext(),
|
||||
'previous' => $nextPreviousLocator->getPrevious(),
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($page),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -235,6 +232,7 @@ class PageController extends Controller
|
||||
* Save a draft update as a revision.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
public function saveDraft(Request $request, int $pageId)
|
||||
{
|
||||
@@ -245,6 +243,10 @@ class PageController extends Controller
|
||||
return $this->jsonError(trans('errors.guests_cannot_save_drafts'), 500);
|
||||
}
|
||||
|
||||
if ($page->draft) {
|
||||
$this->ensureDraftAccess($page);
|
||||
}
|
||||
|
||||
$draft = $this->pageRepo->updatePageDraft($page, $request->only(['name', 'html', 'markdown']));
|
||||
$warnings = (new PageEditActivity($page))->getWarningMessagesForDraft($draft);
|
||||
|
||||
@@ -294,11 +296,14 @@ class PageController extends Controller
|
||||
* Show the deletion page for the specified page.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
public function showDeleteDraft(string $bookSlug, int $pageId)
|
||||
{
|
||||
$page = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
$this->ensureDraftAccess($page);
|
||||
|
||||
$this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()]));
|
||||
$usedAsTemplate =
|
||||
$this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
|
||||
@@ -340,7 +345,9 @@ class PageController extends Controller
|
||||
$page = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$book = $page->book;
|
||||
$chapter = $page->chapter;
|
||||
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
$this->ensureDraftAccess($page);
|
||||
|
||||
$this->pageRepo->destroy($page);
|
||||
|
||||
@@ -358,8 +365,8 @@ class PageController extends Controller
|
||||
*/
|
||||
public function showRecentlyUpdated()
|
||||
{
|
||||
$visibleBelongsScope = function (BelongsTo $query) {
|
||||
$query->scopes('visible');
|
||||
$visibleBelongsScope = function (Relation $relation): void {
|
||||
$relation->scopes('visible');
|
||||
};
|
||||
|
||||
$pages = $this->queries->visibleForList()
|
||||
@@ -470,4 +477,14 @@ class PageController extends Controller
|
||||
|
||||
return redirect($pageCopy->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
protected function ensureDraftAccess(Page $draft): void
|
||||
{
|
||||
if (!$draft->draft || $draft->created_by !== user()->id) {
|
||||
throw new PermissionsException('This page is already published or does not belong to you.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function destroyUserDraft(string $pageId)
|
||||
{
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail(intval($pageId));
|
||||
$this->revisionRepo->deleteDraftsForCurrentUser($page);
|
||||
|
||||
return response('', 200);
|
||||
|
||||
@@ -20,10 +20,10 @@ use Illuminate\Support\Collection;
|
||||
* @property ?int $image_id
|
||||
* @property ?int $default_template_id
|
||||
* @property ?int $sort_rule_id
|
||||
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
||||
* @property \Illuminate\Database\Eloquent\Collection $pages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $directPages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $shelves
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, Chapter> $chapters
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, Page> $pages
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, Page> $directPages
|
||||
* @property \Illuminate\Database\Eloquent\Collection<int, Bookshelf> $shelves
|
||||
* @property ?SortRule $sortRule
|
||||
*/
|
||||
class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface
|
||||
|
||||
@@ -46,7 +46,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property int|null $created_by
|
||||
* @property int|null $updated_by
|
||||
* @property int|null $owned_by
|
||||
* @property Collection $tags
|
||||
* @property Collection<int, Tag> $tags
|
||||
*
|
||||
* @method static Entity|Builder visible()
|
||||
* @method static Builder withLastView()
|
||||
|
||||
@@ -25,6 +25,9 @@ class EntityTable extends Model
|
||||
|
||||
/**
|
||||
* Get the entities that are visible to the current user.
|
||||
* Note: This only applies basic permission filtering which considers the core entity table.
|
||||
* This will not filter on information from other tables such as page draft status.
|
||||
* That should be done after applying this scope.
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
|
||||
@@ -24,8 +24,8 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
* @property int $revision_count
|
||||
* @property string $editor
|
||||
* @property Chapter|null $chapter
|
||||
* @property Collection $attachments
|
||||
* @property Collection $revisions
|
||||
* @property Collection<int, Attachment> $attachments
|
||||
* @property Collection<int, PageRevision> $revisions
|
||||
* @property PageRevision $currentRevision
|
||||
*/
|
||||
class Page extends BookChild
|
||||
|
||||
@@ -81,6 +81,13 @@ class EntityQueries
|
||||
})->leftJoin('entity_page_data', function (JoinClause $join) {
|
||||
$join->on('entity_page_data.page_id', '=', 'entities.id')
|
||||
->where('entities.type', '=', 'page');
|
||||
})->where(function ($query) {
|
||||
$query->whereNull('entity_page_data.draft')
|
||||
->orWhere('entity_page_data.draft', '=', 0)
|
||||
->orWhere(function ($query) {
|
||||
$query->where('entity_page_data.draft', '=', 1)
|
||||
->where('entities.owned_by', '=', user()->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -20,6 +21,7 @@ class QueryPopular
|
||||
|
||||
public function run(int $count, int $page, array $filterModels): Collection
|
||||
{
|
||||
/** @var Builder<View> $query */
|
||||
$query = $this->permissions
|
||||
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
|
||||
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Entities\Queries;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class QueryRecentlyViewed
|
||||
@@ -22,6 +23,7 @@ class QueryRecentlyViewed
|
||||
return collect();
|
||||
}
|
||||
|
||||
/** @var Builder<View> $query */
|
||||
$query = $this->permissions->restrictEntityRelationQuery(
|
||||
View::query(),
|
||||
'views',
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Entities\Queries;
|
||||
use BookStack\Activity\Models\Favourite;
|
||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
|
||||
class QueryTopFavourites
|
||||
@@ -22,6 +23,7 @@ class QueryTopFavourites
|
||||
return collect();
|
||||
}
|
||||
|
||||
/** @var Builder<Favourite> $query */
|
||||
$query = $this->permissions
|
||||
->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type')
|
||||
->select('favourites.*')
|
||||
|
||||
@@ -91,7 +91,7 @@ class PageRepo
|
||||
$draft = $this->baseRepo->update($draft, $input);
|
||||
$draft->rebuildPermissions();
|
||||
|
||||
$summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
|
||||
$summary = trim($input['changelog'] ?? '') ?: trans('entities.pages_initial_revision');
|
||||
$this->revisionRepo->storeNewForPage($draft, $summary);
|
||||
$draft->refresh();
|
||||
|
||||
@@ -134,7 +134,7 @@ class PageRepo
|
||||
$this->revisionRepo->deleteDraftsForCurrentUser($page);
|
||||
|
||||
// Save a revision after updating
|
||||
$summary = trim($input['summary'] ?? '');
|
||||
$summary = trim($input['changelog'] ?? '');
|
||||
$htmlChanged = isset($input['html']) && $input['html'] !== $oldHtml;
|
||||
$nameChanged = isset($input['name']) && $input['name'] !== $oldName;
|
||||
$markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown;
|
||||
|
||||
@@ -25,9 +25,13 @@ class CustomListItemRenderer implements NodeRendererInterface
|
||||
*/
|
||||
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
|
||||
{
|
||||
if (!($node instanceof ListItem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$listItem = $this->baseRenderer->render($node, $childRenderer);
|
||||
|
||||
if ($node instanceof ListItem && $this->startsTaskListItem($node) && $listItem instanceof HtmlElement) {
|
||||
if ($this->startsTaskListItem($node) && $listItem instanceof HtmlElement) {
|
||||
$listItem->setAttribute('class', 'task-list-item');
|
||||
}
|
||||
|
||||
|
||||
@@ -421,28 +421,41 @@ class PageContent
|
||||
*/
|
||||
protected function headerNodesToLevelList(DOMNodeList $nodeList): array
|
||||
{
|
||||
$tree = collect($nodeList)->map(function (DOMElement $header) {
|
||||
$minLevel = 6;
|
||||
|
||||
$headerDetails = array_map(function (DOMNode $header) use (&$minLevel) {
|
||||
if (!$header instanceof DOMElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim(str_replace("\xc2\xa0", ' ', $header->nodeValue));
|
||||
$text = mb_substr($text, 0, 100);
|
||||
|
||||
if (empty($text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$level = intval(str_replace('h', '', $header->nodeName));
|
||||
if ($level < $minLevel) {
|
||||
$minLevel = $level;
|
||||
}
|
||||
|
||||
return [
|
||||
'nodeName' => strtolower($header->nodeName),
|
||||
'level' => intval(str_replace('h', '', $header->nodeName)),
|
||||
'level' => $level,
|
||||
'link' => '#' . $header->getAttribute('id'),
|
||||
'text' => $text,
|
||||
];
|
||||
})->filter(function ($header) {
|
||||
return mb_strlen($header['text']) > 0;
|
||||
});
|
||||
}, [...$nodeList]);
|
||||
|
||||
$filtered = array_values(array_filter($headerDetails));
|
||||
|
||||
// Shift headers if only smaller headers have been used
|
||||
$levelChange = ($tree->pluck('level')->min() - 1);
|
||||
$tree = $tree->map(function ($header) use ($levelChange) {
|
||||
$header['level'] -= ($levelChange);
|
||||
$levelChange = ($minLevel - 1);
|
||||
foreach ($filtered as $index => $header) {
|
||||
$filtered[$index]['level'] -= $levelChange;
|
||||
}
|
||||
|
||||
return $header;
|
||||
});
|
||||
|
||||
return $tree->toArray();
|
||||
return $filtered;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,10 +192,10 @@ class PageIncludeParser
|
||||
/**
|
||||
* Get the parent paragraph of the given node, if existing.
|
||||
*/
|
||||
protected function getParentParagraph(DOMNode $parent): ?DOMNode
|
||||
protected function getParentParagraph(DOMNode $parent): ?DOMElement
|
||||
{
|
||||
do {
|
||||
if (strtolower($parent->nodeName) === 'p') {
|
||||
if (strtolower($parent->nodeName) === 'p' && $parent instanceof DOMElement) {
|
||||
return $parent;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ class SlugGenerator
|
||||
{
|
||||
$slug = Str::slug($name);
|
||||
if ($slug === '') {
|
||||
$slug = substr(md5(rand(1, 500)), 0, 5);
|
||||
$slug = substr(md5(strval(rand(1, 500))), 0, 5);
|
||||
}
|
||||
|
||||
return $slug;
|
||||
|
||||
@@ -358,6 +358,8 @@ class TrashCan
|
||||
$entity->chapters()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
|
||||
}
|
||||
|
||||
$entity->rebuildPermissions();
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
|
||||
@@ -169,14 +169,4 @@ class Handler extends ExceptionHandler
|
||||
|
||||
return redirect()->guest('login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a validation exception into a JSON response.
|
||||
*
|
||||
* @param Request $request
|
||||
*/
|
||||
protected function invalidJson($request, ValidationException $exception): JsonResponse
|
||||
{
|
||||
return response()->json($exception->errors(), $exception->status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class BookExportApiController extends ApiController
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$pdfContent = $this->exportFormatter->bookToPdf($book);
|
||||
|
||||
return $this->download()->directly($pdfContent, $book->slug . '.pdf');
|
||||
return $this->createDownload()->directly($pdfContent, $book->slug . '.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ class BookExportApiController extends ApiController
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
|
||||
|
||||
return $this->download()->directly($htmlContent, $book->slug . '.html');
|
||||
return $this->createDownload()->directly($htmlContent, $book->slug . '.html');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +52,7 @@ class BookExportApiController extends ApiController
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$textContent = $this->exportFormatter->bookToPlainText($book);
|
||||
|
||||
return $this->download()->directly($textContent, $book->slug . '.txt');
|
||||
return $this->createDownload()->directly($textContent, $book->slug . '.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +63,7 @@ class BookExportApiController extends ApiController
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$markdown = $this->exportFormatter->bookToMarkdown($book);
|
||||
|
||||
return $this->download()->directly($markdown, $book->slug . '.md');
|
||||
return $this->createDownload()->directly($markdown, $book->slug . '.md');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +74,6 @@ class BookExportApiController extends ApiController
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$zip = $builder->buildForBook($book);
|
||||
|
||||
return $this->download()->streamedFileDirectly($zip, $book->slug . '.zip', true);
|
||||
return $this->createDownload()->streamedFileDirectly($zip, $book->slug . '.zip', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class BookExportController extends Controller
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$pdfContent = $this->exportFormatter->bookToPdf($book);
|
||||
|
||||
return $this->download()->directly($pdfContent, $bookSlug . '.pdf');
|
||||
return $this->createDownload()->directly($pdfContent, $bookSlug . '.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +43,7 @@ class BookExportController extends Controller
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
|
||||
|
||||
return $this->download()->directly($htmlContent, $bookSlug . '.html');
|
||||
return $this->createDownload()->directly($htmlContent, $bookSlug . '.html');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +54,7 @@ class BookExportController extends Controller
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$textContent = $this->exportFormatter->bookToPlainText($book);
|
||||
|
||||
return $this->download()->directly($textContent, $bookSlug . '.txt');
|
||||
return $this->createDownload()->directly($textContent, $bookSlug . '.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,7 +65,7 @@ class BookExportController extends Controller
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$textContent = $this->exportFormatter->bookToMarkdown($book);
|
||||
|
||||
return $this->download()->directly($textContent, $bookSlug . '.md');
|
||||
return $this->createDownload()->directly($textContent, $bookSlug . '.md');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +77,6 @@ class BookExportController extends Controller
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$zip = $builder->buildForBook($book);
|
||||
|
||||
return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', true);
|
||||
return $this->createDownload()->streamedFileDirectly($zip, $bookSlug . '.zip', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class ChapterExportApiController extends ApiController
|
||||
$chapter = $this->queries->findVisibleByIdOrFail($id);
|
||||
$pdfContent = $this->exportFormatter->chapterToPdf($chapter);
|
||||
|
||||
return $this->download()->directly($pdfContent, $chapter->slug . '.pdf');
|
||||
return $this->createDownload()->directly($pdfContent, $chapter->slug . '.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ class ChapterExportApiController extends ApiController
|
||||
$chapter = $this->queries->findVisibleByIdOrFail($id);
|
||||
$htmlContent = $this->exportFormatter->chapterToContainedHtml($chapter);
|
||||
|
||||
return $this->download()->directly($htmlContent, $chapter->slug . '.html');
|
||||
return $this->createDownload()->directly($htmlContent, $chapter->slug . '.html');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +52,7 @@ class ChapterExportApiController extends ApiController
|
||||
$chapter = $this->queries->findVisibleByIdOrFail($id);
|
||||
$textContent = $this->exportFormatter->chapterToPlainText($chapter);
|
||||
|
||||
return $this->download()->directly($textContent, $chapter->slug . '.txt');
|
||||
return $this->createDownload()->directly($textContent, $chapter->slug . '.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +63,7 @@ class ChapterExportApiController extends ApiController
|
||||
$chapter = $this->queries->findVisibleByIdOrFail($id);
|
||||
$markdown = $this->exportFormatter->chapterToMarkdown($chapter);
|
||||
|
||||
return $this->download()->directly($markdown, $chapter->slug . '.md');
|
||||
return $this->createDownload()->directly($markdown, $chapter->slug . '.md');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +74,6 @@ class ChapterExportApiController extends ApiController
|
||||
$chapter = $this->queries->findVisibleByIdOrFail($id);
|
||||
$zip = $builder->buildForChapter($chapter);
|
||||
|
||||
return $this->download()->streamedFileDirectly($zip, $chapter->slug . '.zip', true);
|
||||
return $this->createDownload()->streamedFileDirectly($zip, $chapter->slug . '.zip', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class ChapterExportController extends Controller
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$pdfContent = $this->exportFormatter->chapterToPdf($chapter);
|
||||
|
||||
return $this->download()->directly($pdfContent, $chapterSlug . '.pdf');
|
||||
return $this->createDownload()->directly($pdfContent, $chapterSlug . '.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +45,7 @@ class ChapterExportController extends Controller
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$containedHtml = $this->exportFormatter->chapterToContainedHtml($chapter);
|
||||
|
||||
return $this->download()->directly($containedHtml, $chapterSlug . '.html');
|
||||
return $this->createDownload()->directly($containedHtml, $chapterSlug . '.html');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +58,7 @@ class ChapterExportController extends Controller
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$chapterText = $this->exportFormatter->chapterToPlainText($chapter);
|
||||
|
||||
return $this->download()->directly($chapterText, $chapterSlug . '.txt');
|
||||
return $this->createDownload()->directly($chapterText, $chapterSlug . '.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +71,7 @@ class ChapterExportController extends Controller
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$chapterText = $this->exportFormatter->chapterToMarkdown($chapter);
|
||||
|
||||
return $this->download()->directly($chapterText, $chapterSlug . '.md');
|
||||
return $this->createDownload()->directly($chapterText, $chapterSlug . '.md');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +83,6 @@ class ChapterExportController extends Controller
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$zip = $builder->buildForChapter($chapter);
|
||||
|
||||
return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', true);
|
||||
return $this->createDownload()->streamedFileDirectly($zip, $chapterSlug . '.zip', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class PageExportApiController extends ApiController
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$pdfContent = $this->exportFormatter->pageToPdf($page);
|
||||
|
||||
return $this->download()->directly($pdfContent, $page->slug . '.pdf');
|
||||
return $this->createDownload()->directly($pdfContent, $page->slug . '.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ class PageExportApiController extends ApiController
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$htmlContent = $this->exportFormatter->pageToContainedHtml($page);
|
||||
|
||||
return $this->download()->directly($htmlContent, $page->slug . '.html');
|
||||
return $this->createDownload()->directly($htmlContent, $page->slug . '.html');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +52,7 @@ class PageExportApiController extends ApiController
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$textContent = $this->exportFormatter->pageToPlainText($page);
|
||||
|
||||
return $this->download()->directly($textContent, $page->slug . '.txt');
|
||||
return $this->createDownload()->directly($textContent, $page->slug . '.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +63,7 @@ class PageExportApiController extends ApiController
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$markdown = $this->exportFormatter->pageToMarkdown($page);
|
||||
|
||||
return $this->download()->directly($markdown, $page->slug . '.md');
|
||||
return $this->createDownload()->directly($markdown, $page->slug . '.md');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +74,6 @@ class PageExportApiController extends ApiController
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$zip = $builder->buildForPage($page);
|
||||
|
||||
return $this->download()->streamedFileDirectly($zip, $page->slug . '.zip', true);
|
||||
return $this->createDownload()->streamedFileDirectly($zip, $page->slug . '.zip', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class PageExportController extends Controller
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$pdfContent = $this->exportFormatter->pageToPdf($page);
|
||||
|
||||
return $this->download()->directly($pdfContent, $pageSlug . '.pdf');
|
||||
return $this->createDownload()->directly($pdfContent, $pageSlug . '.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +49,7 @@ class PageExportController extends Controller
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$containedHtml = $this->exportFormatter->pageToContainedHtml($page);
|
||||
|
||||
return $this->download()->directly($containedHtml, $pageSlug . '.html');
|
||||
return $this->createDownload()->directly($containedHtml, $pageSlug . '.html');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +62,7 @@ class PageExportController extends Controller
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$pageText = $this->exportFormatter->pageToPlainText($page);
|
||||
|
||||
return $this->download()->directly($pageText, $pageSlug . '.txt');
|
||||
return $this->createDownload()->directly($pageText, $pageSlug . '.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +75,7 @@ class PageExportController extends Controller
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$pageText = $this->exportFormatter->pageToMarkdown($page);
|
||||
|
||||
return $this->download()->directly($pageText, $pageSlug . '.md');
|
||||
return $this->createDownload()->directly($pageText, $pageSlug . '.md');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +87,6 @@ class PageExportController extends Controller
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$zip = $builder->buildForPage($page);
|
||||
|
||||
return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', true);
|
||||
return $this->createDownload()->streamedFileDirectly($zip, $pageSlug . '.zip', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace BookStack\Exports;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
@@ -119,6 +121,9 @@ class ImportRepo
|
||||
$parentModel = null;
|
||||
if ($import->type === 'page' || $import->type === 'chapter') {
|
||||
$parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null;
|
||||
if ($parentModel && !($parentModel instanceof Book || $parentModel instanceof Chapter)) {
|
||||
throw new ZipImportException(['Selected parent is not a book or chapter']);
|
||||
}
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
@@ -87,7 +87,7 @@ final class ZipExportBook extends ZipExportModel
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('book')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'description_html' => ['nullable', 'string'],
|
||||
'cover' => ['nullable', 'string', $context->fileReferenceRule()],
|
||||
'cover' => ['nullable', 'string', $context->imageFileReferenceRule()],
|
||||
'tags' => ['array'],
|
||||
'pages' => ['array'],
|
||||
'chapters' => ['array'],
|
||||
|
||||
@@ -32,11 +32,10 @@ final class ZipExportImage extends ZipExportModel
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$acceptedImageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('image')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'file' => ['required', 'string', $context->fileReferenceRule($acceptedImageTypes)],
|
||||
'file' => ['required', 'string', $context->imageFileReferenceRule()],
|
||||
'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])],
|
||||
];
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ class ZipExportFiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract each of the ZIP export tracked files.
|
||||
* Extract each of the ZIP export-tracked files.
|
||||
* Calls the given callback for each tracked file, passing a temporary
|
||||
* file reference of the file contents, and the zip-local tracked reference.
|
||||
*/
|
||||
|
||||
@@ -48,7 +48,7 @@ class ZipImportRunner
|
||||
* Returns the top-level entity item which was imported.
|
||||
* @throws ZipImportException
|
||||
*/
|
||||
public function run(Import $import, ?Entity $parent = null): Entity
|
||||
public function run(Import $import, Book|Chapter|null $parent = null): Entity
|
||||
{
|
||||
$zipPath = $this->getZipPath($import);
|
||||
$reader = new ZipExportReader($zipPath);
|
||||
@@ -96,7 +96,7 @@ class ZipImportRunner
|
||||
|
||||
/**
|
||||
* Revert any files which have been stored during this import process.
|
||||
* Considers files only, and avoids the database under the
|
||||
* Considers files only and avoids the database under the
|
||||
* assumption that the database may already have been
|
||||
* reverted as part of a transaction rollback.
|
||||
*/
|
||||
@@ -129,7 +129,7 @@ class ZipImportRunner
|
||||
$book = $this->bookRepo->create([
|
||||
'name' => $exportBook->name,
|
||||
'description_html' => $exportBook->description_html ?? '',
|
||||
'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null,
|
||||
'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader, true) : null,
|
||||
'tags' => $this->exportTagsToInputArray($exportBook->tags),
|
||||
]);
|
||||
|
||||
@@ -227,18 +227,11 @@ class ZipImportRunner
|
||||
|
||||
protected function importImage(ZipExportImage $exportImage, Page $page, ZipExportReader $reader): Image
|
||||
{
|
||||
$mime = $reader->sniffFileMime($exportImage->file);
|
||||
$extension = explode('/', $mime)[1];
|
||||
|
||||
$file = $this->zipFileToUploadedFile($exportImage->file, $reader);
|
||||
$file = $this->zipFileToUploadedFile($exportImage->file, $reader, true);
|
||||
$image = $this->imageService->saveNewFromUpload(
|
||||
$file,
|
||||
$exportImage->type,
|
||||
$page->id,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
$exportImage->name . '.' . $extension,
|
||||
);
|
||||
|
||||
$image->name = $exportImage->name;
|
||||
@@ -261,7 +254,7 @@ class ZipImportRunner
|
||||
return $tags;
|
||||
}
|
||||
|
||||
protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile
|
||||
protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader, bool $forceExtensionFromMime = false): UploadedFile
|
||||
{
|
||||
if (!$reader->fileWithinSizeLimit($fileName)) {
|
||||
throw new ZipImportException([
|
||||
@@ -277,7 +270,16 @@ class ZipImportRunner
|
||||
|
||||
$this->tempFilesToCleanup[] = $tempPath;
|
||||
|
||||
return new UploadedFile($tempPath, $fileName);
|
||||
$intendedUploadName = $fileName;
|
||||
if ($forceExtensionFromMime) {
|
||||
$mime = $reader->sniffFileMime($fileName);
|
||||
$extension = explode('/', $mime)[1];
|
||||
if (!str_ends_with(strtolower($intendedUploadName), '.' . $extension)) {
|
||||
$intendedUploadName .= '.' . $extension;
|
||||
}
|
||||
}
|
||||
|
||||
return new UploadedFile($tempPath, $intendedUploadName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportModel;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Validation\Factory;
|
||||
|
||||
class ZipValidationHelper
|
||||
@@ -38,6 +39,11 @@ class ZipValidationHelper
|
||||
return new ZipFileReferenceRule($this, $acceptedMimes);
|
||||
}
|
||||
|
||||
public function imageFileReferenceRule(): ZipFileReferenceRule
|
||||
{
|
||||
return new ZipFileReferenceRule($this, ImageService::getSupportedMimeTypes());
|
||||
}
|
||||
|
||||
public function uniqueIdRule(string $type): ZipUniqueIdRule
|
||||
{
|
||||
return new ZipUniqueIdRule($this, $type);
|
||||
|
||||
@@ -7,6 +7,7 @@ use BookStack\App\Model;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Users\Models\OwnableInterface;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -80,7 +81,7 @@ abstract class Controller extends BaseController
|
||||
/**
|
||||
* Check the current user's permissions against an ownable item otherwise throw an exception.
|
||||
*/
|
||||
protected function checkOwnablePermission(string|Permission $permission, Model $ownable, string $redirectLocation = '/'): void
|
||||
protected function checkOwnablePermission(string|Permission $permission, Model&OwnableInterface $ownable, string $redirectLocation = '/'): void
|
||||
{
|
||||
if (!userCan($permission, $ownable)) {
|
||||
$this->showPermissionError($redirectLocation);
|
||||
@@ -120,7 +121,7 @@ abstract class Controller extends BaseController
|
||||
/**
|
||||
* Create and return a new download response factory using the current request.
|
||||
*/
|
||||
protected function download(): DownloadResponseFactory
|
||||
protected function createDownload(): DownloadResponseFactory
|
||||
{
|
||||
return new DownloadResponseFactory(request());
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class Kernel extends HttpKernel
|
||||
\BookStack\Http\Middleware\CheckEmailConfirmed::class,
|
||||
\BookStack\Http\Middleware\RunThemeActions::class,
|
||||
\BookStack\Http\Middleware\Localization::class,
|
||||
\BookStack\Http\Middleware\ClearPerRequestCaches::class,
|
||||
],
|
||||
'api' => [
|
||||
\BookStack\Http\Middleware\ThrottleApiRequests::class,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Middleware;
|
||||
|
||||
use BookStack\View\ViewBlockManager;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Custom middleware to clear any local caches in the app which are created on a
|
||||
* per-request basis. While this can be somewhat redundant in the normal PHP request
|
||||
* lifecycle (since in memory caches are cleared on each request), this can be useful
|
||||
* in testing and to prepare for future long-serving PHP runtimes.
|
||||
*/
|
||||
class ClearPerRequestCaches
|
||||
{
|
||||
public function __construct(
|
||||
protected ViewBlockManager $viewBlockManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$this->clearCaches();
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
protected function clearCaches(): void
|
||||
{
|
||||
$this->viewBlockManager->clearLocalCache();
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,6 @@ class TrimStrings extends Middleware
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'password-confirm',
|
||||
'password-current',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -70,7 +70,11 @@ class JointPermissionBuilder
|
||||
}
|
||||
|
||||
if ($entity instanceof Chapter) {
|
||||
foreach ($entity->pages as $page) {
|
||||
$childPages = $entity->pages()
|
||||
->withTrashed()
|
||||
->select(['id', 'owned_by', 'book_id', 'chapter_id'])
|
||||
->get();
|
||||
foreach ($childPages as $page) {
|
||||
$entities[] = $page;
|
||||
}
|
||||
}
|
||||
@@ -101,6 +105,7 @@ class JointPermissionBuilder
|
||||
|
||||
/**
|
||||
* Get a query for fetching a book with its children.
|
||||
* @return Builder<Book>
|
||||
*/
|
||||
protected function bookFetchQuery(): Builder
|
||||
{
|
||||
@@ -117,9 +122,11 @@ class JointPermissionBuilder
|
||||
|
||||
/**
|
||||
* Build joint permissions for the given book and role combinations.
|
||||
* @param EloquentCollection<int, Book> $books
|
||||
*/
|
||||
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false): void
|
||||
{
|
||||
/** @var EloquentCollection<int, Entity> $entities */
|
||||
$entities = clone $books;
|
||||
|
||||
/** @var Book $book */
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace BookStack\References;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class ReferenceFetcher
|
||||
*/
|
||||
public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection
|
||||
{
|
||||
/** @var Collection<int, Reference> $references */
|
||||
$references = $this->queryReferencesToEntity($entity)->get();
|
||||
$this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents);
|
||||
|
||||
@@ -37,6 +38,9 @@ class ReferenceFetcher
|
||||
return $this->queryReferencesToEntity($entity)->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Reference>
|
||||
*/
|
||||
protected function queryReferencesToEntity(Entity $entity): Builder
|
||||
{
|
||||
$baseQuery = Reference::query()
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace BookStack\Search;
|
||||
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\EntityTable;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\EntityHydrator;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
@@ -98,6 +99,8 @@ class SearchRunner
|
||||
|
||||
/**
|
||||
* Get a page of result data from the given query based on the provided page parameters.
|
||||
* @param EloquentBuilder<EntityTable> $query
|
||||
* @return Collection<Entity>
|
||||
*/
|
||||
protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int $count): Collection
|
||||
{
|
||||
@@ -114,6 +117,7 @@ class SearchRunner
|
||||
/**
|
||||
* Create a search query for an entity.
|
||||
* @param string[] $entityTypes
|
||||
* @return EloquentBuilder<EntityTable>
|
||||
*/
|
||||
protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): EloquentBuilder
|
||||
{
|
||||
@@ -298,7 +302,7 @@ class SearchRunner
|
||||
$query->where('name', '=', $tagParts['name']);
|
||||
}
|
||||
|
||||
if (is_numeric($tagParts['value']) && is_finite($tagParts['value']) && $tagParts['operator'] !== 'like') {
|
||||
if (is_numeric($tagParts['value']) && is_finite(floatval($tagParts['value'])) && $tagParts['operator'] !== 'like') {
|
||||
// We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
|
||||
// search the value as a string which prevents being able to do number-based operations
|
||||
// on the tag values. We ensure it has a numeric value and then cast it just to be sure.
|
||||
|
||||
@@ -243,9 +243,9 @@ class SettingService
|
||||
/**
|
||||
* Convert a setting key into a user-specific key.
|
||||
*/
|
||||
protected function userKey(string $userId, string $key = ''): string
|
||||
protected function userKey(int $userId, string $key = ''): string
|
||||
{
|
||||
return 'user:' . $userId . ':' . $key;
|
||||
return 'user:' . strval($userId) . ':' . $key;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,10 +264,18 @@ class SettingService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user-specific setting from the database, for the current access user.
|
||||
*/
|
||||
public function removeForCurrentUser(string $key): void
|
||||
{
|
||||
$this->remove($this->userKey(user()->id, $key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete settings for a given user id.
|
||||
*/
|
||||
public function deleteUserSettings(string $userId): void
|
||||
public function deleteUserSettings(int $userId): void
|
||||
{
|
||||
Setting::query()
|
||||
->where('setting_key', 'like', $this->userKey($userId) . '%')
|
||||
|
||||
@@ -38,21 +38,35 @@ class SortRule extends Model implements Loggable
|
||||
$this->sequence = implode(',', $values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL to where this rule can be managed.
|
||||
*/
|
||||
public function getUrl(): string
|
||||
{
|
||||
return url("/settings/sorting/rules/{$this->id}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the books which are specifically set to use this sort rule.
|
||||
* @return HasMany<Book, $this>
|
||||
*/
|
||||
public function books(): HasMany
|
||||
{
|
||||
return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the available sort rules, ordered by name, with the number of books using each.
|
||||
* @return Collection<SortRule>
|
||||
*/
|
||||
public static function allByName(): Collection
|
||||
{
|
||||
return static::query()
|
||||
|
||||
@@ -24,7 +24,7 @@ class ThemeController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$response = $this->download()->streamedFileInline($filePath);
|
||||
$response = $this->createDownload()->streamedFileInline($filePath);
|
||||
$response->setMaxAge(86400);
|
||||
|
||||
return $response;
|
||||
|
||||
@@ -180,6 +180,15 @@ class ThemeEvents
|
||||
*/
|
||||
const THEME_REGISTER_VIEWS = 'theme_register_views';
|
||||
|
||||
/**
|
||||
* View blocks register event.
|
||||
* Runs once a ViewBlockManager instance is available so that custom blocks can be registered
|
||||
* for use within user-configurable layouts in the system.
|
||||
*
|
||||
* @param \BookStack\View\ViewBlockManager $manager
|
||||
*/
|
||||
const VIEW_BLOCKS_REGISTER = 'view_blocks_register';
|
||||
|
||||
/**
|
||||
* Web before middleware action.
|
||||
* Runs before the request is handled but after all other middleware apart from those
|
||||
|
||||
@@ -102,7 +102,7 @@ class AttachmentService
|
||||
/**
|
||||
* Updates the ordering for a listing of attached files.
|
||||
*/
|
||||
public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId)
|
||||
public function updateFileOrderWithinPage(array $attachmentOrder, int $pageId)
|
||||
{
|
||||
foreach ($attachmentOrder as $index => $attachmentId) {
|
||||
Attachment::query()->where('uploaded_to', '=', $pageId)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Util\WebSafeMimeSniffer;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class Base64UriMimeRule implements ValidationRule
|
||||
{
|
||||
public function __construct(
|
||||
protected string $requiredMime
|
||||
) {
|
||||
if (empty($requiredMime)) {
|
||||
throw new InvalidArgumentException('A required mime type must be provided');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$imageDataEncoded = explode(',', $value, 2)[1] ?? '';
|
||||
if (empty($imageDataEncoded)) {
|
||||
$fail('validation.base64_uri_mime')->translate(['mime' => $this->requiredMime]);
|
||||
return;
|
||||
}
|
||||
|
||||
$imageData = base64_decode($imageDataEncoded);
|
||||
if (empty($imageData)) {
|
||||
$fail('validation.base64_uri_mime')->translate(['mime' => $this->requiredMime]);
|
||||
return;
|
||||
}
|
||||
|
||||
$sniffer = new WebSafeMimeSniffer();
|
||||
$mime = $sniffer->sniff($imageData);
|
||||
|
||||
if ($mime !== $this->requiredMime) {
|
||||
$fail('validation.base64_uri_mime')->translate(['mime' => $this->requiredMime]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class AttachmentApiController extends ApiController
|
||||
|
||||
/**
|
||||
* Get a listing of attachments visible to the user.
|
||||
* The external property indicates whether the attachment is simple a link.
|
||||
* The external property indicates whether the attachment is simply a link.
|
||||
* A false value for the external property would indicate a file upload.
|
||||
*/
|
||||
public function list()
|
||||
@@ -39,7 +39,7 @@ class AttachmentApiController extends ApiController
|
||||
* An uploaded_to value must be provided containing an ID of the page
|
||||
* that this upload will be related to.
|
||||
*
|
||||
* If you're uploading a file the POST data should be provided via
|
||||
* If you're uploading a file, the POST data should be provided via
|
||||
* a multipart/form-data type request instead of JSON.
|
||||
*
|
||||
* @throws ValidationException
|
||||
@@ -71,7 +71,7 @@ class AttachmentApiController extends ApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details & content of a single attachment of the given ID.
|
||||
* Get the details and content of a single attachment of the given ID.
|
||||
* The attachment link or file content is provided via a 'content' property.
|
||||
* For files the content will be base64 encoded.
|
||||
*
|
||||
@@ -120,7 +120,7 @@ class AttachmentApiController extends ApiController
|
||||
|
||||
/**
|
||||
* Update the details of a single attachment.
|
||||
* As per the create endpoint, if a file is being provided as the attachment content
|
||||
* As per the create endpoint, if a file is being provided as the attachment content,
|
||||
* the request should be formatted as a multipart/form-data request instead of JSON.
|
||||
*
|
||||
* @throws ValidationException
|
||||
@@ -134,9 +134,12 @@ class AttachmentApiController extends ApiController
|
||||
|
||||
$page = $attachment->page;
|
||||
if ($requestData['uploaded_to'] ?? false) {
|
||||
$pageId = $request->input('uploaded_to');
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
|
||||
$attachment->uploaded_to = $requestData['uploaded_to'];
|
||||
$pageId = intval($requestData['uploaded_to']);
|
||||
if ($pageId !== $page->id) {
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
|
||||
$attachment->uploaded_to = $pageId;
|
||||
}
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission(Permission::PageView, $page);
|
||||
|
||||
@@ -232,10 +232,10 @@ class AttachmentController extends Controller
|
||||
$attachmentSize = $this->attachmentService->getAttachmentFileSize($attachment);
|
||||
|
||||
if ($request->input('open') === 'true') {
|
||||
return $this->download()->streamedInline($attachmentStream, $fileName, $attachmentSize);
|
||||
return $this->createDownload()->streamedInline($attachmentStream, $fileName, $attachmentSize);
|
||||
}
|
||||
|
||||
return $this->download()->streamedDirectly($attachmentStream, $fileName, $attachmentSize);
|
||||
return $this->createDownload()->streamedDirectly($attachmentStream, $fileName, $attachmentSize);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Uploads\Base64UriMimeRule;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageResizer;
|
||||
use BookStack\Util\OutOfMemoryHandler;
|
||||
@@ -57,7 +58,7 @@ class DrawioImageController extends Controller
|
||||
{
|
||||
$this->checkPermission(Permission::ImageCreateAll);
|
||||
$validated = $this->validate($request, [
|
||||
'image' => ['required', 'string'],
|
||||
'image' => ['required', 'string', new Base64UriMimeRule('image/png')],
|
||||
'uploaded_to' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ class ImageController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Check related page permission and ensure type is drawio or gallery.
|
||||
* Check related page permission and ensure the type is drawio or gallery.
|
||||
* @throws NotifyException
|
||||
*/
|
||||
protected function checkImagePermission(Image $image): void
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\PrettyException;
|
||||
use BookStack\Http\DownloadResponseFactory;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -34,9 +36,8 @@ class ImageService
|
||||
?int $resizeWidth = null,
|
||||
?int $resizeHeight = null,
|
||||
bool $keepRatio = true,
|
||||
string $imageName = '',
|
||||
): Image {
|
||||
$imageName = $imageName ?: $uploadedFile->getClientOriginalName();
|
||||
$imageName = $uploadedFile->getClientOriginalName();
|
||||
$imageData = file_get_contents($uploadedFile->getRealPath());
|
||||
|
||||
if ($resizeWidth !== null || $resizeHeight !== null) {
|
||||
@@ -368,7 +369,18 @@ class ImageService
|
||||
{
|
||||
$disk = $this->storage->getDisk($imageType);
|
||||
|
||||
return $disk->response($path);
|
||||
$stream = $disk->stream($path);
|
||||
$fileSize = $disk->size($path);
|
||||
$imageName = basename($path);
|
||||
$downloadResponseFactory = new DownloadResponseFactory(request());
|
||||
$response = $downloadResponseFactory->streamedInline($stream, $imageName, $fileSize);
|
||||
|
||||
$contentType = $response->headers->get('Content-Type');
|
||||
if (!str_starts_with($contentType, 'image/')) {
|
||||
throw new PrettyException('Invalid non-image file type when streaming from storage', 415);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,6 +390,14 @@ class ImageService
|
||||
*/
|
||||
public static function isExtensionSupported(string $extension): bool
|
||||
{
|
||||
return in_array($extension, static::$supportedExtensions);
|
||||
return in_array(strtolower($extension), static::$supportedExtensions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mime-types for images formats which BookStack supports.
|
||||
*/
|
||||
public static function getSupportedMimeTypes(): array
|
||||
{
|
||||
return array_map(fn($ext) => 'image/' . $ext, static::$supportedExtensions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -45,6 +46,7 @@ class ImageStorage
|
||||
|
||||
/**
|
||||
* Clean up an image file name to be both URL and storage safe.
|
||||
* @throws ImageUploadException
|
||||
*/
|
||||
public function cleanImageFileName(string $name): string
|
||||
{
|
||||
@@ -54,6 +56,10 @@ class ImageStorage
|
||||
$name = implode('-', $nameParts);
|
||||
$name = Str::slug($name);
|
||||
|
||||
if (!ImageService::isExtensionSupported($extension)) {
|
||||
throw new ImageUploadException('Non supported image extension used when storing image: ' . $extension);
|
||||
}
|
||||
|
||||
if (strlen($name) === 0) {
|
||||
$name = Str::random(10);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,14 @@ class ImageStorageDisk
|
||||
return $this->filesystem->readStream($this->adjustPathForDisk($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of the file at the given path.
|
||||
*/
|
||||
public function size(string $path): int
|
||||
{
|
||||
return $this->filesystem->size($this->adjustPathForDisk($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the given image data at the given path. Can choose to set
|
||||
* the image as public which will update its visibility after saving.
|
||||
|
||||
@@ -87,7 +87,7 @@ class RoleApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$role = $this->permissionsRepo->getRoleById($id);
|
||||
$role = $this->permissionsRepo->getRoleById(intval($id));
|
||||
$this->singleFormatter($role);
|
||||
|
||||
return response()->json($role);
|
||||
|
||||
@@ -94,7 +94,7 @@ class RoleController extends Controller
|
||||
public function edit(string $id)
|
||||
{
|
||||
$this->checkPermission(Permission::UserRolesManage);
|
||||
$role = $this->permissionsRepo->getRoleById($id);
|
||||
$role = $this->permissionsRepo->getRoleById(intval($id));
|
||||
|
||||
$this->setPageTitle(trans('settings.role_edit'));
|
||||
|
||||
@@ -129,7 +129,7 @@ class RoleController extends Controller
|
||||
public function showDelete(string $id)
|
||||
{
|
||||
$this->checkPermission(Permission::UserRolesManage);
|
||||
$role = $this->permissionsRepo->getRoleById($id);
|
||||
$role = $this->permissionsRepo->getRoleById(intval($id));
|
||||
$roles = $this->permissionsRepo->getAllRolesExcept($role);
|
||||
$blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]);
|
||||
$roles->prepend($blankRole);
|
||||
@@ -151,7 +151,7 @@ class RoleController extends Controller
|
||||
|
||||
try {
|
||||
$migrateRoleId = intval($request->input('migrate_role_id') ?: "0");
|
||||
$this->permissionsRepo->deleteRole($id, $migrateRoleId);
|
||||
$this->permissionsRepo->deleteRole(intval($id), $migrateRoleId);
|
||||
} catch (PermissionsException $e) {
|
||||
$this->showErrorNotification($e->getMessage());
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ use BookStack\Settings\UserNotificationPreferences;
|
||||
use BookStack\Settings\UserShortcutMap;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Users\UserRepo;
|
||||
use BookStack\View\ViewBlockManager;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class UserAccountController extends Controller
|
||||
@@ -159,6 +161,37 @@ class UserAccountController extends Controller
|
||||
return redirect('/my-account/notifications');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view for the "Interface Preferences" user account area.
|
||||
*/
|
||||
public function showInterface(ViewBlockManager $viewBlockManager)
|
||||
{
|
||||
$this->setPageTitle(trans('preferences.interface'));
|
||||
|
||||
return view('users.account.interface', [
|
||||
'category' => 'interface',
|
||||
'namedLocations' => $viewBlockManager->getNamedLocations(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the submission of the interface preferences form.
|
||||
*/
|
||||
public function updateInterface(Request $request)
|
||||
{
|
||||
$this->preventAccessInDemoMode();
|
||||
|
||||
$user = user();
|
||||
$validated = $this->validate($request, [
|
||||
'language' => ['string', 'max:15', 'alpha_dash'],
|
||||
'display_mode' => ['string', 'max:15', 'alpha_dash'],
|
||||
]);
|
||||
|
||||
$this->userRepo->update($user, $validated, userCan(Permission::UsersManage));
|
||||
|
||||
return redirect('/my-account/interface');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view for the "Access & Security" account options.
|
||||
*/
|
||||
@@ -188,8 +221,9 @@ class UserAccountController extends Controller
|
||||
}
|
||||
|
||||
$validated = $this->validate($request, [
|
||||
'password' => ['required_with:password_confirm', Password::default()],
|
||||
'password-confirm' => ['same:password', 'required_with:password'],
|
||||
'password' => ['required', Password::default()],
|
||||
'password-confirm' => ['required', 'same:password'],
|
||||
'password-current' => ['required', 'current_password:standard'],
|
||||
]);
|
||||
|
||||
$this->userRepo->update(user(), $validated, false);
|
||||
|
||||
@@ -110,7 +110,7 @@ class UserApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$user = $this->userRepo->getById($id);
|
||||
$user = $this->userRepo->getById(intval($id));
|
||||
$this->singleFormatter($user);
|
||||
|
||||
return response()->json($user);
|
||||
@@ -124,8 +124,8 @@ class UserApiController extends ApiController
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$data = $this->validate($request, $this->rules($id)['update']);
|
||||
$user = $this->userRepo->getById($id);
|
||||
$data = $this->validate($request, $this->rules(intval($id))['update']);
|
||||
$user = $this->userRepo->getById(intval($id));
|
||||
$this->userRepo->update($user, $data, userCan(Permission::UsersManage));
|
||||
$this->singleFormatter($user);
|
||||
|
||||
@@ -140,7 +140,7 @@ class UserApiController extends ApiController
|
||||
*/
|
||||
public function delete(Request $request, string $id)
|
||||
{
|
||||
$user = $this->userRepo->getById($id);
|
||||
$user = $this->userRepo->getById(intval($id));
|
||||
$newOwnerId = $request->input('migrate_ownership_id', null);
|
||||
|
||||
$this->userRepo->destroy($user, $newOwnerId);
|
||||
|
||||
@@ -122,7 +122,7 @@ class UserRepo
|
||||
/**
|
||||
* Update the given user with the given data, but do not create an activity.
|
||||
*
|
||||
* @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string} $data
|
||||
* @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string, display_mode: ?string} $data
|
||||
*
|
||||
* @throws UserUpdateException
|
||||
*/
|
||||
@@ -153,6 +153,11 @@ class UserRepo
|
||||
setting()->putUser($user, 'language', $data['language']);
|
||||
}
|
||||
|
||||
if (!empty($data['display_mode'])) {
|
||||
$value = $data['display_mode'] === 'dark' ? 'true' : 'false';
|
||||
setting()->putUser($user, 'dark-mode-enabled', $value);
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return $user;
|
||||
@@ -161,7 +166,7 @@ class UserRepo
|
||||
/**
|
||||
* Update the given user with the given data.
|
||||
*
|
||||
* @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string} $data
|
||||
* @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string, display_mode: ?string} $data
|
||||
*
|
||||
* @throws UserUpdateException
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Util;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
@@ -24,7 +25,7 @@ use Throwable;
|
||||
class DatabaseTransaction
|
||||
{
|
||||
/**
|
||||
* @param (Closure(static): TReturn) $callback
|
||||
* @param (Closure(Connection): TReturn) $callback
|
||||
*/
|
||||
public function __construct(
|
||||
protected Closure $callback
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Util\HtmlPurifier;
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\Util\HtmlPurifier\Filters\UriEnsureScheme;
|
||||
use BookStack\Util\HtmlPurifier\Filters\UriLimitFileProtocolToAnchors;
|
||||
use BookStack\Util\HtmlPurifier\Filters\UriMakeAbsolute;
|
||||
use BookStack\Util\UrlFilter;
|
||||
use HTMLPurifier;
|
||||
use HTMLPurifier_Config;
|
||||
@@ -12,6 +13,7 @@ use HTMLPurifier_DefinitionCache_Serializer;
|
||||
use HTMLPurifier_HTML5Config;
|
||||
use HTMLPurifier_HTMLDefinition;
|
||||
use HTMLPurifier_URIDefinition;
|
||||
use HTMLPurifier_URISchemeRegistry;
|
||||
|
||||
/**
|
||||
* Provides a configured HTML Purifier instance.
|
||||
@@ -40,6 +42,9 @@ class ConfiguredHtmlPurifier
|
||||
$this->configureHtmlDefinition($htmlDef);
|
||||
}
|
||||
|
||||
$registry = HTMLPurifier_URISchemeRegistry::instance();
|
||||
$registry->register('sftp', new SftpUriScheme());
|
||||
|
||||
$uriDef = $config->getDefinition('URI', true, true);
|
||||
if ($uriDef instanceof HTMLPurifier_URIDefinition) {
|
||||
$this->configureUriDefinition($uriDef);
|
||||
@@ -94,7 +99,7 @@ class ConfiguredHtmlPurifier
|
||||
$defaultScheme = str_starts_with(url('/'), 'http:') ? 'http' : 'https';
|
||||
$config->set('URI.SafeIframeRegexp', '%^(http://|https://|//)%');
|
||||
$config->set('URI.AllowedSchemes', $allowedSchemesSetting);
|
||||
$config->set('URI.MakeAbsolute', true);
|
||||
$config->set('URI.MakeAbsolute', false); // We register our own MakeAbsolute filter below
|
||||
$config->set('URI.DefaultScheme', $defaultScheme);
|
||||
$config->set('URI.Base', url('/'));
|
||||
|
||||
@@ -160,6 +165,10 @@ class ConfiguredHtmlPurifier
|
||||
// Allow mention-ids on links
|
||||
$definition->addAttribute('a', 'data-mention-user-id', 'Number');
|
||||
|
||||
// Allow 'align' attribute on table cells
|
||||
$definition->addAttribute('td', 'align', 'Enum#left,center,right');
|
||||
$definition->addAttribute('th', 'align', 'Enum#left,center,right');
|
||||
|
||||
// Set up custom handler for srcset
|
||||
// To remove once added upstream: https://github.com/xemlock/htmlpurifier-html5/pull/91
|
||||
$definition->addAttribute('img', 'srcset', new SrcsetAttrDef());
|
||||
@@ -170,6 +179,7 @@ class ConfiguredHtmlPurifier
|
||||
{
|
||||
$definition->registerFilter(new UriLimitFileProtocolToAnchors());
|
||||
$definition->registerFilter(new UriEnsureScheme());
|
||||
$definition->registerFilter(new UriMakeAbsolute());
|
||||
}
|
||||
|
||||
public function purify(string $html): string
|
||||
|
||||
@@ -31,7 +31,9 @@ class UriEnsureScheme extends HTMLPurifier_URIFilter
|
||||
$defaultScheme = $def->defaultScheme ?? '';
|
||||
|
||||
if (empty($uri->scheme) && $defaultScheme) {
|
||||
$uri->scheme = $defaultScheme;
|
||||
if (!str_starts_with($uri->toString(), '#')) {
|
||||
$uri->scheme = $defaultScheme;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util\HtmlPurifier\Filters;
|
||||
|
||||
use HTMLPurifier_Config;
|
||||
use HTMLPurifier_Context;
|
||||
use HTMLPurifier_URI;
|
||||
use HTMLPurifier_URIFilter_MakeAbsolute;
|
||||
|
||||
/**
|
||||
* A custom version of HTMLPurifier's MakeAbsolute filter which leaves fragment-based
|
||||
* anchors alone without adding a base.
|
||||
*/
|
||||
class UriMakeAbsolute extends HTMLPurifier_URIFilter_MakeAbsolute
|
||||
{
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
public $name = 'CustomMakeAbsolute';
|
||||
|
||||
/**
|
||||
* @type bool
|
||||
*/
|
||||
public $always_load = true;
|
||||
|
||||
/**
|
||||
* @param HTMLPurifier_URI $uri
|
||||
* @param HTMLPurifier_Config $config
|
||||
* @param HTMLPurifier_Context $context
|
||||
*/
|
||||
public function filter(&$uri, $config, $context): bool
|
||||
{
|
||||
if (str_starts_with($uri->toString(), '#')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return parent::filter($uri, $config, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util\HtmlPurifier;
|
||||
|
||||
use HTMLPurifier_URIScheme_ftp;
|
||||
|
||||
class SftpUriScheme extends HTMLPurifier_URIScheme_ftp
|
||||
{
|
||||
public $default_port = 22;
|
||||
}
|
||||
@@ -5,12 +5,12 @@ declare(strict_types=1);
|
||||
namespace BookStack\Util;
|
||||
|
||||
/**
|
||||
* Helps filter URLs to prevent use of undesired schemes.
|
||||
* Helps filter URLs to prevent the use of undesired schemes.
|
||||
* Also parses and rebuilds the URL to ensure it's valid.
|
||||
*/
|
||||
class UrlFilter
|
||||
{
|
||||
protected static array $allowedSchemes = ['http', 'https', 'mailto', 'tel', 'file', 'ftp', 'nntp', 'news'];
|
||||
protected static array $allowedSchemes = ['http', 'https', 'mailto', 'tel', 'file', 'sftp', 'ftp', 'nntp', 'news'];
|
||||
protected string $url;
|
||||
|
||||
public function __construct(string $url)
|
||||
@@ -20,7 +20,7 @@ class UrlFilter
|
||||
|
||||
/**
|
||||
* Check if the URL is allowed to be generally used as a link
|
||||
* in the application. This does not assure the original URL string
|
||||
* in the application. This does not ensure the original URL string
|
||||
* provided is safe as-is. Ensure you use the clean method to produce
|
||||
* a URL that is considered safe to use.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View;
|
||||
|
||||
use BookStack\Http\Controller;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LayoutController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ViewBlockManager $viewBlocks,
|
||||
protected ViewBlockPreferences $viewBlockPreferences,
|
||||
) {
|
||||
$this->middleware(function (Request $request, Closure $next) {
|
||||
$this->preventGuestAccess();
|
||||
return $next($request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing the layout for a specific location.
|
||||
*/
|
||||
public function edit(string $location)
|
||||
{
|
||||
$namedLocations = $this->viewBlocks->getNamedLocations();
|
||||
$locationName = $namedLocations[$location] ?? $location;
|
||||
$blocks = $this->viewBlocks->getForLocationForCurrentUser($location);
|
||||
|
||||
$this->setPageTitle(trans('preferences.layout_edit'));
|
||||
|
||||
return view('settings.layouts.edit', [
|
||||
'location' => $location,
|
||||
'locationName' => $locationName,
|
||||
'namedLocations' => $namedLocations,
|
||||
'blocks' => $blocks,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the layout for a specific location for the current user.
|
||||
*/
|
||||
public function update(string $location, Request $request)
|
||||
{
|
||||
$data = $this->validate($request, [
|
||||
'layout' => ['required', 'string', 'json'],
|
||||
]);
|
||||
|
||||
$layoutData = json_decode($data['layout'], true, 5);
|
||||
if (is_array($layoutData)) {
|
||||
$this->viewBlocks->updatePreferencesFromIdPositionMap($location, $layoutData);
|
||||
}
|
||||
|
||||
$this->showSuccessNotification(trans('preferences.layout_update_success'));
|
||||
|
||||
return redirect("/layouts/{$location}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the layout for a specific location, for the current user,
|
||||
* back to system defaults by removing any user-specific preferences.
|
||||
*/
|
||||
public function reset(string $location)
|
||||
{
|
||||
$this->viewBlockPreferences->clearForLocation($location);
|
||||
|
||||
$this->showSuccessNotification(trans('preferences.layout_reset_success'));
|
||||
|
||||
return redirect("/layouts/{$location}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View;
|
||||
|
||||
abstract class ViewBlock implements ViewBlockInterface
|
||||
{
|
||||
protected static string $id;
|
||||
protected static string $view;
|
||||
protected static string $labelTranslationKey;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public static function getId(): string
|
||||
{
|
||||
return static::$id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public static function getLabel(): string
|
||||
{
|
||||
return trans(static::$labelTranslationKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getView(array $viewData): string
|
||||
{
|
||||
return static::$view;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View;
|
||||
|
||||
class ViewBlockDefaults
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<string, class-string<ViewBlockInterface>[]>>
|
||||
*/
|
||||
protected static array $defaults = [
|
||||
'home-default' => [
|
||||
'left' => [
|
||||
ViewBlocks\HomeRecentDrafts::class,
|
||||
ViewBlocks\HomeRecentlyViewedOrRecentBooks::class,
|
||||
],
|
||||
'center' => [
|
||||
ViewBlocks\HomeTopFavourites::class,
|
||||
ViewBlocks\HomeRecentlyUpdatedPages::class,
|
||||
],
|
||||
'right' => [
|
||||
ViewBlocks\HomeRecentActivity::class,
|
||||
],
|
||||
],
|
||||
'home-non-default' => [
|
||||
'left' => [
|
||||
ViewBlocks\HomeRecentDrafts::class,
|
||||
ViewBlocks\HomeTopFavourites::class,
|
||||
ViewBlocks\HomeRecentlyViewedOrRecentBooks::class,
|
||||
ViewBlocks\HomeRecentlyUpdatedPages::class,
|
||||
ViewBlocks\HomeRecentActivity::class,
|
||||
],
|
||||
'right' => [
|
||||
ViewBlocks\HomeActions::class,
|
||||
],
|
||||
],
|
||||
'shelves-index' => [
|
||||
'left' => [
|
||||
ViewBlocks\ShelvesIndexRecents::class,
|
||||
ViewBlocks\ShelvesIndexPopular::class,
|
||||
ViewBlocks\ShelvesIndexNew::class,
|
||||
],
|
||||
'right' => [
|
||||
ViewBlocks\ShelvesIndexActions::class,
|
||||
],
|
||||
],
|
||||
'shelves-show' => [
|
||||
'left' => [
|
||||
ViewBlocks\ShelvesShowTags::class,
|
||||
ViewBlocks\ShelvesShowDetails::class,
|
||||
ViewBlocks\ShelvesShowActivity::class,
|
||||
],
|
||||
'right' => [
|
||||
ViewBlocks\ShelvesShowActions::class,
|
||||
],
|
||||
],
|
||||
'books-index' => [
|
||||
'left' => [
|
||||
ViewBlocks\BooksIndexRecents::class,
|
||||
ViewBlocks\BooksIndexPopular::class,
|
||||
ViewBlocks\BooksIndexNew::class,
|
||||
],
|
||||
'right' => [
|
||||
ViewBlocks\BooksIndexActions::class,
|
||||
],
|
||||
],
|
||||
'books-show' => [
|
||||
'left' => [
|
||||
ViewBlocks\BooksShowSearchForm::class,
|
||||
ViewBlocks\BooksShowTags::class,
|
||||
ViewBlocks\BooksShowShelves::class,
|
||||
ViewBlocks\BooksShowActivity::class,
|
||||
],
|
||||
'right' => [
|
||||
ViewBlocks\BooksShowDetails::class,
|
||||
ViewBlocks\BooksShowActions::class,
|
||||
],
|
||||
],
|
||||
'chapters-show' => [
|
||||
'left' => [
|
||||
ViewBlocks\ChaptersShowSearchForm::class,
|
||||
ViewBlocks\ChaptersShowTags::class,
|
||||
ViewBlocks\ChaptersShowBookTree::class,
|
||||
],
|
||||
'right' => [
|
||||
ViewBlocks\ChaptersShowDetails::class,
|
||||
ViewBlocks\ChaptersShowActions::class,
|
||||
],
|
||||
],
|
||||
'pages-show' => [
|
||||
'left' => [
|
||||
ViewBlocks\PagesShowTags::class,
|
||||
ViewBlocks\PagesShowAttachments::class,
|
||||
ViewBlocks\PagesShowPageNav::class,
|
||||
ViewBlocks\PagesShowBookTree::class,
|
||||
],
|
||||
'right' => [
|
||||
ViewBlocks\PagesShowDetails::class,
|
||||
ViewBlocks\PagesShowActions::class,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the default view blocks for the given location.
|
||||
*/
|
||||
public static function getForLocation(string $location): array
|
||||
{
|
||||
return self::$defaults[$location] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the locations for all default blocks.
|
||||
* @return string[]
|
||||
*/
|
||||
public static function getLocations(): array
|
||||
{
|
||||
return array_keys(self::$defaults);
|
||||
}
|
||||
|
||||
public static function getLocationLabels(): array
|
||||
{
|
||||
return [
|
||||
'home-default' => trans('common.homepage'),
|
||||
'home-non-default' => trans('common.homepage'),
|
||||
'shelves-index' => trans('entities.shelves'),
|
||||
'shelves-show' => trans('entities.shelf'),
|
||||
'books-index' => trans('entities.books'),
|
||||
'books-show' => trans('entities.book'),
|
||||
'chapters-show' => trans('entities.chapter'),
|
||||
'pages-show' => trans('entities.page'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View;
|
||||
|
||||
interface ViewBlockInterface
|
||||
{
|
||||
/**
|
||||
* Get the unique ID for this block.
|
||||
*/
|
||||
public static function getId(): string;
|
||||
|
||||
/**
|
||||
* Get the label for this block.
|
||||
*/
|
||||
public static function getLabel(): string;
|
||||
|
||||
/**
|
||||
* Get the view to render for this block.
|
||||
* Is provided with the existing parent view data.
|
||||
*/
|
||||
public function getView(array $viewData): string;
|
||||
|
||||
/**
|
||||
* Specify the data to pass to the view on render.
|
||||
* Is provided with the existing parent view data and the original request.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function withData(array $viewData): array;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
|
||||
class ViewBlockManager
|
||||
{
|
||||
public function __construct(
|
||||
protected ViewBlockPreferences $preferences,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array<string, array<string, class-string<ViewBlockInterface>[]>>
|
||||
*/
|
||||
protected array $blocksByLocationAndPosition = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array<string, class-string<ViewBlockInterface>[]>>
|
||||
*/
|
||||
protected array $locationBlockCache = [];
|
||||
|
||||
/**
|
||||
* Register a block type to be displayed at the given location and position.
|
||||
* @param class-string<ViewBlockInterface> $blockClass
|
||||
*/
|
||||
public function register(string $location, string $defaultPosition, string $blockClass): void
|
||||
{
|
||||
if (!isset($this->blocksByLocationAndPosition[$location])) {
|
||||
$this->blocksByLocationAndPosition[$location] = [];
|
||||
}
|
||||
|
||||
if (!isset($this->blocksByLocationAndPosition[$location][$defaultPosition])) {
|
||||
$this->blocksByLocationAndPosition[$location][$defaultPosition] = [];
|
||||
}
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
if (!is_a($blockClass, ViewBlockInterface::class, true)) {
|
||||
throw new \InvalidArgumentException('When registering a view block, the block class must implement ViewBlockInterface');
|
||||
}
|
||||
|
||||
$this->blocksByLocationAndPosition[$location][$defaultPosition][] = $blockClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blocks registered for a given location and position, considering the
|
||||
* preferences for the current user.
|
||||
* @return ViewBlockInterface[]
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function getInstancesForLocationAndPositionForCurrentUser(string $location, string $position): array
|
||||
{
|
||||
$key = $location;
|
||||
if (isset($this->locationBlockCache[$key])) {
|
||||
$blocks = $this->locationBlockCache[$key][$position] ?? [];
|
||||
return $this->blocksToInstances($blocks);
|
||||
}
|
||||
|
||||
$forLocation = $this->getForLocationForCurrentUser($location);
|
||||
$this->locationBlockCache[$key] = $forLocation;
|
||||
|
||||
$blocks = $forLocation[$position] ?? [];
|
||||
return $this->blocksToInstances($blocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create instances of the given block classes.
|
||||
* @param class-string<ViewBlockInterface>[] $blocks
|
||||
* @return ViewBlockInterface[]
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
protected function blocksToInstances(array $blocks): array
|
||||
{
|
||||
return array_map(fn (string $blockClass) => app()->make($blockClass), $blocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blocks registered for a given location, as sets of arrays
|
||||
* keyed by position.
|
||||
* @return array<string, class-string<ViewBlockInterface>[]>
|
||||
*/
|
||||
protected function getForLocation(string $location): array
|
||||
{
|
||||
$defaults = ViewBlockDefaults::getForLocation($location);
|
||||
$registered = $this->blocksByLocationAndPosition[$location] ?? [];
|
||||
return array_merge_recursive($defaults, $registered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blocks registered for a given location, as sets of arrays
|
||||
* keyed by position, for the current user.
|
||||
* Same as above but with user-specific preferences applied.
|
||||
* @return array<string, list<class-string<ViewBlockInterface>>>
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function getForLocationForCurrentUser(string $location): array
|
||||
{
|
||||
$forLocation = $this->getForLocation($location);
|
||||
$userBlocksByPosition = $this->preferences->getIdByPositionMap($location);
|
||||
if (empty($userBlocksByPosition)) {
|
||||
return $forLocation;
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$blocksById = $this->blocksByPositionToIdMap($forLocation);
|
||||
$idPositionMap = $this->blocksByPositionToIdPositionMap($forLocation);
|
||||
$locations = array_keys($forLocation);
|
||||
$locations[] = 'unused';
|
||||
|
||||
// Add based on user preferences
|
||||
foreach ($locations as $position) {
|
||||
$userBlockIds = $userBlocksByPosition[$position] ?? [];
|
||||
$results[$position] = [];
|
||||
foreach ($userBlockIds as $blockId) {
|
||||
$block = $blocksById[$blockId] ?? null;
|
||||
if ($block) {
|
||||
$results[$position][] = $block;
|
||||
unset($blocksById[$blockId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining blocks based on their default locations
|
||||
foreach ($blocksById as $block) {
|
||||
$position = $idPositionMap[$block::getId()] ?? 'unused';
|
||||
$results[$position][] = $block;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the names of all locations where blocks are registered.
|
||||
* Returns an array where the keys are location strings, and the
|
||||
* values are translated labels for that location.
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getNamedLocations(): array
|
||||
{
|
||||
$labels = ViewBlockDefaults::getLocationLabels();
|
||||
$defaults = ViewBlockDefaults::getLocations();
|
||||
$registered = array_keys($this->blocksByLocationAndPosition);
|
||||
$merged = array_unique(array_merge($defaults, $registered));
|
||||
|
||||
$results = [];
|
||||
foreach ($merged as $location) {
|
||||
$results[$location] = $labels[$location] ?? $location;
|
||||
}
|
||||
|
||||
$usingDefaultHome = setting('app-homepage-type') === 'default';
|
||||
$toIgnore = $usingDefaultHome ? 'home-non-default' : 'home-default';
|
||||
unset($results[$toIgnore]);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update user preferences for a given location to match the given layout data map.
|
||||
* @param array<string, string[]> $layoutData
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function updatePreferencesFromIdPositionMap(string $location, array $layoutData): void
|
||||
{
|
||||
$this->preferences->storeByIdPositionMap(
|
||||
$location,
|
||||
$layoutData,
|
||||
$this->getForLocation($location),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the local user-specific cache of blocks.
|
||||
* The cache only needs to exist for the current request time since its purpose is to
|
||||
* avoid duplicate loading across views.
|
||||
*/
|
||||
public function clearLocalCache(): void
|
||||
{
|
||||
$this->locationBlockCache = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a blocksByPosition array into a map of block IDs to blocks.
|
||||
* @param array<string, class-string<ViewBlockInterface>[]> $blocksByPosition
|
||||
* @return array<string, class-string<ViewBlockInterface>>
|
||||
*/
|
||||
protected function blocksByPositionToIdMap(array $blocksByPosition): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($blocksByPosition as $position => $blocks) {
|
||||
foreach ($blocks as $block) {
|
||||
$map[$block::getId()] = $block;
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a blocksByPosition array into a map of block IDs to their positions.
|
||||
* @param array<string, class-string<ViewBlockInterface>[]> $blocksByPosition
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function blocksByPositionToIdPositionMap(array $blocksByPosition): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($blocksByPosition as $position => $blocks) {
|
||||
foreach ($blocks as $block) {
|
||||
$map[$block::getId()] = $position;
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
|
||||
class ViewBlockPreferences
|
||||
{
|
||||
/**
|
||||
* From the request data of the layout editor, validate the request data to ensure the blocks
|
||||
* and locations are valid and then store the layout data.
|
||||
* The request data is expected to be in the format of:
|
||||
* [
|
||||
* 'position-1' => ['block-id-1', 'block-id-2'],
|
||||
* 'position-2' => ['block-id-3'],
|
||||
* ]
|
||||
* @param array<string, string[]> $layoutData
|
||||
* @param array<string, class-string<ViewBlockInterface>[]> $validBlocksByPosition
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function storeByIdPositionMap(
|
||||
string $location,
|
||||
array $layoutData,
|
||||
array $validBlocksByPosition,
|
||||
): void {
|
||||
$validIds = $this->extractValidBlockIds($validBlocksByPosition);
|
||||
$validPositions = array_keys($validBlocksByPosition);
|
||||
$validPositions[] = 'unused';
|
||||
|
||||
// Ignore updates for invalid/unknown locations
|
||||
if (empty($validBlocksByPosition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var array<string, string[]> $validatedLayoutData */
|
||||
$validatedLayoutData = [];
|
||||
|
||||
foreach ($layoutData as $position => $blockIds) {
|
||||
// @phpstan-ignore-next-line
|
||||
if (!in_array($position, $validPositions) || !is_array($blockIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$validatedLayoutData[$position] = array_filter($blockIds, function ($id) use ($validIds) {
|
||||
// @phpstan-ignore function.alreadyNarrowedType
|
||||
return is_string($id) && in_array($id, $validIds);
|
||||
});
|
||||
}
|
||||
|
||||
$settingKey = $this->getSettingKey($location);
|
||||
setting()->putForCurrentUser($settingKey, json_encode($validatedLayoutData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the view block preferences for a given location for the current user.
|
||||
*/
|
||||
public function clearForLocation(string $location): void
|
||||
{
|
||||
$settingKey = $this->getSettingKey($location);
|
||||
setting()->removeForCurrentUser($settingKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the layout data for a given location.
|
||||
* Provides arrays of block ids keyed by position.
|
||||
* @return array<string, string[]>
|
||||
*/
|
||||
public function getIdByPositionMap(string $location): array
|
||||
{
|
||||
$settingKey = $this->getSettingKey($location);
|
||||
$layoutData = setting()->getForCurrentUser($settingKey, '{}');
|
||||
return json_decode($layoutData, true) ?? [];
|
||||
}
|
||||
|
||||
protected function getSettingKey(string $location): string
|
||||
{
|
||||
return 'view-layout#' . $location;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, class-string<ViewBlockInterface>[]> $blocksByPosition
|
||||
* @return string[]
|
||||
*/
|
||||
protected function extractValidBlockIds(array $blocksByPosition): array
|
||||
{
|
||||
$ids = [];
|
||||
|
||||
foreach ($blocksByPosition as $blocks) {
|
||||
foreach ($blocks as $block) {
|
||||
$ids[] = $block::getId();
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View\ViewBlocks;
|
||||
|
||||
use BookStack\View\ViewBlock;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BooksIndexActions extends ViewBlock
|
||||
{
|
||||
protected static string $id = 'builtin_books-index-actions';
|
||||
protected static string $view = 'books.parts.index-sidebar-section-actions';
|
||||
protected static string $labelTranslationKey = 'common.actions';
|
||||
|
||||
public function withData(array $viewData): array
|
||||
{
|
||||
return [
|
||||
'view' => $viewData['view'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View\ViewBlocks;
|
||||
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\View\ViewBlock;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BooksIndexNew extends ViewBlock
|
||||
{
|
||||
protected static string $id = 'builtin_books-index-new';
|
||||
protected static string $view = 'books.parts.index-sidebar-section-new';
|
||||
protected static string $labelTranslationKey = 'entities.books_new';
|
||||
|
||||
public function __construct(
|
||||
protected BookQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
public function withData(array $viewData): array
|
||||
{
|
||||
$new = $this->queries->visibleForList()
|
||||
->orderBy('created_at', 'desc')
|
||||
->take(4)
|
||||
->get();
|
||||
|
||||
return [
|
||||
'new' => $new,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View\ViewBlocks;
|
||||
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\View\ViewBlock;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BooksIndexPopular extends ViewBlock
|
||||
{
|
||||
protected static string $id = 'builtin_books-index-popular';
|
||||
protected static string $view = 'books.parts.index-sidebar-section-popular';
|
||||
protected static string $labelTranslationKey = 'entities.books_popular';
|
||||
|
||||
public function __construct(
|
||||
protected BookQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
public function withData(array $viewData): array
|
||||
{
|
||||
return [
|
||||
'popular' => $this->queries->popularForList()->take(4)->get(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View\ViewBlocks;
|
||||
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\View\ViewBlock;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BooksIndexRecents extends ViewBlock
|
||||
{
|
||||
protected static string $id = 'builtin_books-index-recents';
|
||||
protected static string $view = 'books.parts.index-sidebar-section-recents';
|
||||
protected static string $labelTranslationKey = 'entities.recently_viewed';
|
||||
|
||||
public function __construct(
|
||||
protected BookQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
public function withData(array $viewData): array
|
||||
{
|
||||
$recents = null;
|
||||
if (!user()->isGuest()) {
|
||||
$recents = $this->queries->recentlyViewedForCurrentUser()->take(4)->get();
|
||||
}
|
||||
|
||||
return [
|
||||
'recents' => $recents,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View\ViewBlocks;
|
||||
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\View\ViewBlock;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BooksShowActions extends ViewBlock
|
||||
{
|
||||
protected static string $id = 'builtin_books-show-actions';
|
||||
protected static string $view = 'books.parts.show-sidebar-section-actions';
|
||||
protected static string $labelTranslationKey = 'common.actions';
|
||||
|
||||
public function withData(array $viewData): array
|
||||
{
|
||||
/** @var Book $book */
|
||||
$book = $viewData['book'];
|
||||
|
||||
return [
|
||||
'book' => $book,
|
||||
'watchOptions' => new UserEntityWatchOptions(user(), $book),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View\ViewBlocks;
|
||||
|
||||
use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\View\ViewBlock;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BooksShowActivity extends ViewBlock
|
||||
{
|
||||
protected static string $id = 'builtin_books-show-activity';
|
||||
protected static string $view = 'books.parts.show-sidebar-section-activity';
|
||||
protected static string $labelTranslationKey = 'entities.recent_activity';
|
||||
|
||||
public function __construct(
|
||||
protected ActivityQueries $activityQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
public function withData(array $viewData): array
|
||||
{
|
||||
/** @var Book $book */
|
||||
$book = $viewData['book'];
|
||||
|
||||
return [
|
||||
'activity' => $this->activityQueries->entityActivity($book, 20, 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View\ViewBlocks;
|
||||
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\View\ViewBlock;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BooksShowDetails extends ViewBlock
|
||||
{
|
||||
protected static string $id = 'builtin_books-show-details';
|
||||
protected static string $view = 'books.parts.show-sidebar-section-details';
|
||||
protected static string $labelTranslationKey = 'common.details';
|
||||
|
||||
public function __construct(
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
}
|
||||
|
||||
public function withData(array $viewData): array
|
||||
{
|
||||
/** @var Book $book */
|
||||
$book = $viewData['book'];
|
||||
$referenceCount = $this->referenceFetcher->getReferenceCountToEntity($book);
|
||||
|
||||
return [
|
||||
'book' => $book,
|
||||
'watchOptions' => new UserEntityWatchOptions(user(), $book),
|
||||
'referenceCount' => $referenceCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\View\ViewBlocks;
|
||||
|
||||
use BookStack\View\ViewBlock;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BooksShowSearchForm extends ViewBlock
|
||||
{
|
||||
protected static string $id = 'builtin_books-show-search-form';
|
||||
protected static string $view = 'entities.search-form';
|
||||
protected static string $labelTranslationKey = 'common.search';
|
||||
|
||||
public function withData(array $viewData): array
|
||||
{
|
||||
return [
|
||||
'label' => trans('entities.books_search_this'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user