Merge branch 'development' into enableservices-api-search-book-chapter-endpoints

This commit is contained in:
Dan Brown
2026-09-17 12:39:41 +01:00
300 changed files with 6810 additions and 2766 deletions
+21 -7
View File
@@ -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._
+2
View File
@@ -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
+1 -1
View File
@@ -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;
+4
View File
@@ -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
View File
@@ -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
*/
+20 -3
View File
@@ -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
*/
-2
View File
@@ -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) {
+1
View File
@@ -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,
]);
+19 -3
View File
@@ -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();
}
+3
View File
@@ -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')
+7 -1
View File
@@ -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;
}
/**
+9 -3
View File
@@ -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');
}
);
}
+40 -2
View File
@@ -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()
{
+2 -2
View File
@@ -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) {
+9 -42
View File
@@ -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 */
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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',
+1
View File
@@ -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' => [
+1 -16
View File
@@ -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,
]);
}
+16 -10
View File
@@ -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);
+30 -13
View File
@@ -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);
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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()
+3
View File
@@ -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
{
+2 -2
View File
@@ -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
+7
View File
@@ -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);
});
});
}
+2
View File
@@ -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.*')
+2 -2
View File
@@ -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');
}
+25 -12
View File
@@ -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;
}
}
+2 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+2
View File
@@ -358,6 +358,8 @@ class TrashCan
$entity->chapters()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
}
$entity->rebuildPermissions();
return $count;
}
-10
View File
@@ -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);
}
}
+5
View File
@@ -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'])],
];
+1 -1
View File
@@ -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.
*/
+15 -13
View File
@@ -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);
+3 -2
View File
@@ -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());
}
+1
View File
@@ -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();
}
}
+1
View File
@@ -15,5 +15,6 @@ class TrimStrings extends Middleware
'password',
'password_confirmation',
'password-confirm',
'password-current',
];
}
+8 -1
View File
@@ -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 */
+1 -1
View File
@@ -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;
+4
View File
@@ -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()
+5 -1
View File
@@ -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.
+11 -3
View File
@@ -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) . '%')
+14
View File
@@ -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()
+1 -1
View File
@@ -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;
+9
View File
@@ -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
+1 -1
View File
@@ -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)
+47
View File
@@ -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'],
]);
+1 -1
View File
@@ -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
+24 -4
View File
@@ -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);
}
}
+6
View File
@@ -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);
}
+8
View File
@@ -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.
+1 -1
View File
@@ -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);
+3 -3
View File
@@ -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);
+4 -4
View File
@@ -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);
+7 -2
View File
@@ -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
*/
+2 -1
View File
@@ -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);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace BookStack\Util\HtmlPurifier;
use HTMLPurifier_URIScheme_ftp;
class SftpUriScheme extends HTMLPurifier_URIScheme_ftp
{
public $default_port = 22;
}
+3 -3
View File
@@ -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.
*/
+71
View File
@@ -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}");
}
}
+34
View File
@@ -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;
}
}
+133
View File
@@ -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'),
];
}
}
+29
View File
@@ -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;
}
+214
View File
@@ -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;
}
}
+96
View File
@@ -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);
}
}
+20
View File
@@ -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'],
];
}
}
+31
View File
@@ -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,
];
}
}
+26
View File
@@ -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(),
];
}
}
+31
View File
@@ -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,
];
}
}
+26
View File
@@ -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),
];
}
}
+30
View File
@@ -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),
];
}
}
+34
View File
@@ -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