1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2024-10-29 23:22:34 +01:00

Merge branch 'development' into lukeshu/oidc-development

This commit is contained in:
Dan Brown 2024-04-16 14:57:36 +01:00
commit dc6013fd7e
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
878 changed files with 16486 additions and 9229 deletions

View File

@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: French; Dutch; Turkish;
REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@ -324,7 +324,7 @@ Robin Flikkema (RobinFlikkema) :: Dutch
Michal Gurcik (mgurcik) :: Slovak
Pooyan Arab (pooyanarab) :: Persian
Ochi Darma Putra (troke12) :: Indonesian
H.-H. Peng (Hsins) :: Chinese Traditional
Hsin-Hsiang Peng (Hsins) :: Chinese Traditional
Mosi Wang (mosiwang) :: Chinese Traditional
骆言 (LawssssCat) :: Chinese Simplified
Stickers Gaming Shøw (StickerSGSHOW) :: French
@ -371,3 +371,42 @@ LameeQS :: Latvian
Sorin T. (trimbitassorin) :: Romanian
poesty :: Chinese Simplified
balmag :: Hungarian
Antti-Jussi Nygård (ajnyga) :: Finnish
Eduard Ereza Martínez (Ereza) :: Catalan
Jabir Lang (amar.almrad) :: Arabic
Jaroslav Kobližek (foretix) :: Czech; French
Wiktor Adamczyk (adamczyk.wiktor) :: Polish
Abdulmajeed Alshuaibi (4Majeed) :: Arabic
NotSmartZakk :: Czech
HyoungMin Lee (ddokkaebi) :: Korean
Dasferco :: Chinese Simplified
Marcus Teräs (mteras) :: Finnish
Serkan Yardim (serkanzz) :: Turkish
Y (cnsr) :: Ukrainian
ZY ZV (vy0b0x) :: Chinese Simplified
diegobenitez :: Spanish
Marc Hagen (MarcHagen) :: Dutch
Kasper Alsøe (zeonos) :: Danish
sultani :: Persian
renge :: Korean
TheGatesDev (thegatesdev) :: Dutch
Irdi (irdiOL) :: Albanian
KateBarber :: Welsh
Twister (theuncles75) :: Hebrew
algernon19 :: Hungarian
Ivan Krstic (ikrstic) :: Serbian (Cyrillic)
Show :: Russian
xBahamut :: Portuguese, Brazilian
Pavle Knežević (pavleknezzevic) :: Serbian (Cyrillic)
Vanja Cvelbar (b100w11) :: Slovenian
simonpct :: French
Honza Nagy (honza.nagy) :: Czech
asd20752 :: Norwegian Bokmal
Jan Picka (polipones) :: Czech
diogoalex991 :: Portuguese
Ehsan Sadeghi (ehsansadeghi) :: Persian
ka_picit :: Danish
cracrayol :: French
CapuaSC :: Dutch
Guardian75 :: German Informal
mr-kanister :: German

View File

@ -18,7 +18,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.1
php-version: 8.3
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
@ -27,10 +27,10 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-8.1
key: ${{ runner.os }}-composer-8.3
restore-keys: ${{ runner.os }}-composer-
- name: Install composer dependencies

View File

@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.0', '8.1', '8.2', '8.3']
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v1
@ -32,7 +32,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}

View File

@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.0', '8.1', '8.2', '8.3']
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v1
@ -32,7 +32,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}

View File

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015-2023, Dan Brown and the BookStack Project contributors.
Copyright (c) 2015-2024, Dan Brown and the BookStack Project contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -19,20 +19,25 @@ class MfaTotpController extends Controller
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret';
public function __construct(
protected TotpService $totp
) {
}
/**
* Show a view that generates and displays a TOTP QR code.
*/
public function generate(TotpService $totp)
public function generate()
{
if (session()->has(static::SETUP_SECRET_SESSION_KEY)) {
$totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
} else {
$totpSecret = $totp->generateSecret();
$totpSecret = $this->totp->generateSecret();
session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
}
$qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
$qrCodeUrl = $this->totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
$svg = $this->totp->generateQrCodeSvg($qrCodeUrl);
$this->setPageTitle(trans('auth.mfa_gen_totp_title'));
@ -56,7 +61,7 @@ class MfaTotpController extends Controller
'code' => [
'required',
'max:12', 'min:4',
new TotpValidationRule($totpSecret),
new TotpValidationRule($totpSecret, $this->totp),
],
]);
@ -87,7 +92,7 @@ class MfaTotpController extends Controller
'code' => [
'required',
'max:12', 'min:4',
new TotpValidationRule($totpSecret),
new TotpValidationRule($totpSecret, $this->totp),
],
]);

View File

@ -2,36 +2,26 @@
namespace BookStack\Access\Mfa;
use Illuminate\Contracts\Validation\Rule;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class TotpValidationRule implements Rule
class TotpValidationRule implements ValidationRule
{
protected $secret;
protected $totpService;
/**
* Create a new rule instance.
* Takes the TOTP secret that must be system provided, not user provided.
*/
public function __construct(string $secret)
{
$this->secret = $secret;
$this->totpService = app()->make(TotpService::class);
public function __construct(
protected string $secret,
protected TotpService $totpService,
) {
}
/**
* Determine if the validation rule passes.
*/
public function passes($attribute, $value)
public function validate(string $attribute, mixed $value, Closure $fail): void
{
return $this->totpService->verifyCode($value, $this->secret);
}
/**
* Get the validation error message.
*/
public function message()
{
return trans('validation.totp');
$passes = $this->totpService->verifyCode($value, $this->secret);
if (!$passes) {
$fail(trans('validation.totp'));
}
}
}

View File

@ -83,15 +83,9 @@ class OidcOAuthProvider extends AbstractProvider
/**
* Checks a provider response for errors.
*
* @param ResponseInterface $response
* @param array|string $data Parsed response data
*
* @throws IdentityProviderException
*
* @return void
*/
protected function checkResponse(ResponseInterface $response, $data)
protected function checkResponse(ResponseInterface $response, $data): void
{
if ($response->getStatusCode() >= 400 || isset($data['error'])) {
throw new IdentityProviderException(
@ -105,13 +99,8 @@ class OidcOAuthProvider extends AbstractProvider
/**
* Generates a resource owner object from a successful resource owner
* details request.
*
* @param array $response
* @param AccessToken $token
*
* @return ResourceOwnerInterface
*/
protected function createResourceOwner(array $response, AccessToken $token)
protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface
{
return new GenericResourceOwner($response, '');
}
@ -121,14 +110,18 @@ class OidcOAuthProvider extends AbstractProvider
*
* The grant that was used to fetch the response can be used to provide
* additional context.
*
* @param array $response
* @param AbstractGrant $grant
*
* @return OidcAccessToken
*/
protected function createAccessToken(array $response, AbstractGrant $grant)
protected function createAccessToken(array $response, AbstractGrant $grant): OidcAccessToken
{
return new OidcAccessToken($response);
}
/**
* Get the method used for PKCE code verifier hashing, which is passed
* in the "code_challenge_method" parameter in the authorization request.
*/
protected function getPkceMethod(): string
{
return static::PKCE_METHOD_S256;
}
}

View File

@ -33,6 +33,8 @@ class OidcService
/**
* Initiate an authorization flow.
* Provides back an authorize redirect URL, in addition to other
* details which may be required for the auth flow.
*
* @throws OidcException
*
@ -42,8 +44,12 @@ class OidcService
{
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
$url = $provider->getAuthorizationUrl();
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
return [
'url' => $provider->getAuthorizationUrl(),
'url' => $url,
'state' => $provider->getState(),
];
}
@ -63,6 +69,10 @@ class OidcService
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
// Set PKCE code flashed at login
$pkceCode = session()->pull('oidc_pkce_code', '');
$provider->setPkceCode($pkceCode);
// Try to exchange authorization code for access token
$accessToken = $provider->getAccessToken('authorization_code', [
'code' => $authorizationCode,

View File

@ -14,20 +14,14 @@ use Illuminate\Support\Str;
class RegistrationService
{
protected $userRepo;
protected $emailConfirmationService;
/**
* RegistrationService constructor.
*/
public function __construct(UserRepo $userRepo, EmailConfirmationService $emailConfirmationService)
{
$this->userRepo = $userRepo;
$this->emailConfirmationService = $emailConfirmationService;
public function __construct(
protected UserRepo $userRepo,
protected EmailConfirmationService $emailConfirmationService,
) {
}
/**
* Check whether or not registrations are allowed in the app settings.
* Check if registrations are allowed in the app settings.
*
* @throws UserRegistrationException
*/
@ -84,6 +78,7 @@ class RegistrationService
public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
{
$userEmail = $userData['email'];
$authSystem = $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver();
// Email restriction
$this->ensureEmailDomainAllowed($userEmail);
@ -94,6 +89,12 @@ class RegistrationService
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
}
/** @var ?bool $shouldRegister */
$shouldRegister = Theme::dispatch(ThemeEvents::AUTH_PRE_REGISTER, $authSystem, $userData);
if ($shouldRegister === false) {
throw new UserRegistrationException(trans('errors.auth_pre_register_theme_prevention'), '/login');
}
// Create the user
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
$newUser->attachDefaultRole();
@ -104,7 +105,7 @@ class RegistrationService
}
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(), $newUser);
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $newUser);
// Start email confirmation flow if required
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
@ -138,7 +139,7 @@ class RegistrationService
}
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
$userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, '@'), 1);
$userEmailDomain = mb_substr(mb_strrchr($userEmail, '@'), 1);
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
$redirect = $this->registrationAllowed() ? '/register' : '/login';

View File

@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
@ -14,11 +15,10 @@ use Illuminate\Database\Eloquent\Relations\Relation;
class ActivityQueries
{
protected PermissionApplicator $permissions;
public function __construct(PermissionApplicator $permissions)
{
$this->permissions = $permissions;
public function __construct(
protected PermissionApplicator $permissions,
protected MixedEntityListLoader $listLoader,
) {
}
/**
@ -29,11 +29,13 @@ class ActivityQueries
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->with(['user'])
->skip($count * $page)
->take($count)
->get();
$this->listLoader->loadIntoRelations($activityList->all(), 'entity', false);
return $this->filterSimilar($activityList);
}

View File

@ -5,7 +5,7 @@ namespace BookStack\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Activity as ActivityService;
use League\CommonMark\CommonMarkConverter;
use BookStack\Util\HtmlDescriptionFilter;
class CommentRepo
{
@ -20,13 +20,12 @@ class CommentRepo
/**
* Create a new comment on an entity.
*/
public function create(Entity $entity, string $text, ?int $parent_id): Comment
public function create(Entity $entity, string $html, ?int $parent_id): Comment
{
$userId = user()->id;
$comment = new Comment();
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
$comment->html = HtmlDescriptionFilter::filterFromString($html);
$comment->created_by = $userId;
$comment->updated_by = $userId;
$comment->local_id = $this->getNextLocalId($entity);
@ -42,11 +41,10 @@ class CommentRepo
/**
* Update an existing comment.
*/
public function update(Comment $comment, string $text): Comment
public function update(Comment $comment, string $html): Comment
{
$comment->updated_by = user()->id;
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
$comment->html = HtmlDescriptionFilter::filterFromString($html);
$comment->save();
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
@ -64,20 +62,6 @@ class CommentRepo
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
}
/**
* Convert the given comment Markdown to HTML.
*/
public function commentToHtml(string $commentText): string
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'max_nesting_level' => 10,
'allow_unsafe_links' => false,
]);
return $converter->convert($commentText);
}
/**
* Get the next local ID relative to the linked entity.
*/

View File

@ -3,7 +3,7 @@
namespace BookStack\Activity\Controllers;
use BookStack\Activity\CommentRepo;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@ -11,7 +11,8 @@ use Illuminate\Validation\ValidationException;
class CommentController extends Controller
{
public function __construct(
protected CommentRepo $commentRepo
protected CommentRepo $commentRepo,
protected PageQueries $pageQueries,
) {
}
@ -22,12 +23,12 @@ class CommentController extends Controller
*/
public function savePageComment(Request $request, int $pageId)
{
$this->validate($request, [
'text' => ['required', 'string'],
$input = $this->validate($request, [
'html' => ['required', 'string'],
'parent_id' => ['nullable', 'integer'],
]);
$page = Page::visible()->find($pageId);
$page = $this->pageQueries->findVisibleById($pageId);
if ($page === null) {
return response('Not found', 404);
}
@ -39,7 +40,7 @@ class CommentController extends Controller
// Create a new comment.
$this->checkPermission('comment-create-all');
$comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null);
return view('comments.comment-branch', [
'readOnly' => false,
@ -57,17 +58,20 @@ class CommentController extends Controller
*/
public function update(Request $request, int $commentId)
{
$this->validate($request, [
'text' => ['required', 'string'],
$input = $this->validate($request, [
'html' => ['required', 'string'],
]);
$comment = $this->commentRepo->getById($commentId);
$this->checkOwnablePermission('page-view', $comment->entity);
$this->checkOwnablePermission('comment-update', $comment);
$comment = $this->commentRepo->update($comment, $request->get('text'));
$comment = $this->commentRepo->update($comment, $input['html']);
return view('comments.comment', ['comment' => $comment, 'readOnly' => false]);
return view('comments.comment', [
'comment' => $comment,
'readOnly' => false,
]);
}
/**

View File

@ -2,7 +2,7 @@
namespace BookStack\Activity\Controllers;
use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Queries\QueryTopFavourites;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
@ -17,11 +17,11 @@ class FavouriteController extends Controller
/**
* Show a listing of all favourite items for the current user.
*/
public function index(Request $request)
public function index(Request $request, QueryTopFavourites $topFavourites)
{
$viewCount = 20;
$page = intval($request->get('page', 1));
$favourites = (new TopFavourites())->run($viewCount + 1, (($page - 1) * $viewCount));
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;

View File

@ -4,13 +4,14 @@ namespace BookStack\Activity\Models;
use BookStack\App\Model;
use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Util\HtmlContentFilter;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property string $text
* @property string $text - Deprecated & now unused (#4821)
* @property string $html
* @property int|null $parent_id - Relates to local_id, not id
* @property int $local_id
@ -24,7 +25,7 @@ class Comment extends Model implements Loggable
use HasFactory;
use HasCreatorAndUpdater;
protected $fillable = ['text', 'parent_id'];
protected $fillable = ['parent_id'];
protected $appends = ['created', 'updated'];
/**
@ -73,4 +74,9 @@ class Comment extends Model implements Loggable
{
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
}
public function safeHtml(): string
{
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
}
}

View File

@ -38,7 +38,8 @@ class TagRepo
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
])
->orderBy($sort, $listOptions->getOrder());
->orderBy($sort, $listOptions->getOrder())
->whereHas('entity');
if ($nameFilter) {
$query->where('name', '=', $nameFilter);

View File

@ -41,6 +41,17 @@ class CommentTree
return $this->tree;
}
public function canUpdateAny(): bool
{
foreach ($this->comments as $comment) {
if (userCan('comment-update', $comment)) {
return true;
}
}
return false;
}
/**
* @param Comment[] $comments
*/

View File

@ -7,7 +7,6 @@ use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;

View File

@ -3,32 +3,36 @@
namespace BookStack\App;
use BookStack\Activity\ActivityQueries;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\RecentlyViewed;
use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
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\Uploads\FaviconHandler;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
class HomeController extends Controller
{
public function __construct(
protected EntityQueries $queries,
) {
}
/**
* Display the homepage.
*/
public function index(Request $request, ActivityQueries $activities)
{
public function index(
Request $request,
ActivityQueries $activities,
QueryRecentlyViewed $recentlyViewed,
QueryTopFavourites $topFavourites,
) {
$activity = $activities->latest(10);
$draftPages = [];
if ($this->isSignedIn()) {
$draftPages = Page::visible()
->where('draft', '=', true)
->where('created_by', '=', user()->id)
$draftPages = $this->queries->pages->currentUserDraftsForList()
->orderBy('updated_at', 'desc')
->with('book')
->take(6)
@ -37,14 +41,13 @@ class HomeController extends Controller
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
$recents = $this->isSignedIn() ?
(new RecentlyViewed())->run(12 * $recentFactor, 1)
: Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
$favourites = (new TopFavourites())->run(6);
$recentlyUpdatedPages = Page::visible()->with('book')
$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)
->select(Page::$listAttributes)
->get();
$homepageOptions = ['default', 'books', 'bookshelves', 'page'];
@ -78,14 +81,18 @@ class HomeController extends Controller
}
if ($homepageOption === 'bookshelves') {
$shelves = app()->make(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$shelves = $this->queries->shelves->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(18);
$data = array_merge($commonData, ['shelves' => $shelves]);
return view('home.shelves', $data);
}
if ($homepageOption === 'books') {
$books = app()->make(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$books = $this->queries->books->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(18);
$data = array_merge($commonData, ['books' => $books]);
return view('home.books', $data);
@ -95,7 +102,7 @@ class HomeController extends Controller
$homepageSetting = setting('app-homepage', '0:');
$id = intval(explode(':', $homepageSetting)[0]);
/** @var Page $customHomepage */
$customHomepage = Page::query()->where('draft', '=', false)->findOrFail($id);
$customHomepage = $this->queries->pages->start()->where('draft', '=', false)->findOrFail($id);
$pageContent = new PageContent($customHomepage);
$customHomepage->html = $pageContent->render(false);
@ -104,48 +111,4 @@ class HomeController extends Controller
return view('home.default', $commonData);
}
/**
* Show the view for /robots.txt.
*/
public function robots()
{
$sitePublic = setting('app-public', false);
$allowRobots = config('app.allow_robots');
if ($allowRobots === null) {
$allowRobots = $sitePublic;
}
return response()
->view('misc.robots', ['allowRobots' => $allowRobots])
->header('Content-Type', 'text/plain');
}
/**
* Show the route for 404 responses.
*/
public function notFound()
{
return response()->view('errors.404', [], 404);
}
/**
* Serve the application favicon.
* Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served
* directly by the webserver in the future.
*/
public function favicon(FaviconHandler $favicons)
{
$exists = $favicons->restoreOriginalIfNotExists();
return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath());
}
/**
* Serve a PWA application manifest.
*/
public function pwaManifest(PwaManifestBuilder $manifestBuilder)
{
return response()->json($manifestBuilder->build());
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace BookStack\App;
use BookStack\Http\Controller;
use BookStack\Uploads\FaviconHandler;
class MetaController extends Controller
{
/**
* Show the view for /robots.txt.
*/
public function robots()
{
$sitePublic = setting('app-public', false);
$allowRobots = config('app.allow_robots');
if ($allowRobots === null) {
$allowRobots = $sitePublic;
}
return response()
->view('misc.robots', ['allowRobots' => $allowRobots])
->header('Content-Type', 'text/plain');
}
/**
* Show the route for 404 responses.
*/
public function notFound()
{
return response()->view('errors.404', [], 404);
}
/**
* Serve the application favicon.
* Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served
* directly by the webserver in the future.
*/
public function favicon(FaviconHandler $favicons)
{
$exists = $favicons->restoreOriginalIfNotExists();
return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath());
}
/**
* Serve a PWA application manifest.
*/
public function pwaManifest(PwaManifestBuilder $manifestBuilder)
{
return response()->json($manifestBuilder->build());
}
/**
* Show license information for the application.
*/
public function licenses()
{
$this->setPageTitle(trans('settings.licenses'));
return view('help.licenses', [
'license' => file_get_contents(base_path('LICENSE')),
'phpLibData' => file_get_contents(base_path('dev/licensing/php-library-licenses.txt')),
'jsLibData' => file_get_contents(base_path('dev/licensing/js-library-licenses.txt')),
]);
}
}

View File

@ -25,7 +25,7 @@ class AppServiceProvider extends ServiceProvider
* Custom container bindings to register.
* @var string[]
*/
public $bindings = [
public array $bindings = [
ExceptionRenderer::class => BookStackExceptionHandlerPage::class,
];
@ -33,7 +33,7 @@ class AppServiceProvider extends ServiceProvider
* Custom singleton bindings to register.
* @var string[]
*/
public $singletons = [
public array $singletons = [
'activity' => ActivityLogger::class,
SettingService::class => SettingService::class,
SocialDriverManager::class => SocialDriverManager::class,
@ -42,11 +42,19 @@ class AppServiceProvider extends ServiceProvider
];
/**
* Bootstrap any application services.
*
* @return void
* Register any application services.
*/
public function boot()
public function register(): void
{
$this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null);
});
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Set root URL
$appUrl = config('app.url');
@ -67,16 +75,4 @@ class AppServiceProvider extends ServiceProvider
'page' => Page::class,
]);
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null);
});
}
}

View File

@ -18,10 +18,8 @@ class AuthServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application services.
*
* @return void
*/
public function boot()
public function boot(): void
{
// Password Configuration
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
@ -58,10 +56,8 @@ class AuthServiceProvider extends ServiceProvider
/**
* Register the application services.
*
* @return void
*/
public function register()
public function register(): void
{
Auth::provider('external-users', function ($app, array $config) {
return new ExternalBaseUserProvider($config['model']);

View File

@ -29,20 +29,16 @@ class EventServiceProvider extends ServiceProvider
/**
* Register any events for your application.
*
* @return void
*/
public function boot()
public function boot(): void
{
//
}
/**
* Determine if events and listeners should be automatically discovered.
*
* @return bool
*/
public function shouldDiscoverEvents()
public function shouldDiscoverEvents(): bool
{
return false;
}

View File

@ -24,10 +24,8 @@ class RouteServiceProvider extends ServiceProvider
/**
* Define your route model bindings, pattern filters, etc.
*
* @return void
*/
public function boot()
public function boot(): void
{
$this->configureRateLimiting();
@ -41,10 +39,8 @@ class RouteServiceProvider extends ServiceProvider
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
protected function mapWebRoutes(): void
{
Route::group([
'middleware' => 'web',
@ -65,10 +61,8 @@ class RouteServiceProvider extends ServiceProvider
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*
* @return void
*/
protected function mapApiRoutes()
protected function mapApiRoutes(): void
{
Route::group([
'middleware' => 'api',
@ -81,10 +75,8 @@ class RouteServiceProvider extends ServiceProvider
/**
* Configure the rate limiters for the application.
*
* @return void
*/
protected function configureRateLimiting()
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());

View File

@ -4,17 +4,14 @@ namespace BookStack\App\Providers;
use BookStack\Theming\ThemeEvents;
use BookStack\Theming\ThemeService;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class ThemeServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register()
public function register(): void
{
// Register the ThemeService as a singleton
$this->app->singleton(ThemeService::class, fn ($app) => new ThemeService());
@ -22,10 +19,8 @@ class ThemeServiceProvider extends ServiceProvider
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
public function boot(): void
{
// Boot up the theme system
$themeService = $this->app->make(ThemeService::class);

View File

@ -11,10 +11,8 @@ class TranslationServiceProvider extends BaseProvider
{
/**
* Register the service provider.
*
* @return void
*/
public function register()
public function register(): void
{
$this->registerLoader();
@ -41,10 +39,8 @@ class TranslationServiceProvider extends BaseProvider
/**
* Register the translation line loader.
* Overrides the default register action from Laravel so a custom loader can be used.
*
* @return void
*/
protected function registerLoader()
protected function registerLoader(): void
{
$this->app->singleton('translation.loader', function ($app) {
return new FileLoader($app['files'], $app['path.lang']);

View File

@ -12,10 +12,8 @@ class ViewTweaksServiceProvider extends ServiceProvider
{
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
public function boot(): void
{
// Set paginator to use bootstrap-style pagination
Paginator::useBootstrap();

View File

@ -26,7 +26,7 @@ class PwaManifestBuilder
"launch_handler" => [
"client_mode" => "focus-existing"
],
"orientation" => "portrait",
"orientation" => "any",
"icons" => [
[
"src" => setting('app-icon-32') ?: url('/icon-32.png'),

View File

@ -9,6 +9,7 @@
*/
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider;
return [
@ -113,46 +114,22 @@ return [
],
// Application Service Providers
'providers' => [
// Laravel Framework Service Providers...
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
Illuminate\Cookie\CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
'providers' => ServiceProvider::defaultProviders()->merge([
// Third party service providers
Barryvdh\DomPDF\ServiceProvider::class,
Barryvdh\Snappy\ServiceProvider::class,
SocialiteProviders\Manager\ServiceProvider::class,
// BookStack custom service providers
\BookStack\App\Providers\ThemeServiceProvider::class,
\BookStack\App\Providers\AppServiceProvider::class,
\BookStack\App\Providers\AuthServiceProvider::class,
\BookStack\App\Providers\EventServiceProvider::class,
\BookStack\App\Providers\RouteServiceProvider::class,
\BookStack\App\Providers\TranslationServiceProvider::class,
\BookStack\App\Providers\ValidationRuleServiceProvider::class,
\BookStack\App\Providers\ViewTweaksServiceProvider::class,
],
BookStack\App\Providers\ThemeServiceProvider::class,
BookStack\App\Providers\AppServiceProvider::class,
BookStack\App\Providers\AuthServiceProvider::class,
BookStack\App\Providers\EventServiceProvider::class,
BookStack\App\Providers\RouteServiceProvider::class,
BookStack\App\Providers\TranslationServiceProvider::class,
BookStack\App\Providers\ValidationRuleServiceProvider::class,
BookStack\App\Providers\ViewTweaksServiceProvider::class,
])->toArray(),
// Class Aliases
// This array of class aliases to be registered on application start.

View File

@ -53,7 +53,8 @@ return [
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache'),
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [

View File

@ -173,6 +173,8 @@ return [
// List of URIs that should not be collected
'except' => [
'/uploads/images/.*', // BookStack image requests
'/horizon/.*', // Laravel Horizon requests
'/telescope/.*', // Laravel Telescope requests
'/_debugbar/.*', // Laravel DebugBar requests

View File

@ -58,6 +58,7 @@ return [
'endpoint' => env('STORAGE_S3_ENDPOINT', null),
'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
'throw' => true,
'stream_reads' => false,
],
],

View File

@ -21,7 +21,8 @@ return [
// passwords are hashed using the Bcrypt algorithm. This will allow you
// to control the amount of time it takes to hash the given password.
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 10),
'rounds' => env('BCRYPT_ROUNDS', 12),
'verify' => true,
],
// Argon Options

View File

@ -4,6 +4,7 @@ use Monolog\Formatter\LineFormatter;
use Monolog\Handler\ErrorLogHandler;
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Processor\PsrLogMessageProcessor;
/**
* Logging configuration options.
@ -49,6 +50,7 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 14,
'replace_placeholders' => true,
],
'daily' => [
@ -56,6 +58,7 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 7,
'replace_placeholders' => true,
],
'stderr' => [
@ -65,16 +68,20 @@ return [
'with' => [
'stream' => 'php://stderr',
],
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => 'debug',
'facility' => LOG_USER,
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => 'debug',
'replace_placeholders' => true,
],
// Custom errorlog implementation that logs out a plain,
@ -88,6 +95,7 @@ return [
'formatter_with' => [
'format' => '%message%',
],
'replace_placeholders' => true,
],
'null' => [

View File

@ -40,6 +40,12 @@ return [
],
// Job batching
'batching' => [
'database' => 'mysql',
'table' => 'job_batches',
],
// Failed queue job logging
'failed' => [
'driver' => 'database-uuids',

View File

@ -85,4 +85,11 @@ return [
// do not enable this as other CSRF protection services are in place.
// Options: lax, strict, none
'same_site' => 'lax',
// Partitioned Cookies
// Setting this value to true will tie the cookie to the top-level site for
// a cross-site context. Partitioned cookies are accepted by the browser
// when flagged "secure" and the Same-Site attribute is set to "none".
'partitioned' => false,
];

View File

@ -2,7 +2,7 @@
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Tools\PermissionsUpdater;
use Illuminate\Console\Command;
@ -28,7 +28,7 @@ class CopyShelfPermissionsCommand extends Command
/**
* Execute the console command.
*/
public function handle(PermissionsUpdater $permissionsUpdater): int
public function handle(PermissionsUpdater $permissionsUpdater, BookshelfQueries $queries): int
{
$shelfSlug = $this->option('slug');
$cascadeAll = $this->option('all');
@ -51,11 +51,11 @@ class CopyShelfPermissionsCommand extends Command
return 0;
}
$shelves = Bookshelf::query()->get(['id']);
$shelves = $queries->start()->get(['id']);
}
if ($shelfSlug) {
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id']);
$shelves = $queries->start()->where('slug', '=', $shelfSlug)->get(['id']);
if ($shelves->count() === 0) {
$this->info('No shelves found with the given slug.');
}

View File

@ -1,49 +0,0 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Activity\CommentRepo;
use BookStack\Activity\Models\Comment;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RegenerateCommentContentCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:regenerate-comment-content
{--database= : The database connection to use}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Regenerate the stored HTML of all comments';
/**
* Execute the console command.
*/
public function handle(CommentRepo $commentRepo): int
{
$connection = DB::getDefaultConnection();
if ($this->option('database') !== null) {
DB::setDefaultConnection($this->option('database'));
}
Comment::query()->chunk(100, function ($comments) use ($commentRepo) {
foreach ($comments as $comment) {
$comment->html = $commentRepo->commentToHtml($comment->text);
$comment->save();
}
});
DB::setDefaultConnection($connection);
$this->comment('Comment HTML content has been regenerated');
return 0;
}
}

View File

@ -34,7 +34,7 @@ class RegenerateReferencesCommand extends Command
DB::setDefaultConnection($this->option('database'));
}
$references->updateForAllPages();
$references->updateForAll();
DB::setDefaultConnection($connection);

View File

@ -46,6 +46,9 @@ class UpdateUrlCommand extends Command
$columnsToUpdateByTable = [
'attachments' => ['path'],
'pages' => ['html', 'text', 'markdown'],
'chapters' => ['description_html'],
'books' => ['description_html'],
'bookshelves' => ['description_html'],
'images' => ['url'],
'settings' => ['value'],
'comments' => ['html', 'text'],

View File

@ -6,6 +6,7 @@ use BookStack\Api\ApiEntityListFormatter;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Http\ApiController;
@ -15,7 +16,8 @@ use Illuminate\Validation\ValidationException;
class BookApiController extends ApiController
{
public function __construct(
protected BookRepo $bookRepo
protected BookRepo $bookRepo,
protected BookQueries $queries,
) {
}
@ -24,7 +26,9 @@ class BookApiController extends ApiController
*/
public function list()
{
$books = Book::visible();
$books = $this->queries
->visibleForList()
->addSelect(['created_by', 'updated_by']);
return $this->apiListingResponse($books, [
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
@ -45,7 +49,7 @@ class BookApiController extends ApiController
$book = $this->bookRepo->create($requestData);
return response()->json($book);
return response()->json($this->forJsonDisplay($book));
}
/**
@ -56,9 +60,9 @@ class BookApiController extends ApiController
*/
public function read(string $id)
{
$book = Book::visible()
->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])
->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail(intval($id));
$book = $this->forJsonDisplay($book);
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
$contents = (new BookContents($book))->getTree(true, false)->all();
$contentsApiData = (new ApiEntityListFormatter($contents))
@ -83,13 +87,13 @@ class BookApiController extends ApiController
*/
public function update(Request $request, string $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('book-update', $book);
$requestData = $this->validate($request, $this->rules()['update']);
$book = $this->bookRepo->update($book, $requestData);
return response()->json($book);
return response()->json($this->forJsonDisplay($book));
}
/**
@ -100,7 +104,7 @@ class BookApiController extends ApiController
*/
public function delete(string $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('book-delete', $book);
$this->bookRepo->destroy($book);
@ -108,21 +112,35 @@ class BookApiController extends ApiController
return response('', 204);
}
protected function forJsonDisplay(Book $book): Book
{
$book = clone $book;
$book->unsetRelations()->refresh();
$book->load(['tags', 'cover']);
$book->makeVisible('description_html')
->setAttribute('description_html', $book->descriptionHtml());
return $book;
}
protected function rules(): array
{
return [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'default_template_id' => ['nullable', 'integer'],
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'default_template_id' => ['nullable', 'integer'],
],
];

View File

@ -6,7 +6,8 @@ use BookStack\Activity\ActivityQueries;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
@ -27,7 +28,9 @@ class BookController extends Controller
public function __construct(
protected ShelfContext $shelfContext,
protected BookRepo $bookRepo,
protected ReferenceFetcher $referenceFetcher
protected BookQueries $queries,
protected BookshelfQueries $shelfQueries,
protected ReferenceFetcher $referenceFetcher,
) {
}
@ -43,10 +46,12 @@ class BookController extends Controller
'updated_at' => trans('common.sort_updated_at'),
]);
$books = $this->bookRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
$recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
$popular = $this->bookRepo->getPopular(4);
$new = $this->bookRepo->getRecentlyCreated(4);
$books = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(18);
$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();
@ -71,7 +76,7 @@ class BookController extends Controller
$bookshelf = null;
if ($shelfSlug !== null) {
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
}
@ -93,7 +98,7 @@ class BookController extends Controller
$this->checkPermission('book-create-all');
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'description_html' => ['string', 'max:2000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'default_template_id' => ['nullable', 'integer'],
@ -101,7 +106,7 @@ class BookController extends Controller
$bookshelf = null;
if ($shelfSlug !== null) {
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
}
@ -120,7 +125,7 @@ class BookController extends Controller
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$book = $this->bookRepo->getBySlug($slug);
$book = $this->queries->findVisibleBySlugOrFail($slug);
$bookChildren = (new BookContents($book))->getTree(true);
$bookParentShelves = $book->shelves()->scopes('visible')->get();
@ -138,7 +143,7 @@ class BookController extends Controller
'bookParentShelves' => $bookParentShelves,
'watchOptions' => new UserEntityWatchOptions(user(), $book),
'activity' => $activities->entityActivity($book, 20, 1),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book),
]);
}
@ -147,7 +152,7 @@ class BookController extends Controller
*/
public function edit(string $slug)
{
$book = $this->bookRepo->getBySlug($slug);
$book = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('book-update', $book);
$this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()]));
@ -163,12 +168,12 @@ class BookController extends Controller
*/
public function update(Request $request, string $slug)
{
$book = $this->bookRepo->getBySlug($slug);
$book = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('book-update', $book);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'description_html' => ['string', 'max:2000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'default_template_id' => ['nullable', 'integer'],
@ -190,7 +195,7 @@ class BookController extends Controller
*/
public function showDelete(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-delete', $book);
$this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()]));
@ -204,7 +209,7 @@ class BookController extends Controller
*/
public function destroy(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-delete', $book);
$this->bookRepo->destroy($book);
@ -219,7 +224,7 @@ class BookController extends Controller
*/
public function showCopy(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-view', $book);
session()->flashInput(['name' => $book->name]);
@ -236,7 +241,7 @@ class BookController extends Controller
*/
public function copy(Request $request, Cloner $cloner, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-view', $book);
$this->checkPermission('book-create-all');
@ -252,7 +257,7 @@ class BookController extends Controller
*/
public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$this->checkOwnablePermission('book-delete', $book);
$this->checkPermission('bookshelf-create-all');

View File

@ -2,18 +2,17 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Http\ApiController;
use Throwable;
class BookExportApiController extends ApiController
{
protected $exportFormatter;
public function __construct(ExportFormatter $exportFormatter)
{
$this->exportFormatter = $exportFormatter;
public function __construct(
protected ExportFormatter $exportFormatter,
protected BookQueries $queries,
) {
$this->middleware('can:content-export');
}
@ -24,7 +23,7 @@ class BookExportApiController extends ApiController
*/
public function exportPdf(int $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail($id);
$pdfContent = $this->exportFormatter->bookToPdf($book);
return $this->download()->directly($pdfContent, $book->slug . '.pdf');
@ -37,7 +36,7 @@ class BookExportApiController extends ApiController
*/
public function exportHtml(int $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail($id);
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
return $this->download()->directly($htmlContent, $book->slug . '.html');
@ -48,7 +47,7 @@ class BookExportApiController extends ApiController
*/
public function exportPlainText(int $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail($id);
$textContent = $this->exportFormatter->bookToPlainText($book);
return $this->download()->directly($textContent, $book->slug . '.txt');
@ -59,7 +58,7 @@ class BookExportApiController extends ApiController
*/
public function exportMarkdown(int $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail($id);
$markdown = $this->exportFormatter->bookToMarkdown($book);
return $this->download()->directly($markdown, $book->slug . '.md');

View File

@ -2,23 +2,17 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Http\Controller;
use Throwable;
class BookExportController extends Controller
{
protected $bookRepo;
protected $exportFormatter;
/**
* BookExportController constructor.
*/
public function __construct(BookRepo $bookRepo, ExportFormatter $exportFormatter)
{
$this->bookRepo = $bookRepo;
$this->exportFormatter = $exportFormatter;
public function __construct(
protected BookQueries $queries,
protected ExportFormatter $exportFormatter,
) {
$this->middleware('can:content-export');
}
@ -29,7 +23,7 @@ class BookExportController extends Controller
*/
public function pdf(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$pdfContent = $this->exportFormatter->bookToPdf($book);
return $this->download()->directly($pdfContent, $bookSlug . '.pdf');
@ -42,7 +36,7 @@ class BookExportController extends Controller
*/
public function html(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
return $this->download()->directly($htmlContent, $bookSlug . '.html');
@ -53,7 +47,7 @@ class BookExportController extends Controller
*/
public function plainText(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$textContent = $this->exportFormatter->bookToPlainText($book);
return $this->download()->directly($textContent, $bookSlug . '.txt');
@ -64,7 +58,7 @@ class BookExportController extends Controller
*/
public function markdown(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$textContent = $this->exportFormatter->bookToMarkdown($book);
return $this->download()->directly($textContent, $bookSlug . '.md');

View File

@ -3,7 +3,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\BookSortMap;
use BookStack\Facades\Activity;
@ -12,11 +12,9 @@ use Illuminate\Http\Request;
class BookSortController extends Controller
{
protected $bookRepo;
public function __construct(BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
public function __construct(
protected BookQueries $queries,
) {
}
/**
@ -24,7 +22,7 @@ class BookSortController extends Controller
*/
public function show(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$bookChildren = (new BookContents($book))->getTree(false);
@ -40,7 +38,7 @@ class BookSortController extends Controller
*/
public function showItem(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$bookChildren = (new BookContents($book))->getTree();
return view('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
@ -51,7 +49,7 @@ class BookSortController extends Controller
*/
public function update(Request $request, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
// Return if no map sent

View File

@ -3,6 +3,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Http\ApiController;
use Exception;
@ -12,11 +13,10 @@ use Illuminate\Validation\ValidationException;
class BookshelfApiController extends ApiController
{
protected BookshelfRepo $bookshelfRepo;
public function __construct(BookshelfRepo $bookshelfRepo)
{
$this->bookshelfRepo = $bookshelfRepo;
public function __construct(
protected BookshelfRepo $bookshelfRepo,
protected BookshelfQueries $queries,
) {
}
/**
@ -24,7 +24,9 @@ class BookshelfApiController extends ApiController
*/
public function list()
{
$shelves = Bookshelf::visible();
$shelves = $this->queries
->visibleForList()
->addSelect(['created_by', 'updated_by']);
return $this->apiListingResponse($shelves, [
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
@ -48,7 +50,7 @@ class BookshelfApiController extends ApiController
$bookIds = $request->get('books', []);
$shelf = $this->bookshelfRepo->create($requestData, $bookIds);
return response()->json($shelf);
return response()->json($this->forJsonDisplay($shelf));
}
/**
@ -56,12 +58,14 @@ class BookshelfApiController extends ApiController
*/
public function read(string $id)
{
$shelf = Bookshelf::visible()->with([
'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
$shelf = $this->forJsonDisplay($shelf);
$shelf->load([
'createdBy', 'updatedBy', 'ownedBy',
'books' => function (BelongsToMany $query) {
$query->scopes('visible')->get(['id', 'name', 'slug']);
},
])->findOrFail($id);
]);
return response()->json($shelf);
}
@ -78,7 +82,7 @@ class BookshelfApiController extends ApiController
*/
public function update(Request $request, string $id)
{
$shelf = Bookshelf::visible()->findOrFail($id);
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('bookshelf-update', $shelf);
$requestData = $this->validate($request, $this->rules()['update']);
@ -86,7 +90,7 @@ class BookshelfApiController extends ApiController
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
return response()->json($shelf);
return response()->json($this->forJsonDisplay($shelf));
}
/**
@ -97,7 +101,7 @@ class BookshelfApiController extends ApiController
*/
public function delete(string $id)
{
$shelf = Bookshelf::visible()->findOrFail($id);
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->bookshelfRepo->destroy($shelf);
@ -105,22 +109,36 @@ class BookshelfApiController extends ApiController
return response('', 204);
}
protected function forJsonDisplay(Bookshelf $shelf): Bookshelf
{
$shelf = clone $shelf;
$shelf->unsetRelations()->refresh();
$shelf->load(['tags', 'cover']);
$shelf->makeVisible('description_html')
->setAttribute('description_html', $shelf->descriptionHtml());
return $shelf;
}
protected function rules(): array
{
return [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
],
];
}

View File

@ -4,7 +4,8 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityQueries;
use BookStack\Activity\Models\View;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
@ -18,15 +19,13 @@ use Illuminate\Validation\ValidationException;
class BookshelfController extends Controller
{
protected BookshelfRepo $shelfRepo;
protected ShelfContext $shelfContext;
protected ReferenceFetcher $referenceFetcher;
public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext, ReferenceFetcher $referenceFetcher)
{
$this->shelfRepo = $shelfRepo;
$this->shelfContext = $shelfContext;
$this->referenceFetcher = $referenceFetcher;
public function __construct(
protected BookshelfRepo $shelfRepo,
protected BookshelfQueries $queries,
protected BookQueries $bookQueries,
protected ShelfContext $shelfContext,
protected ReferenceFetcher $referenceFetcher,
) {
}
/**
@ -41,10 +40,15 @@ class BookshelfController extends Controller
'updated_at' => trans('common.sort_updated_at'),
]);
$shelves = $this->shelfRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
$recents = $this->isSignedIn() ? $this->shelfRepo->getRecentlyViewed(4) : false;
$popular = $this->shelfRepo->getPopular(4);
$new = $this->shelfRepo->getRecentlyCreated(4);
$shelves = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(18);
$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'));
@ -65,7 +69,7 @@ class BookshelfController extends Controller
public function create()
{
$this->checkPermission('bookshelf-create-all');
$books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
$books = $this->bookQueries->visibleForList()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
$this->setPageTitle(trans('entities.shelves_create'));
return view('shelves.create', ['books' => $books]);
@ -81,10 +85,10 @@ class BookshelfController extends Controller
{
$this->checkPermission('bookshelf-create-all');
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
]);
$bookIds = explode(',', $request->get('books', ''));
@ -100,7 +104,7 @@ class BookshelfController extends Controller
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-view', $shelf);
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
@ -129,7 +133,7 @@ class BookshelfController extends Controller
'view' => $view,
'activity' => $activities->entityActivity($shelf, 20, 1),
'listOptions' => $listOptions,
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($shelf),
]);
}
@ -138,11 +142,14 @@ class BookshelfController extends Controller
*/
public function edit(string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
$books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
$books = $this->bookQueries->visibleForList()
->whereNotIn('id', $shelfBookIds)
->orderBy('name')
->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
$this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));
@ -161,13 +168,13 @@ class BookshelfController extends Controller
*/
public function update(Request $request, string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
]);
if ($request->has('image_reset')) {
@ -187,7 +194,7 @@ class BookshelfController extends Controller
*/
public function showDelete(string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
@ -202,7 +209,7 @@ class BookshelfController extends Controller
*/
public function destroy(string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->shelfRepo->destroy($shelf);

View File

@ -2,8 +2,9 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\ApiController;
@ -15,23 +16,29 @@ class ChapterApiController extends ApiController
{
protected $rules = [
'create' => [
'book_id' => ['required', 'integer'],
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'priority' => ['integer'],
'book_id' => ['required', 'integer'],
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'priority' => ['integer'],
'default_template_id' => ['nullable', 'integer'],
],
'update' => [
'book_id' => ['integer'],
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'priority' => ['integer'],
'book_id' => ['integer'],
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'priority' => ['integer'],
'default_template_id' => ['nullable', 'integer'],
],
];
public function __construct(
protected ChapterRepo $chapterRepo
protected ChapterRepo $chapterRepo,
protected ChapterQueries $queries,
protected EntityQueries $entityQueries,
) {
}
@ -40,7 +47,8 @@ class ChapterApiController extends ApiController
*/
public function list()
{
$chapters = Chapter::visible();
$chapters = $this->queries->visibleForList()
->addSelect(['created_by', 'updated_by']);
return $this->apiListingResponse($chapters, [
'id', 'book_id', 'name', 'slug', 'description', 'priority',
@ -56,12 +64,12 @@ class ChapterApiController extends ApiController
$requestData = $this->validate($request, $this->rules['create']);
$bookId = $request->get('book_id');
$book = Book::visible()->findOrFail($bookId);
$book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
$this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->create($requestData, $book);
return response()->json($chapter->load(['tags']));
return response()->json($this->forJsonDisplay($chapter));
}
/**
@ -69,9 +77,17 @@ class ChapterApiController extends ApiController
*/
public function read(string $id)
{
$chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
$query->scopes('visible')->get(['id', 'name', 'slug']);
}])->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$chapter = $this->forJsonDisplay($chapter);
$chapter->load(['createdBy', 'updatedBy', 'ownedBy']);
// Note: More fields than usual here, for backwards compatibility,
// due to previously accidentally including more fields that desired.
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)
->addSelect(['created_by', 'updated_by', 'revision_count', 'editor'])
->get();
$chapter->setRelation('pages', $pages);
return response()->json($chapter);
}
@ -84,7 +100,7 @@ class ChapterApiController extends ApiController
public function update(Request $request, string $id)
{
$requestData = $this->validate($request, $this->rules()['update']);
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('chapter-update', $chapter);
if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
@ -93,7 +109,7 @@ class ChapterApiController extends ApiController
try {
$this->chapterRepo->move($chapter, "book:{$requestData['book_id']}");
} catch (Exception $exception) {
if ($exception instanceof PermissionsException) {
if ($exception instanceof PermissionsException) {
$this->showPermissionError();
}
@ -103,7 +119,7 @@ class ChapterApiController extends ApiController
$updatedChapter = $this->chapterRepo->update($chapter, $requestData);
return response()->json($updatedChapter->load(['tags']));
return response()->json($this->forJsonDisplay($updatedChapter));
}
/**
@ -112,11 +128,24 @@ class ChapterApiController extends ApiController
*/
public function delete(string $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->chapterRepo->destroy($chapter);
return response('', 204);
}
protected function forJsonDisplay(Chapter $chapter): Chapter
{
$chapter = clone $chapter;
$chapter->unsetRelations()->refresh();
$chapter->load(['tags']);
$chapter->makeVisible('description_html');
$chapter->setAttribute('description_html', $chapter->descriptionHtml());
$chapter->setAttribute('book_slug', $chapter->book()->first()->slug);
return $chapter;
}
}

View File

@ -5,6 +5,8 @@ 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;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
@ -22,13 +24,12 @@ use Throwable;
class ChapterController extends Controller
{
protected ChapterRepo $chapterRepo;
protected ReferenceFetcher $referenceFetcher;
public function __construct(ChapterRepo $chapterRepo, ReferenceFetcher $referenceFetcher)
{
$this->chapterRepo = $chapterRepo;
$this->referenceFetcher = $referenceFetcher;
public function __construct(
protected ChapterRepo $chapterRepo,
protected ChapterQueries $queries,
protected EntityQueries $entityQueries,
protected ReferenceFetcher $referenceFetcher,
) {
}
/**
@ -36,12 +37,15 @@ class ChapterController extends Controller
*/
public function create(string $bookSlug)
{
$book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
$book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('chapter-create', $book);
$this->setPageTitle(trans('entities.chapters_create'));
return view('chapters.create', ['book' => $book, 'current' => $book]);
return view('chapters.create', [
'book' => $book,
'current' => $book,
]);
}
/**
@ -51,14 +55,17 @@ class ChapterController extends Controller
*/
public function store(Request $request, string $bookSlug)
{
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'default_template_id' => ['nullable', 'integer'],
]);
$book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
$book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->create($request->all(), $book);
$chapter = $this->chapterRepo->create($validated, $book);
return redirect($chapter->getUrl());
}
@ -68,11 +75,12 @@ class ChapterController extends Controller
*/
public function show(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$sidebarTree = (new BookContents($chapter->book))->getTree();
$pages = $chapter->getVisiblePages();
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
$nextPreviousLocator = new NextPreviousContentLocator($chapter, $sidebarTree);
View::incrementFor($chapter);
@ -87,7 +95,7 @@ class ChapterController extends Controller
'pages' => $pages,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($chapter),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter),
]);
}
@ -96,7 +104,7 @@ class ChapterController extends Controller
*/
public function edit(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()]));
@ -111,10 +119,17 @@ class ChapterController extends Controller
*/
public function update(Request $request, string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'default_template_id' => ['nullable', 'integer'],
]);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->chapterRepo->update($chapter, $request->all());
$this->chapterRepo->update($chapter, $validated);
return redirect($chapter->getUrl());
}
@ -126,7 +141,7 @@ class ChapterController extends Controller
*/
public function showDelete(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()]));
@ -142,7 +157,7 @@ class ChapterController extends Controller
*/
public function destroy(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->chapterRepo->destroy($chapter);
@ -157,7 +172,7 @@ class ChapterController extends Controller
*/
public function showMove(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->setPageTitle(trans('entities.chapters_move_named', ['chapterName' => $chapter->getShortName()]));
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
@ -175,7 +190,7 @@ class ChapterController extends Controller
*/
public function move(Request $request, string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
@ -204,7 +219,7 @@ class ChapterController extends Controller
*/
public function showCopy(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
session()->flashInput(['name' => $chapter->name]);
@ -223,13 +238,13 @@ class ChapterController extends Controller
*/
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$entitySelection = $request->get('entity_selection') ?: null;
$newParentBook = $entitySelection ? $this->chapterRepo->findParentByIdentifier($entitySelection) : $chapter->getParent();
$newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent();
if (is_null($newParentBook)) {
if (!$newParentBook instanceof Book) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));
return redirect($chapter->getUrl('/copy'));
@ -249,7 +264,7 @@ class ChapterController extends Controller
*/
public function convertToBook(HierarchyTransformer $transformer, string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkPermission('book-create-all');

View File

@ -2,21 +2,17 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Http\ApiController;
use Throwable;
class ChapterExportApiController extends ApiController
{
protected $exportFormatter;
/**
* ChapterExportController constructor.
*/
public function __construct(ExportFormatter $exportFormatter)
{
$this->exportFormatter = $exportFormatter;
public function __construct(
protected ExportFormatter $exportFormatter,
protected ChapterQueries $queries,
) {
$this->middleware('can:content-export');
}
@ -27,7 +23,7 @@ class ChapterExportApiController extends ApiController
*/
public function exportPdf(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail($id);
$pdfContent = $this->exportFormatter->chapterToPdf($chapter);
return $this->download()->directly($pdfContent, $chapter->slug . '.pdf');
@ -40,7 +36,7 @@ class ChapterExportApiController extends ApiController
*/
public function exportHtml(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail($id);
$htmlContent = $this->exportFormatter->chapterToContainedHtml($chapter);
return $this->download()->directly($htmlContent, $chapter->slug . '.html');
@ -51,7 +47,7 @@ class ChapterExportApiController extends ApiController
*/
public function exportPlainText(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail($id);
$textContent = $this->exportFormatter->chapterToPlainText($chapter);
return $this->download()->directly($textContent, $chapter->slug . '.txt');
@ -62,7 +58,7 @@ class ChapterExportApiController extends ApiController
*/
public function exportMarkdown(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail($id);
$markdown = $this->exportFormatter->chapterToMarkdown($chapter);
return $this->download()->directly($markdown, $chapter->slug . '.md');

View File

@ -2,7 +2,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controller;
@ -10,16 +10,10 @@ use Throwable;
class ChapterExportController extends Controller
{
protected $chapterRepo;
protected $exportFormatter;
/**
* ChapterExportController constructor.
*/
public function __construct(ChapterRepo $chapterRepo, ExportFormatter $exportFormatter)
{
$this->chapterRepo = $chapterRepo;
$this->exportFormatter = $exportFormatter;
public function __construct(
protected ChapterQueries $queries,
protected ExportFormatter $exportFormatter,
) {
$this->middleware('can:content-export');
}
@ -31,7 +25,7 @@ class ChapterExportController extends Controller
*/
public function pdf(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$pdfContent = $this->exportFormatter->chapterToPdf($chapter);
return $this->download()->directly($pdfContent, $chapterSlug . '.pdf');
@ -45,7 +39,7 @@ class ChapterExportController extends Controller
*/
public function html(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$containedHtml = $this->exportFormatter->chapterToContainedHtml($chapter);
return $this->download()->directly($containedHtml, $chapterSlug . '.html');
@ -58,7 +52,7 @@ class ChapterExportController extends Controller
*/
public function plainText(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$chapterText = $this->exportFormatter->chapterToPlainText($chapter);
return $this->download()->directly($chapterText, $chapterSlug . '.txt');
@ -71,7 +65,7 @@ class ChapterExportController extends Controller
*/
public function markdown(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$chapterText = $this->exportFormatter->chapterToMarkdown($chapter);
return $this->download()->directly($chapterText, $chapterSlug . '.md');

View File

@ -2,9 +2,8 @@
namespace BookStack\Entities\Controllers;
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;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\ApiController;
@ -35,7 +34,9 @@ class PageApiController extends ApiController
];
public function __construct(
protected PageRepo $pageRepo
protected PageRepo $pageRepo,
protected PageQueries $queries,
protected EntityQueries $entityQueries,
) {
}
@ -44,7 +45,8 @@ class PageApiController extends ApiController
*/
public function list()
{
$pages = Page::visible();
$pages = $this->queries->visibleForList()
->addSelect(['created_by', 'updated_by', 'revision_count', 'editor']);
return $this->apiListingResponse($pages, [
'id', 'book_id', 'chapter_id', 'name', 'slug', 'priority',
@ -70,9 +72,9 @@ class PageApiController extends ApiController
$this->validate($request, $this->rules['create']);
if ($request->has('chapter_id')) {
$parent = Chapter::visible()->findOrFail($request->get('chapter_id'));
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
} else {
$parent = Book::visible()->findOrFail($request->get('book_id'));
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
}
$this->checkOwnablePermission('page-create', $parent);
@ -97,7 +99,7 @@ class PageApiController extends ApiController
*/
public function read(string $id)
{
$page = $this->pageRepo->getById($id, []);
$page = $this->queries->findVisibleByIdOrFail($id);
return response()->json($page->forJsonDisplay());
}
@ -113,14 +115,14 @@ class PageApiController extends ApiController
{
$requestData = $this->validate($request, $this->rules['update']);
$page = $this->pageRepo->getById($id, []);
$page = $this->queries->findVisibleByIdOrFail($id);
$this->checkOwnablePermission('page-update', $page);
$parent = null;
if ($request->has('chapter_id')) {
$parent = Chapter::visible()->findOrFail($request->get('chapter_id'));
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
} elseif ($request->has('book_id')) {
$parent = Book::visible()->findOrFail($request->get('book_id'));
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
}
if ($parent && !$parent->matches($page->getParent())) {
@ -148,7 +150,7 @@ class PageApiController extends ApiController
*/
public function delete(string $id)
{
$page = $this->pageRepo->getById($id, []);
$page = $this->queries->findVisibleByIdOrFail($id);
$this->checkOwnablePermission('page-delete', $page);
$this->pageRepo->destroy($page);

View File

@ -6,7 +6,9 @@ use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
@ -28,6 +30,8 @@ class PageController extends Controller
{
public function __construct(
protected PageRepo $pageRepo,
protected PageQueries $queries,
protected EntityQueries $entityQueries,
protected ReferenceFetcher $referenceFetcher
) {
}
@ -39,7 +43,12 @@ class PageController extends Controller
*/
public function create(string $bookSlug, string $chapterSlug = null)
{
$parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug);
if ($chapterSlug) {
$parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
} else {
$parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
}
$this->checkOwnablePermission('page-create', $parent);
// Redirect to draft edit screen if signed in
@ -66,7 +75,12 @@ class PageController extends Controller
'name' => ['required', 'string', 'max:255'],
]);
$parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug);
if ($chapterSlug) {
$parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
} else {
$parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
}
$this->checkOwnablePermission('page-create', $parent);
$page = $this->pageRepo->getNewDraftPage($parent);
@ -84,10 +98,10 @@ class PageController extends Controller
*/
public function editDraft(Request $request, string $bookSlug, int $pageId)
{
$draft = $this->pageRepo->getById($pageId);
$draft = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-create', $draft->getParent());
$editorData = new PageEditorData($draft, $this->pageRepo, $request->query('editor', ''));
$editorData = new PageEditorData($draft, $this->entityQueries, $request->query('editor', ''));
$this->setPageTitle(trans('entities.pages_edit_draft'));
return view('pages.edit', $editorData->getViewData());
@ -104,7 +118,7 @@ class PageController extends Controller
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
]);
$draftPage = $this->pageRepo->getById($pageId);
$draftPage = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-create', $draftPage->getParent());
$page = $this->pageRepo->publishDraft($draftPage, $request->all());
@ -121,11 +135,12 @@ class PageController extends Controller
public function show(string $bookSlug, string $pageSlug)
{
try {
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
} catch (NotFoundException $e) {
$page = $this->pageRepo->getByOldSlug($bookSlug, $pageSlug);
$revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
$page = $revision->page ?? null;
if ($page === null) {
if (is_null($page)) {
throw $e;
}
@ -155,7 +170,7 @@ class PageController extends Controller
'watchOptions' => new UserEntityWatchOptions(user(), $page),
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($page),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($page),
]);
}
@ -166,7 +181,7 @@ class PageController extends Controller
*/
public function getPageAjax(int $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->queries->findVisibleByIdOrFail($pageId);
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
$page->makeHidden(['book']);
@ -180,10 +195,10 @@ class PageController extends Controller
*/
public function edit(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$editorData = new PageEditorData($page, $this->pageRepo, $request->query('editor', ''));
$editorData = new PageEditorData($page, $this->entityQueries, $request->query('editor', ''));
if ($editorData->getWarnings()) {
$this->showWarningNotification(implode("\n", $editorData->getWarnings()));
}
@ -204,7 +219,7 @@ class PageController extends Controller
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
]);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->pageRepo->update($page, $request->all());
@ -219,7 +234,7 @@ class PageController extends Controller
*/
public function saveDraft(Request $request, int $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-update', $page);
if (!$this->isSignedIn()) {
@ -244,7 +259,7 @@ class PageController extends Controller
*/
public function redirectFromLink(int $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->queries->findVisibleByIdOrFail($pageId);
return redirect($page->getUrl());
}
@ -256,10 +271,12 @@ class PageController extends Controller
*/
public function showDelete(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()]));
$usedAsTemplate = Book::query()->where('default_template_id', '=', $page->id)->count() > 0;
$usedAsTemplate =
$this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
$this->entityQueries->chapters->start()->where('default_template_id', '=', $page->id)->count() > 0;
return view('pages.delete', [
'book' => $page->book,
@ -276,10 +293,12 @@ class PageController extends Controller
*/
public function showDeleteDraft(string $bookSlug, int $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-update', $page);
$this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()]));
$usedAsTemplate = Book::query()->where('default_template_id', '=', $page->id)->count() > 0;
$usedAsTemplate =
$this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
$this->entityQueries->chapters->start()->where('default_template_id', '=', $page->id)->count() > 0;
return view('pages.delete', [
'book' => $page->book,
@ -297,7 +316,7 @@ class PageController extends Controller
*/
public function destroy(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$parent = $page->getParent();
@ -314,7 +333,7 @@ class PageController extends Controller
*/
public function destroyDraft(string $bookSlug, int $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->queries->findVisibleByIdOrFail($pageId);
$book = $page->book;
$chapter = $page->chapter;
$this->checkOwnablePermission('page-update', $page);
@ -339,7 +358,9 @@ class PageController extends Controller
$query->scopes('visible');
};
$pages = Page::visible()->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
$pages = $this->queries->visibleForList()
->addSelect('updated_by')
->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
->orderBy('updated_at', 'desc')
->paginate(20)
->setPath(url('/pages/recently-updated'));
@ -361,7 +382,7 @@ class PageController extends Controller
*/
public function showMove(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('page-delete', $page);
@ -379,7 +400,7 @@ class PageController extends Controller
*/
public function move(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('page-delete', $page);
@ -408,7 +429,7 @@ class PageController extends Controller
*/
public function showCopy(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $page);
session()->flashInput(['name' => $page->name]);
@ -426,13 +447,13 @@ class PageController extends Controller
*/
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $page);
$entitySelection = $request->get('entity_selection') ?: null;
$newParent = $entitySelection ? $this->pageRepo->findParentByIdentifier($entitySelection) : $page->getParent();
$newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent();
if (is_null($newParent)) {
if (!$newParent instanceof Book && !$newParent instanceof Chapter) {
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect($page->getUrl('/copy'));

View File

@ -2,18 +2,17 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Http\ApiController;
use Throwable;
class PageExportApiController extends ApiController
{
protected $exportFormatter;
public function __construct(ExportFormatter $exportFormatter)
{
$this->exportFormatter = $exportFormatter;
public function __construct(
protected ExportFormatter $exportFormatter,
protected PageQueries $queries,
) {
$this->middleware('can:content-export');
}
@ -24,7 +23,7 @@ class PageExportApiController extends ApiController
*/
public function exportPdf(int $id)
{
$page = Page::visible()->findOrFail($id);
$page = $this->queries->findVisibleByIdOrFail($id);
$pdfContent = $this->exportFormatter->pageToPdf($page);
return $this->download()->directly($pdfContent, $page->slug . '.pdf');
@ -37,7 +36,7 @@ class PageExportApiController extends ApiController
*/
public function exportHtml(int $id)
{
$page = Page::visible()->findOrFail($id);
$page = $this->queries->findVisibleByIdOrFail($id);
$htmlContent = $this->exportFormatter->pageToContainedHtml($page);
return $this->download()->directly($htmlContent, $page->slug . '.html');
@ -48,7 +47,7 @@ class PageExportApiController extends ApiController
*/
public function exportPlainText(int $id)
{
$page = Page::visible()->findOrFail($id);
$page = $this->queries->findVisibleByIdOrFail($id);
$textContent = $this->exportFormatter->pageToPlainText($page);
return $this->download()->directly($textContent, $page->slug . '.txt');
@ -59,7 +58,7 @@ class PageExportApiController extends ApiController
*/
public function exportMarkdown(int $id)
{
$page = Page::visible()->findOrFail($id);
$page = $this->queries->findVisibleByIdOrFail($id);
$markdown = $this->exportFormatter->pageToMarkdown($page);
return $this->download()->directly($markdown, $page->slug . '.md');

View File

@ -2,7 +2,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Entities\Tools\PageContent;
use BookStack\Exceptions\NotFoundException;
@ -11,16 +11,10 @@ use Throwable;
class PageExportController extends Controller
{
protected $pageRepo;
protected $exportFormatter;
/**
* PageExportController constructor.
*/
public function __construct(PageRepo $pageRepo, ExportFormatter $exportFormatter)
{
$this->pageRepo = $pageRepo;
$this->exportFormatter = $exportFormatter;
public function __construct(
protected PageQueries $queries,
protected ExportFormatter $exportFormatter,
) {
$this->middleware('can:content-export');
}
@ -33,7 +27,7 @@ class PageExportController extends Controller
*/
public function pdf(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$page->html = (new PageContent($page))->render();
$pdfContent = $this->exportFormatter->pageToPdf($page);
@ -48,7 +42,7 @@ class PageExportController extends Controller
*/
public function html(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$page->html = (new PageContent($page))->render();
$containedHtml = $this->exportFormatter->pageToContainedHtml($page);
@ -62,7 +56,7 @@ class PageExportController extends Controller
*/
public function plainText(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$pageText = $this->exportFormatter->pageToPlainText($page);
return $this->download()->directly($pageText, $pageSlug . '.txt');
@ -75,7 +69,7 @@ class PageExportController extends Controller
*/
public function markdown(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$pageText = $this->exportFormatter->pageToMarkdown($page);
return $this->download()->directly($pageText, $pageSlug . '.md');

View File

@ -4,6 +4,7 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Models\PageRevision;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Repos\RevisionRepo;
use BookStack\Entities\Tools\PageContent;
@ -18,6 +19,7 @@ class PageRevisionController extends Controller
{
public function __construct(
protected PageRepo $pageRepo,
protected PageQueries $pageQueries,
protected RevisionRepo $revisionRepo,
) {
}
@ -29,7 +31,7 @@ class PageRevisionController extends Controller
*/
public function index(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
'id' => trans('entities.pages_revisions_sort_number')
]);
@ -60,7 +62,7 @@ class PageRevisionController extends Controller
*/
public function show(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
/** @var ?PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
if ($revision === null) {
@ -89,7 +91,7 @@ class PageRevisionController extends Controller
*/
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
/** @var ?PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
if ($revision === null) {
@ -121,7 +123,7 @@ class PageRevisionController extends Controller
*/
public function restore(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$page = $this->pageRepo->restoreRevision($page, $revisionId);
@ -136,7 +138,7 @@ class PageRevisionController extends Controller
*/
public function destroy(string $bookSlug, string $pageSlug, int $revId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$revision = $page->revisions()->where('id', '=', $revId)->first();
@ -162,7 +164,7 @@ class PageRevisionController extends Controller
*/
public function destroyUserDraft(string $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
$this->revisionRepo->deleteDraftsForCurrentUser($page);
return response('', 200);

View File

@ -2,6 +2,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controller;
@ -9,14 +10,10 @@ use Illuminate\Http\Request;
class PageTemplateController extends Controller
{
protected $pageRepo;
/**
* PageTemplateController constructor.
*/
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
public function __construct(
protected PageRepo $pageRepo,
protected PageQueries $pageQueries,
) {
}
/**
@ -26,7 +23,19 @@ class PageTemplateController extends Controller
{
$page = $request->get('page', 1);
$search = $request->get('search', '');
$templates = $this->pageRepo->getTemplates(10, $page, $search);
$count = 10;
$query = $this->pageQueries->visibleTemplates()
->orderBy('name', 'asc')
->skip(($page - 1) * $count)
->take($count);
if ($search) {
$query->where('name', 'like', '%' . $search . '%');
}
$templates = $query->paginate($count, ['*'], 'page', $page);
$templates->withPath('/templates');
if ($search) {
$templates->appends(['search' => $search]);
@ -44,7 +53,7 @@ class PageTemplateController extends Controller
*/
public function get(int $templateId)
{
$page = $this->pageRepo->getById($templateId);
$page = $this->pageQueries->findVisibleByIdOrFail($templateId);
if (!$page->template) {
throw new NotFoundException();

View File

@ -116,9 +116,9 @@ class RecycleBinController extends Controller
*
* @throws \Exception
*/
public function empty()
public function empty(TrashCan $trash)
{
$deleteCount = (new TrashCan())->empty();
$deleteCount = $trash->empty();
$this->logActivity(ActivityType::RECYCLE_BIN_EMPTY);
$this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));

View File

@ -26,11 +26,12 @@ use Illuminate\Support\Collection;
class Book extends Entity implements HasCoverImage
{
use HasFactory;
use HasHtmlDescription;
public $searchFactor = 1.2;
public float $searchFactor = 1.2;
protected $fillable = ['name', 'description'];
protected $hidden = ['pivot', 'image_id', 'deleted_at'];
protected $fillable = ['name'];
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
/**
* Get the url for this book.
@ -116,20 +117,11 @@ class Book extends Entity implements HasCoverImage
/**
* Get the direct child items within this book.
*/
public function getDirectChildren(): Collection
public function getDirectVisibleChildren(): Collection
{
$pages = $this->directPages()->scopes('visible')->get();
$chapters = $this->chapters()->scopes('visible')->get();
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}
/**
* Get a visible book by its slug.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlug(string $slug): self
{
return static::visible()->where('slug', '=', $slug)->firstOrFail();
}
}

View File

@ -13,38 +13,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property int $priority
* @property string $book_slug
* @property Book $book
*
* @method Builder whereSlugs(string $bookSlug, string $childSlug)
*/
abstract class BookChild extends Entity
{
protected static function boot()
{
parent::boot();
// Load book slugs onto these models by default during query-time
static::addGlobalScope('book_slug', function (Builder $builder) {
$builder->addSelect(['book_slug' => function ($builder) {
$builder->select('slug')
->from('books')
->whereColumn('books.id', '=', 'book_id');
}]);
});
}
/**
* Scope a query to find items where the child has the given childSlug
* where its parent has the bookSlug.
*/
public function scopeWhereSlugs(Builder $query, string $bookSlug, string $childSlug)
{
return $query->with('book')
->whereHas('book', function (Builder $query) use ($bookSlug) {
$query->where('slug', '=', $bookSlug);
})
->where('slug', '=', $childSlug);
}
/**
* Get the book this page sits in.
*/
@ -65,7 +36,7 @@ abstract class BookChild extends Entity
$this->refresh();
if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityPageReferences($this, $oldUrl);
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
}
// Update all child pages if a chapter

View File

@ -11,14 +11,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Bookshelf extends Entity implements HasCoverImage
{
use HasFactory;
use HasHtmlDescription;
protected $table = 'bookshelves';
public $searchFactor = 1.2;
public float $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'image_id'];
protected $hidden = ['image_id', 'deleted_at'];
protected $hidden = ['image_id', 'deleted_at', 'description_html'];
/**
* Get the books in this shelf.

View File

@ -2,6 +2,7 @@
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
@ -10,16 +11,18 @@ use Illuminate\Support\Collection;
* Class Chapter.
*
* @property Collection<Page> $pages
* @property string $description
* @property ?int $default_template_id
* @property ?Page $defaultTemplate
*/
class Chapter extends BookChild
{
use HasFactory;
use HasHtmlDescription;
public $searchFactor = 1.2;
public float $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'priority'];
protected $hidden = ['pivot', 'deleted_at'];
protected $hidden = ['pivot', 'deleted_at', 'description_html'];
/**
* Get the pages that this chapter contains.
@ -47,6 +50,14 @@ class Chapter extends BookChild
return url('/' . implode('/', $parts));
}
/**
* Get the Page that is used as default template for newly created pages within this Chapter.
*/
public function defaultTemplate(): BelongsTo
{
return $this->belongsTo(Page::class, 'default_template_id');
}
/**
* Get the visible pages in this chapter.
*/
@ -58,13 +69,4 @@ class Chapter extends BookChild
->orderBy('priority', 'asc')
->get();
}
/**
* Get a visible chapter by its book and page slugs.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlugs(string $bookSlug, string $chapterSlug): self
{
return static::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
}
}

View File

@ -57,12 +57,17 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/**
* @var string - Name of property where the main text content is found
*/
public $textField = 'description';
public string $textField = 'description';
/**
* @var string - Name of the property where the main HTML content is found
*/
public string $htmlField = 'description_html';
/**
* @var float - Multiplier for search indexing.
*/
public $searchFactor = 1.0;
public float $searchFactor = 1.0;
/**
* Get the entities that are visible to the current user.

View File

@ -0,0 +1,21 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Util\HtmlContentFilter;
/**
* @property string $description
* @property string $description_html
*/
trait HasHtmlDescription
{
/**
* Get the HTML description for this book.
*/
public function descriptionHtml(): string
{
$html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
return HtmlContentFilter::removeScriptsFromHtmlString($html);
}
}

View File

@ -32,12 +32,10 @@ class Page extends BookChild
{
use HasFactory;
public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at', 'priority'];
public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at', 'priority'];
protected $fillable = ['name', 'priority'];
public $textField = 'text';
public string $textField = 'text';
public string $htmlField = 'html';
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
@ -144,13 +142,4 @@ class Page extends BookChild
return $refreshed;
}
/**
* Get a visible page by its book and page slugs.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlugs(string $bookSlug, string $pageSlug): self
{
return static::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail();
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Book;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder;
class BookQueries implements ProvidesEntityQueries
{
protected static array $listAttributes = [
'id', 'slug', 'name', 'description',
'created_at', 'updated_at', 'image_id', 'owned_by',
];
public function start(): Builder
{
return Book::query();
}
public function findVisibleById(int $id): ?Book
{
return $this->start()->scopes('visible')->find($id);
}
public function findVisibleByIdOrFail(int $id): Book
{
return $this->start()->scopes('visible')->findOrFail($id);
}
public function findVisibleBySlugOrFail(string $slug): Book
{
/** @var ?Book $book */
$book = $this->start()
->scopes('visible')
->where('slug', '=', $slug)
->first();
if ($book === null) {
throw new NotFoundException(trans('errors.book_not_found'));
}
return $book;
}
public function visibleForList(): Builder
{
return $this->start()->scopes('visible')
->select(static::$listAttributes);
}
public function visibleForListWithCover(): Builder
{
return $this->visibleForList()->with('cover');
}
public function recentlyViewedForCurrentUser(): Builder
{
return $this->visibleForList()
->scopes('withLastView')
->having('last_viewed_at', '>', 0)
->orderBy('last_viewed_at', 'desc');
}
public function popularForList(): Builder
{
return $this->visibleForList()
->scopes('withViewCount')
->having('view_count', '>', 0)
->orderBy('view_count', 'desc');
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder;
class BookshelfQueries implements ProvidesEntityQueries
{
protected static array $listAttributes = [
'id', 'slug', 'name', 'description',
'created_at', 'updated_at', 'image_id', 'owned_by',
];
public function start(): Builder
{
return Bookshelf::query();
}
public function findVisibleById(int $id): ?Bookshelf
{
return $this->start()->scopes('visible')->find($id);
}
public function findVisibleByIdOrFail(int $id): Bookshelf
{
$shelf = $this->findVisibleById($id);
if (is_null($shelf)) {
throw new NotFoundException(trans('errors.bookshelf_not_found'));
}
return $shelf;
}
public function findVisibleBySlugOrFail(string $slug): Bookshelf
{
/** @var ?Bookshelf $shelf */
$shelf = $this->start()
->scopes('visible')
->where('slug', '=', $slug)
->first();
if ($shelf === null) {
throw new NotFoundException(trans('errors.bookshelf_not_found'));
}
return $shelf;
}
public function visibleForList(): Builder
{
return $this->start()->scopes('visible')->select(static::$listAttributes);
}
public function visibleForListWithCover(): Builder
{
return $this->visibleForList()->with('cover');
}
public function recentlyViewedForCurrentUser(): Builder
{
return $this->visibleForList()
->scopes('withLastView')
->having('last_viewed_at', '>', 0)
->orderBy('last_viewed_at', 'desc');
}
public function popularForList(): Builder
{
return $this->visibleForList()
->scopes('withViewCount')
->having('view_count', '>', 0)
->orderBy('view_count', 'desc');
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Chapter;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder;
class ChapterQueries implements ProvidesEntityQueries
{
protected static array $listAttributes = [
'id', 'slug', 'name', 'description', 'priority',
'book_id', 'created_at', 'updated_at', 'owned_by',
];
public function start(): Builder
{
return Chapter::query();
}
public function findVisibleById(int $id): ?Chapter
{
return $this->start()->scopes('visible')->find($id);
}
public function findVisibleByIdOrFail(int $id): Chapter
{
return $this->start()->scopes('visible')->findOrFail($id);
}
public function findVisibleBySlugsOrFail(string $bookSlug, string $chapterSlug): Chapter
{
/** @var ?Chapter $chapter */
$chapter = $this->start()
->scopes('visible')
->with('book')
->whereHas('book', function (Builder $query) use ($bookSlug) {
$query->where('slug', '=', $bookSlug);
})
->where('slug', '=', $chapterSlug)
->first();
if (is_null($chapter)) {
throw new NotFoundException(trans('errors.chapter_not_found'));
}
return $chapter;
}
public function usingSlugs(string $bookSlug, string $chapterSlug): Builder
{
return $this->start()
->where('slug', '=', $chapterSlug)
->whereHas('book', function (Builder $query) use ($bookSlug) {
$query->where('slug', '=', $bookSlug);
});
}
public function visibleForList(): Builder
{
return $this->start()
->scopes('visible')
->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) {
$builder->select('slug')
->from('books')
->whereColumn('books.id', '=', 'chapters.book_id');
}]));
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
use InvalidArgumentException;
class EntityQueries
{
public function __construct(
public BookshelfQueries $shelves,
public BookQueries $books,
public ChapterQueries $chapters,
public PageQueries $pages,
public PageRevisionQueries $revisions,
) {
}
/**
* Find an entity via an identifier string in the format:
* {type}:{id}
* Example: (book:5).
*/
public function findVisibleByStringIdentifier(string $identifier): ?Entity
{
$explodedId = explode(':', $identifier);
$entityType = $explodedId[0];
$entityId = intval($explodedId[1]);
$queries = $this->getQueriesForType($entityType);
return $queries->findVisibleById($entityId);
}
/**
* Start a query of visible entities of the given type,
* suitable for listing display.
*/
public function visibleForList(string $entityType): Builder
{
$queries = $this->getQueriesForType($entityType);
return $queries->visibleForList();
}
protected function getQueriesForType(string $type): ProvidesEntityQueries
{
/** @var ?ProvidesEntityQueries $queries */
$queries = match ($type) {
'page' => $this->pages,
'chapter' => $this->chapters,
'book' => $this->books,
'bookshelf' => $this->shelves,
default => null,
};
if (is_null($queries)) {
throw new InvalidArgumentException("No entity query class configured for {$type}");
}
return $queries;
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\EntityProvider;
use BookStack\Permissions\PermissionApplicator;
abstract class EntityQuery
{
protected function permissionService(): PermissionApplicator
{
return app()->make(PermissionApplicator::class);
}
protected function entityProvider(): EntityProvider
{
return app()->make(EntityProvider::class);
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder;
class PageQueries implements ProvidesEntityQueries
{
protected static array $contentAttributes = [
'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
'template', 'html', 'text', 'created_at', 'updated_at', 'priority',
'created_by', 'updated_by', 'owned_by',
];
protected static array $listAttributes = [
'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
'template', 'text', 'created_at', 'updated_at', 'priority', 'owned_by',
];
public function start(): Builder
{
return Page::query();
}
public function findVisibleById(int $id): ?Page
{
return $this->start()->scopes('visible')->find($id);
}
public function findVisibleByIdOrFail(int $id): Page
{
$page = $this->findVisibleById($id);
if (is_null($page)) {
throw new NotFoundException(trans('errors.page_not_found'));
}
return $page;
}
public function findVisibleBySlugsOrFail(string $bookSlug, string $pageSlug): Page
{
/** @var ?Page $page */
$page = $this->start()->with('book')
->scopes('visible')
->whereHas('book', function (Builder $query) use ($bookSlug) {
$query->where('slug', '=', $bookSlug);
})
->where('slug', '=', $pageSlug)
->first();
if (is_null($page)) {
throw new NotFoundException(trans('errors.page_not_found'));
}
return $page;
}
public function usingSlugs(string $bookSlug, string $pageSlug): Builder
{
return $this->start()
->where('slug', '=', $pageSlug)
->whereHas('book', function (Builder $query) use ($bookSlug) {
$query->where('slug', '=', $bookSlug);
});
}
public function visibleForList(): Builder
{
return $this->start()
->scopes('visible')
->select($this->mergeBookSlugForSelect(static::$listAttributes));
}
public function visibleForChapterList(int $chapterId): Builder
{
return $this->visibleForList()
->where('chapter_id', '=', $chapterId)
->orderBy('draft', 'desc')
->orderBy('priority', 'asc');
}
public function visibleWithContents(): Builder
{
return $this->start()
->scopes('visible')
->select($this->mergeBookSlugForSelect(static::$contentAttributes));
}
public function currentUserDraftsForList(): Builder
{
return $this->visibleForList()
->where('draft', '=', true)
->where('created_by', '=', user()->id);
}
public function visibleTemplates(): Builder
{
return $this->visibleForList()
->where('template', '=', true);
}
protected function mergeBookSlugForSelect(array $columns): array
{
return array_merge($columns, ['book_slug' => function ($builder) {
$builder->select('slug')
->from('books')
->whereColumn('books.id', '=', 'pages.book_id');
}]);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\PageRevision;
use Illuminate\Database\Eloquent\Builder;
class PageRevisionQueries
{
public function start(): Builder
{
return PageRevision::query();
}
public function findLatestVersionBySlugs(string $bookSlug, string $pageSlug): ?PageRevision
{
return PageRevision::query()
->whereHas('page', function (Builder $query) {
$query->scopes('visible');
})
->where('slug', '=', $pageSlug)
->where('type', '=', 'version')
->where('book_slug', '=', $bookSlug)
->orderBy('created_at', 'desc')
->first();
}
public function findLatestCurrentUserDraftsForPageId(int $pageId): ?PageRevision
{
/** @var ?PageRevision $revision */
$revision = $this->latestCurrentUserDraftsForPageId($pageId)->first();
return $revision;
}
public function latestCurrentUserDraftsForPageId(int $pageId): Builder
{
return $this->start()
->where('created_by', '=', user()->id)
->where('type', 'update_draft')
->where('page_id', '=', $pageId)
->orderBy('created_at', 'desc');
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Activity\Models\View;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class Popular extends EntityQuery
{
public function run(int $count, int $page, array $filterModels = null)
{
$query = $this->permissionService()
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');
if ($filterModels) {
$query->whereIn('viewable_type', $this->entityProvider()->getMorphClasses($filterModels));
}
$entities = $query->with('viewable')
->skip($count * ($page - 1))
->take($count)
->get()
->pluck('viewable')
->filter();
$this->loadBooksForChildren($entities);
return $entities;
}
protected function loadBooksForChildren(Collection $entities)
{
$bookChildren = $entities->filter(fn(Entity $entity) => $entity instanceof BookChild);
$eloquent = (new \Illuminate\Database\Eloquent\Collection($bookChildren));
$eloquent->load(['book' => function (BelongsTo $query) {
$query->scopes('visible');
}]);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
/**
* Interface for our classes which provide common queries for our
* entity objects. Ideally all queries for entities should run through
* these classes.
* Any added methods should return a builder instances to allow extension
* via building on the query, unless the method starts with 'find'
* in which case an entity object should be returned.
* (nullable unless it's a *OrFail method).
*/
interface ProvidesEntityQueries
{
/**
* Start a new query for this entity type.
*/
public function start(): Builder;
/**
* Find the entity of the given ID, or return null if not found.
*/
public function findVisibleById(int $id): ?Entity;
/**
* Start a query for items that are visible, with selection
* configured for list display of this item.
*/
public function visibleForList(): Builder;
}

View File

@ -0,0 +1,42 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Activity\Models\View;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class QueryPopular
{
public function __construct(
protected PermissionApplicator $permissions,
protected EntityProvider $entityProvider,
protected MixedEntityListLoader $listLoader,
) {
}
public function run(int $count, int $page, array $filterModels = null): Collection
{
$query = $this->permissions
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');
if ($filterModels) {
$query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels));
}
$views = $query
->skip($count * ($page - 1))
->take($count)
->get();
$this->listLoader->loadIntoRelations($views->all(), 'viewable', true);
return $views->pluck('viewable')->filter();
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Activity\Models\View;
use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator;
use Illuminate\Support\Collection;
class QueryRecentlyViewed
{
public function __construct(
protected PermissionApplicator $permissions,
protected MixedEntityListLoader $listLoader,
) {
}
public function run(int $count, int $page): Collection
{
$user = user();
if ($user->isGuest()) {
return collect();
}
$query = $this->permissions->restrictEntityRelationQuery(
View::query(),
'views',
'viewable_id',
'viewable_type'
)
->orderBy('views.updated_at', 'desc')
->where('user_id', '=', user()->id);
$views = $query
->skip(($page - 1) * $count)
->take($count)
->get();
$this->listLoader->loadIntoRelations($views->all(), 'viewable', false);
return $views->pluck('viewable')->filter();
}
}

View File

@ -3,10 +3,18 @@
namespace BookStack\Entities\Queries;
use BookStack\Activity\Models\Favourite;
use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator;
use Illuminate\Database\Query\JoinClause;
class TopFavourites extends EntityQuery
class QueryTopFavourites
{
public function __construct(
protected PermissionApplicator $permissions,
protected MixedEntityListLoader $listLoader,
) {
}
public function run(int $count, int $skip = 0)
{
$user = user();
@ -14,7 +22,7 @@ class TopFavourites extends EntityQuery
return collect();
}
$query = $this->permissionService()
$query = $this->permissions
->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type')
->select('favourites.*')
->leftJoin('views', function (JoinClause $join) {
@ -25,11 +33,13 @@ class TopFavourites extends EntityQuery
->orderBy('views.views', 'desc')
->where('favourites.user_id', '=', user()->id);
return $query->with('favouritable')
$favourites = $query
->skip($skip)
->take($count)
->get()
->pluck('favouritable')
->filter();
->get();
$this->listLoader->loadIntoRelations($favourites->all(), 'favouritable', false);
return $favourites->pluck('favouritable')->filter();
}
}

View File

@ -1,33 +0,0 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Activity\Models\View;
use Illuminate\Support\Collection;
class RecentlyViewed extends EntityQuery
{
public function run(int $count, int $page): Collection
{
$user = user();
if ($user === null || $user->isGuest()) {
return collect();
}
$query = $this->permissionService()->restrictEntityRelationQuery(
View::query(),
'views',
'viewable_id',
'viewable_type'
)
->orderBy('views.updated_at', 'desc')
->where('user_id', '=', user()->id);
return $query->with('viewable')
->skip(($page - 1) * $count)
->take($count)
->get()
->pluck('viewable')
->filter();
}
}

View File

@ -3,24 +3,28 @@
namespace BookStack\Entities\Repos;
use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
use BookStack\Entities\Models\HasHtmlDescription;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater;
use BookStack\Uploads\ImageRepo;
use BookStack\Util\HtmlDescriptionFilter;
use Illuminate\Http\UploadedFile;
class BaseRepo
{
protected TagRepo $tagRepo;
protected ImageRepo $imageRepo;
protected ReferenceUpdater $referenceUpdater;
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater)
{
$this->tagRepo = $tagRepo;
$this->imageRepo = $imageRepo;
$this->referenceUpdater = $referenceUpdater;
public function __construct(
protected TagRepo $tagRepo,
protected ImageRepo $imageRepo,
protected ReferenceUpdater $referenceUpdater,
protected ReferenceStore $referenceStore,
protected PageQueries $pageQueries,
) {
}
/**
@ -29,6 +33,7 @@ class BaseRepo
public function create(Entity $entity, array $input)
{
$entity->fill($input);
$this->updateDescription($entity, $input);
$entity->forceFill([
'created_by' => user()->id,
'updated_by' => user()->id,
@ -44,6 +49,7 @@ class BaseRepo
$entity->refresh();
$entity->rebuildPermissions();
$entity->indexForSearch();
$this->referenceStore->updateForEntity($entity);
}
/**
@ -54,6 +60,7 @@ class BaseRepo
$oldUrl = $entity->getUrl();
$entity->fill($input);
$this->updateDescription($entity, $input);
$entity->updated_by = user()->id;
if ($entity->isDirty('name') || empty($entity->slug)) {
@ -69,9 +76,10 @@ class BaseRepo
$entity->rebuildPermissions();
$entity->indexForSearch();
$this->referenceStore->updateForEntity($entity);
if ($oldUrl !== $entity->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
$this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
}
}
@ -99,4 +107,47 @@ class BaseRepo
$entity->save();
}
}
/**
* Update the default page template used for this item.
* Checks that, if changing, the provided value is a valid template and the user
* has visibility of the provided page template id.
*/
public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void
{
$changing = $templateId !== intval($entity->default_template_id);
if (!$changing) {
return;
}
if ($templateId === 0) {
$entity->default_template_id = null;
$entity->save();
return;
}
$templateExists = $this->pageQueries->visibleTemplates()
->where('id', '=', $templateId)
->exists();
$entity->default_template_id = $templateExists ? $templateId : null;
$entity->save();
}
protected function updateDescription(Entity $entity, array $input): void
{
if (!in_array(HasHtmlDescription::class, class_uses($entity))) {
return;
}
/** @var HasHtmlDescription $entity */
if (isset($input['description_html'])) {
$entity->description_html = HtmlDescriptionFilter::filterFromString($input['description_html']);
$entity->description = html_entity_decode(strip_tags($input['description_html']));
} else if (isset($input['description'])) {
$entity->description = $input['description'];
$entity->description_html = '';
$entity->description_html = $entity->descriptionHtml();
}
}
}

View File

@ -5,79 +5,23 @@ namespace BookStack\Entities\Repos;
use BookStack\Activity\ActivityType;
use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BookRepo
{
public function __construct(
protected BaseRepo $baseRepo,
protected TagRepo $tagRepo,
protected ImageRepo $imageRepo
protected ImageRepo $imageRepo,
protected TrashCan $trashCan,
) {
}
/**
* Get all books in a paginated format.
*/
public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
{
return Book::visible()->with('cover')->orderBy($sort, $order)->paginate($count);
}
/**
* Get the books that were most recently viewed by this user.
*/
public function getRecentlyViewed(int $count = 20): Collection
{
return Book::visible()->withLastView()
->having('last_viewed_at', '>', 0)
->orderBy('last_viewed_at', 'desc')
->take($count)->get();
}
/**
* Get the most popular books in the system.
*/
public function getPopular(int $count = 20): Collection
{
return Book::visible()->withViewCount()
->having('view_count', '>', 0)
->orderBy('view_count', 'desc')
->take($count)->get();
}
/**
* Get the most recently created books from the system.
*/
public function getRecentlyCreated(int $count = 20): Collection
{
return Book::visible()->orderBy('created_at', 'desc')
->take($count)->get();
}
/**
* Get a book by its slug.
*/
public function getBySlug(string $slug): Book
{
$book = Book::visible()->where('slug', '=', $slug)->first();
if ($book === null) {
throw new NotFoundException(trans('errors.book_not_found'));
}
return $book;
}
/**
* Create a new book in the system.
*/
@ -86,7 +30,7 @@ class BookRepo
$book = new Book();
$this->baseRepo->create($book, $input);
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
$this->updateBookDefaultTemplate($book, intval($input['default_template_id'] ?? null));
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::BOOK_CREATE, $book);
return $book;
@ -100,7 +44,7 @@ class BookRepo
$this->baseRepo->update($book, $input);
if (array_key_exists('default_template_id', $input)) {
$this->updateBookDefaultTemplate($book, intval($input['default_template_id']));
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id']));
}
if (array_key_exists('image', $input)) {
@ -112,33 +56,6 @@ class BookRepo
return $book;
}
/**
* Update the default page template used for this book.
* Checks that, if changing, the provided value is a valid template and the user
* has visibility of the provided page template id.
*/
protected function updateBookDefaultTemplate(Book $book, int $templateId): void
{
$changing = $templateId !== intval($book->default_template_id);
if (!$changing) {
return;
}
if ($templateId === 0) {
$book->default_template_id = null;
$book->save();
return;
}
$templateExists = Page::query()->visible()
->where('template', '=', true)
->where('id', '=', $templateId)
->exists();
$book->default_template_id = $templateExists ? $templateId : null;
$book->save();
}
/**
* Update the given book's cover image, or clear it.
*
@ -157,10 +74,9 @@ class BookRepo
*/
public function destroy(Book $book)
{
$trashCan = new TrashCan();
$trashCan->softDestroyBook($book);
$this->trashCan->softDestroyBook($book);
Activity::add(ActivityType::BOOK_DELETE, $book);
$trashCan->autoClearOld();
$this->trashCan->autoClearOld();
}
}

View File

@ -3,81 +3,19 @@
namespace BookStack\Entities\Repos;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class BookshelfRepo
{
protected $baseRepo;
/**
* BookshelfRepo constructor.
*/
public function __construct(BaseRepo $baseRepo)
{
$this->baseRepo = $baseRepo;
}
/**
* Get all bookshelves in a paginated format.
*/
public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
{
return Bookshelf::visible()
->with(['visibleBooks', 'cover'])
->orderBy($sort, $order)
->paginate($count);
}
/**
* Get the bookshelves that were most recently viewed by this user.
*/
public function getRecentlyViewed(int $count = 20): Collection
{
return Bookshelf::visible()->withLastView()
->having('last_viewed_at', '>', 0)
->orderBy('last_viewed_at', 'desc')
->take($count)->get();
}
/**
* Get the most popular bookshelves in the system.
*/
public function getPopular(int $count = 20): Collection
{
return Bookshelf::visible()->withViewCount()
->having('view_count', '>', 0)
->orderBy('view_count', 'desc')
->take($count)->get();
}
/**
* Get the most recently created bookshelves from the system.
*/
public function getRecentlyCreated(int $count = 20): Collection
{
return Bookshelf::visible()->orderBy('created_at', 'desc')
->take($count)->get();
}
/**
* Get a shelf by its slug.
*/
public function getBySlug(string $slug): Bookshelf
{
$shelf = Bookshelf::visible()->where('slug', '=', $slug)->first();
if ($shelf === null) {
throw new NotFoundException(trans('errors.bookshelf_not_found'));
}
return $shelf;
public function __construct(
protected BaseRepo $baseRepo,
protected BookQueries $bookQueries,
protected TrashCan $trashCan,
) {
}
/**
@ -124,7 +62,7 @@ class BookshelfRepo
return intval($id);
});
$syncData = Book::visible()
$syncData = $this->bookQueries->visibleForList()
->whereIn('id', $bookIds)
->pluck('id')
->mapWithKeys(function ($bookId) use ($numericIDs) {
@ -141,9 +79,8 @@ class BookshelfRepo
*/
public function destroy(Bookshelf $shelf)
{
$trashCan = new TrashCan();
$trashCan->softDestroyShelf($shelf);
$this->trashCan->softDestroyShelf($shelf);
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
$trashCan->autoClearOld();
$this->trashCan->autoClearOld();
}
}

View File

@ -5,11 +5,10 @@ namespace BookStack\Entities\Repos;
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\Entities\Tools\BookContents;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use Exception;
@ -17,26 +16,12 @@ use Exception;
class ChapterRepo
{
public function __construct(
protected BaseRepo $baseRepo
protected BaseRepo $baseRepo,
protected EntityQueries $entityQueries,
protected TrashCan $trashCan,
) {
}
/**
* Get a chapter via the slug.
*
* @throws NotFoundException
*/
public function getBySlug(string $bookSlug, string $chapterSlug): Chapter
{
$chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->first();
if ($chapter === null) {
throw new NotFoundException(trans('errors.chapter_not_found'));
}
return $chapter;
}
/**
* Create a new chapter in the system.
*/
@ -46,6 +31,7 @@ class ChapterRepo
$chapter->book_id = $parentBook->id;
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
$this->baseRepo->create($chapter, $input);
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
return $chapter;
@ -57,6 +43,11 @@ class ChapterRepo
public function update(Chapter $chapter, array $input): Chapter
{
$this->baseRepo->update($chapter, $input);
if (array_key_exists('default_template_id', $input)) {
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id']));
}
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
return $chapter;
@ -69,10 +60,9 @@ class ChapterRepo
*/
public function destroy(Chapter $chapter)
{
$trashCan = new TrashCan();
$trashCan->softDestroyChapter($chapter);
$this->trashCan->softDestroyChapter($chapter);
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
$trashCan->autoClearOld();
$this->trashCan->autoClearOld();
}
/**
@ -85,8 +75,8 @@ class ChapterRepo
*/
public function move(Chapter $chapter, string $parentIdentifier): Book
{
$parent = $this->findParentByIdentifier($parentIdentifier);
if (is_null($parent)) {
$parent = $this->entityQueries->findVisibleByStringIdentifier($parentIdentifier);
if (!$parent instanceof Book) {
throw new MoveOperationException('Book to move chapter into not found');
}
@ -100,24 +90,4 @@ class ChapterRepo
return $parent;
}
/**
* Find a page parent entity via an identifier string in the format:
* {type}:{id}
* Example: (book:5).
*
* @throws MoveOperationException
*/
public function findParentByIdentifier(string $identifier): ?Book
{
$stringExploded = explode(':', $identifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
if ($entityType !== 'book') {
throw new MoveOperationException('Chapters can only be in books');
}
return Book::visible()->where('id', '=', $entityId)->first();
}
}

View File

@ -8,114 +8,30 @@ use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorData;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater;
use Exception;
use Illuminate\Pagination\LengthAwarePaginator;
class PageRepo
{
public function __construct(
protected BaseRepo $baseRepo,
protected RevisionRepo $revisionRepo,
protected EntityQueries $entityQueries,
protected ReferenceStore $referenceStore,
protected ReferenceUpdater $referenceUpdater
protected ReferenceUpdater $referenceUpdater,
protected TrashCan $trashCan,
) {
}
/**
* Get a page by ID.
*
* @throws NotFoundException
*/
public function getById(int $id, array $relations = ['book']): Page
{
/** @var Page $page */
$page = Page::visible()->with($relations)->find($id);
if (!$page) {
throw new NotFoundException(trans('errors.page_not_found'));
}
return $page;
}
/**
* Get a page its book and own slug.
*
* @throws NotFoundException
*/
public function getBySlug(string $bookSlug, string $pageSlug): Page
{
$page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->first();
if (!$page) {
throw new NotFoundException(trans('errors.page_not_found'));
}
return $page;
}
/**
* Get a page by its old slug but checking the revisions table
* for the last revision that matched the given page and book slug.
*/
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
{
$revision = $this->revisionRepo->getBySlugs($bookSlug, $pageSlug);
return $revision->page ?? null;
}
/**
* Get pages that have been marked as a template.
*/
public function getTemplates(int $count = 10, int $page = 1, string $search = ''): LengthAwarePaginator
{
$query = Page::visible()
->where('template', '=', true)
->orderBy('name', 'asc')
->skip(($page - 1) * $count)
->take($count);
if ($search) {
$query->where('name', 'like', '%' . $search . '%');
}
$paginator = $query->paginate($count, ['*'], 'page', $page);
$paginator->withPath('/templates');
return $paginator;
}
/**
* Get a parent item via slugs.
*/
public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity
{
if ($chapterSlug !== null) {
return Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
}
return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
}
/**
* Get the draft copy of the given page for the current user.
*/
public function getUserDraft(Page $page): ?PageRevision
{
return $this->revisionRepo->getLatestDraftForCurrentUser($page);
}
/**
* Get a new draft page belonging to the given parent entity.
*/
@ -136,7 +52,7 @@ class PageRepo
$page->book_id = $parent->id;
}
$defaultTemplate = $page->book->defaultTemplate;
$defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate;
if ($defaultTemplate && userCan('view', $defaultTemplate)) {
$page->forceFill([
'html' => $defaultTemplate->html,
@ -162,7 +78,6 @@ class PageRepo
$this->baseRepo->update($draft, $input);
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
$this->referenceStore->updateForPage($draft);
$draft->refresh();
Activity::add(ActivityType::PAGE_CREATE, $draft);
@ -182,7 +97,6 @@ class PageRepo
$this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, $input);
$this->referenceStore->updateForPage($page);
// Update with new details
$page->revision_count++;
@ -271,10 +185,9 @@ class PageRepo
*/
public function destroy(Page $page)
{
$trashCan = new TrashCan();
$trashCan->softDestroyPage($page);
$this->trashCan->softDestroyPage($page);
Activity::add(ActivityType::PAGE_DELETE, $page);
$trashCan->autoClearOld();
$this->trashCan->autoClearOld();
}
/**
@ -301,13 +214,13 @@ class PageRepo
$page->refreshSlug();
$page->save();
$page->indexForSearch();
$this->referenceStore->updateForPage($page);
$this->referenceStore->updateForEntity($page);
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
$this->revisionRepo->storeNewForPage($page, $summary);
if ($oldUrl !== $page->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($page, $oldUrl);
$this->referenceUpdater->updateEntityReferences($page, $oldUrl);
}
Activity::add(ActivityType::PAGE_RESTORE, $page);
@ -326,8 +239,8 @@ class PageRepo
*/
public function move(Page $page, string $parentIdentifier): Entity
{
$parent = $this->findParentByIdentifier($parentIdentifier);
if (is_null($parent)) {
$parent = $this->entityQueries->findVisibleByStringIdentifier($parentIdentifier);
if (!$parent instanceof Chapter && !$parent instanceof Book) {
throw new MoveOperationException('Book or chapter to move page into not found');
}
@ -345,28 +258,6 @@ class PageRepo
return $parent;
}
/**
* Find a page parent entity via an identifier string in the format:
* {type}:{id}
* Example: (book:5).
*
* @throws MoveOperationException
*/
public function findParentByIdentifier(string $identifier): ?Entity
{
$stringExploded = explode(':', $identifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
if ($entityType !== 'book' && $entityType !== 'chapter') {
throw new MoveOperationException('Pages can only be in books or chapters');
}
$parentClass = $entityType === 'book' ? Book::class : Chapter::class;
return $parentClass::visible()->where('id', '=', $entityId)->first();
}
/**
* Get a new priority for a page.
*/

View File

@ -4,39 +4,13 @@ namespace BookStack\Entities\Repos;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
use Illuminate\Database\Eloquent\Builder;
use BookStack\Entities\Queries\PageRevisionQueries;
class RevisionRepo
{
/**
* Get a revision by its stored book and page slug values.
*/
public function getBySlugs(string $bookSlug, string $pageSlug): ?PageRevision
{
/** @var ?PageRevision $revision */
$revision = PageRevision::query()
->whereHas('page', function (Builder $query) {
$query->scopes('visible');
})
->where('slug', '=', $pageSlug)
->where('type', '=', 'version')
->where('book_slug', '=', $bookSlug)
->orderBy('created_at', 'desc')
->with('page')
->first();
return $revision;
}
/**
* Get the latest draft revision, for the given page, belonging to the current user.
*/
public function getLatestDraftForCurrentUser(Page $page): ?PageRevision
{
/** @var ?PageRevision $revision */
$revision = $this->queryForCurrentUserDraft($page->id)->first();
return $revision;
public function __construct(
protected PageRevisionQueries $queries,
) {
}
/**
@ -44,7 +18,7 @@ class RevisionRepo
*/
public function deleteDraftsForCurrentUser(Page $page): void
{
$this->queryForCurrentUserDraft($page->id)->delete();
$this->queries->latestCurrentUserDraftsForPageId($page->id)->delete();
}
/**
@ -53,7 +27,7 @@ class RevisionRepo
*/
public function getNewDraftForCurrentUser(Page $page): PageRevision
{
$draft = $this->getLatestDraftForCurrentUser($page);
$draft = $this->queries->findLatestCurrentUserDraftsForPageId($page->id);
if ($draft) {
return $draft;
@ -116,16 +90,4 @@ class RevisionRepo
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
}
}
/**
* Query update draft revisions for the current user.
*/
protected function queryForCurrentUserDraft(int $pageId): Builder
{
return PageRevision::query()
->where('created_by', '=', user()->id)
->where('type', 'update_draft')
->where('page_id', '=', $pageId)
->orderBy('created_at', 'desc');
}
}

View File

@ -7,15 +7,17 @@ use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use Illuminate\Support\Collection;
class BookContents
{
protected Book $book;
protected EntityQueries $queries;
public function __construct(Book $book)
{
$this->book = $book;
public function __construct(
protected Book $book,
) {
$this->queries = app()->make(EntityQueries::class);
}
/**
@ -23,10 +25,12 @@ class BookContents
*/
public function getLastPriority(): int
{
$maxPage = Page::visible()->where('book_id', '=', $this->book->id)
$maxPage = $this->book->pages()
->where('draft', '=', false)
->where('chapter_id', '=', 0)->max('priority');
$maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id)
->where('chapter_id', '=', 0)
->max('priority');
$maxChapter = $this->book->chapters()
->max('priority');
return max($maxChapter, $maxPage, 1);
@ -38,7 +42,7 @@ class BookContents
public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
{
$pages = $this->getPages($showDrafts, $renderPages);
$chapters = Chapter::visible()->where('book_id', '=', $this->book->id)->get();
$chapters = $this->book->chapters()->scopes('visible')->get();
$all = collect()->concat($pages)->concat($chapters);
$chapterMap = $chapters->keyBy('id');
$lonePages = collect();
@ -87,15 +91,17 @@ class BookContents
*/
protected function getPages(bool $showDrafts = false, bool $getPageContent = false): Collection
{
$query = Page::visible()
->select($getPageContent ? Page::$contentAttributes : Page::$listAttributes)
->where('book_id', '=', $this->book->id);
if ($getPageContent) {
$query = $this->queries->pages->visibleWithContents();
} else {
$query = $this->queries->pages->visibleForList();
}
if (!$showDrafts) {
$query->where('draft', '=', false);
}
return $query->get();
return $query->where('book_id', '=', $this->book->id)->get();
}
/**
@ -126,7 +132,7 @@ class BookContents
/** @var Book[] $booksInvolved */
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
return strpos($key, 'book:') === 0;
return str_starts_with($key, 'book:');
}, ARRAY_FILTER_USE_KEY));
// Update permissions of books involved
@ -279,7 +285,7 @@ class BookContents
}
}
$pages = Page::visible()->whereIn('id', array_unique($ids['page']))->get(Page::$listAttributes);
$pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
/** @var Page $page */
foreach ($pages as $page) {
$modelMap['page:' . $page->id] = $page;
@ -289,14 +295,14 @@ class BookContents
}
}
$chapters = Chapter::visible()->whereIn('id', array_unique($ids['chapter']))->get();
$chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
/** @var Chapter $chapter */
foreach ($chapters as $chapter) {
$modelMap['chapter:' . $chapter->id] = $chapter;
$ids['book'][] = $chapter->book_id;
}
$books = Book::visible()->whereIn('id', array_unique($ids['book']))->get();
$books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
/** @var Book $book */
foreach ($books as $book) {
$modelMap['book:' . $book->id] = $book;

View File

@ -77,7 +77,7 @@ class Cloner
$copyBook = $this->bookRepo->create($bookDetails);
// Clone contents
$directChildren = $original->getDirectChildren();
$directChildren = $original->getDirectVisibleChildren();
foreach ($directChildren as $child) {
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
$this->cloneChapter($child, $copyBook, $child->name);

View File

@ -0,0 +1,89 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\App\Model;
use BookStack\Entities\Queries\EntityQueries;
use Illuminate\Database\Eloquent\Relations\Relation;
class MixedEntityListLoader
{
public function __construct(
protected EntityQueries $queries,
) {
}
/**
* Efficiently load in entities for listing onto the given list
* where entities are set as a relation via the given name.
* This will look for a model id and type via 'name_id' and 'name_type'.
* @param Model[] $relations
*/
public function loadIntoRelations(array $relations, string $relationName, bool $loadParents): void
{
$idsByType = [];
foreach ($relations as $relation) {
$type = $relation->getAttribute($relationName . '_type');
$id = $relation->getAttribute($relationName . '_id');
if (!isset($idsByType[$type])) {
$idsByType[$type] = [];
}
$idsByType[$type][] = $id;
}
$modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents);
foreach ($relations as $relation) {
$type = $relation->getAttribute($relationName . '_type');
$id = $relation->getAttribute($relationName . '_id');
$related = $modelMap[$type][strval($id)] ?? null;
if ($related) {
$relation->setRelation($relationName, $related);
}
}
}
/**
* @param array<string, int[]> $idsByType
* @return array<string, array<int, Model>>
*/
protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents): array
{
$modelMap = [];
foreach ($idsByType as $type => $ids) {
$models = $this->queries->visibleForList($type)
->whereIn('id', $ids)
->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : [])
->get();
if (count($models) > 0) {
$modelMap[$type] = [];
}
foreach ($models as $model) {
$modelMap[$type][strval($model->id)] = $model;
}
}
return $modelMap;
}
protected function getRelationsToEagerLoad(string $type): array
{
$toLoad = [];
$loadVisible = fn (Relation $query) => $query->scopes('visible');
if ($type === 'chapter' || $type === 'page') {
$toLoad['book'] = $loadVisible;
}
if ($type === 'page') {
$toLoad['chapter'] = $loadVisible;
}
return $toLoad;
}
}

View File

@ -3,6 +3,7 @@
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Theme;
@ -21,9 +22,12 @@ use Illuminate\Support\Str;
class PageContent
{
protected PageQueries $pageQueries;
public function __construct(
protected Page $page
) {
$this->pageQueries = app()->make(PageQueries::class);
}
/**
@ -325,13 +329,14 @@ class PageContent
protected function getContentProviderClosure(bool $blankIncludes): Closure
{
$contextPage = $this->page;
$queries = $this->pageQueries;
return function (PageIncludeTag $tag) use ($blankIncludes, $contextPage): PageIncludeContent {
return function (PageIncludeTag $tag) use ($blankIncludes, $contextPage, $queries): PageIncludeContent {
if ($blankIncludes) {
return PageIncludeContent::fromHtmlAndTag('', $tag);
}
$matchedPage = Page::visible()->find($tag->getPageId());
$matchedPage = $queries->findVisibleById($tag->getPageId());
$content = PageIncludeContent::fromHtmlAndTag($matchedPage->html ?? '', $tag);
if (Theme::hasListeners(ThemeEvents::PAGE_INCLUDE_PARSE)) {
@ -374,7 +379,7 @@ class PageContent
protected function headerNodesToLevelList(DOMNodeList $nodeList): array
{
$tree = collect($nodeList)->map(function (DOMElement $header) {
$text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
$text = trim(str_replace("\xc2\xa0", ' ', $header->nodeValue));
$text = mb_substr($text, 0, 100);
return [

View File

@ -4,7 +4,7 @@ namespace BookStack\Entities\Tools;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
@ -15,7 +15,7 @@ class PageEditorData
public function __construct(
protected Page $page,
protected PageRepo $pageRepo,
protected EntityQueries $queries,
protected string $requestedEditor
) {
$this->viewData = $this->build();
@ -35,7 +35,12 @@ class PageEditorData
{
$page = clone $this->page;
$isDraft = boolval($this->page->draft);
$templates = $this->pageRepo->getTemplates(10);
$templates = $this->queries->pages->visibleTemplates()
->orderBy('name', 'asc')
->take(10)
->paginate()
->withPath('/templates');
$draftsEnabled = auth()->check();
$isDraftRevision = false;
@ -47,8 +52,8 @@ class PageEditorData
}
// Check for a current draft version for this user
$userDraft = $this->pageRepo->getUserDraft($page);
if ($userDraft !== null) {
$userDraft = $this->queries->revisions->findLatestCurrentUserDraftsForPageId($page->id);
if (!is_null($userDraft)) {
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
$isDraftRevision = true;
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);

View File

@ -4,10 +4,16 @@ namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Queries\BookshelfQueries;
class ShelfContext
{
protected $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
protected string $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
public function __construct(
protected BookshelfQueries $shelfQueries,
) {
}
/**
* Get the current bookshelf context for the given book.
@ -20,8 +26,7 @@ class ShelfContext
return null;
}
/** @var Bookshelf $shelf */
$shelf = Bookshelf::visible()->find($contextBookshelfId);
$shelf = $this->shelfQueries->findVisibleById($contextBookshelfId);
$shelfContainsBook = $shelf && $shelf->contains($book);
return $shelfContainsBook ? $shelf : null;
@ -30,7 +35,7 @@ class ShelfContext
/**
* Store the current contextual shelf ID.
*/
public function setShelfContext(int $shelfId)
public function setShelfContext(int $shelfId): void
{
session()->put($this->KEY_SHELF_CONTEXT_ID, $shelfId);
}
@ -38,7 +43,7 @@ class ShelfContext
/**
* Clear the session stored shelf context id.
*/
public function clearShelfContext()
public function clearShelfContext(): void
{
session()->forget($this->KEY_SHELF_CONTEXT_ID);
}

View File

@ -7,10 +7,17 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use Illuminate\Support\Collection;
class SiblingFetcher
{
public function __construct(
protected EntityQueries $queries,
protected ShelfContext $shelfContext,
) {
}
/**
* Search among the siblings of the entity of given type and id.
*/
@ -26,23 +33,23 @@ class SiblingFetcher
// Page in book or chapter
if (($entity instanceof Page && !$entity->chapter) || $entity instanceof Chapter) {
$entities = $entity->book->getDirectChildren();
$entities = $entity->book->getDirectVisibleChildren();
}
// Book
// Gets just the books in a shelf if shelf is in context
if ($entity instanceof Book) {
$contextShelf = (new ShelfContext())->getContextualShelfForBook($entity);
$contextShelf = $this->shelfContext->getContextualShelfForBook($entity);
if ($contextShelf) {
$entities = $contextShelf->visibleBooks()->get();
} else {
$entities = Book::visible()->get();
$entities = $this->queries->books->visibleForList()->orderBy('name', 'asc')->get();
}
}
// Shelf
if ($entity instanceof Bookshelf) {
$entities = Bookshelf::visible()->get();
$entities = $this->queries->shelves->visibleForList()->orderBy('name', 'asc')->get();
}
return $entities;

View File

@ -10,6 +10,7 @@ use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity;
use BookStack\Uploads\AttachmentService;
@ -20,6 +21,11 @@ use Illuminate\Support\Carbon;
class TrashCan
{
public function __construct(
protected EntityQueries $queries,
) {
}
/**
* Send a shelf to the recycle bin.
*
@ -203,7 +209,13 @@ class TrashCan
}
// Remove book template usages
Book::query()->where('default_template_id', '=', $page->id)
$this->queries->books->start()
->where('default_template_id', '=', $page->id)
->update(['default_template_id' => null]);
// Remove chapter template usages
$this->queries->chapters->start()
->where('default_template_id', '=', $page->id)
->update(['default_template_id' => null]);
$page->forceDelete();

View File

@ -2,18 +2,15 @@
namespace BookStack\Http;
use BookStack\Util\WebSafeMimeSniffer;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DownloadResponseFactory
{
protected Request $request;
public function __construct(Request $request)
{
$this->request = $request;
public function __construct(
protected Request $request
) {
}
/**
@ -21,26 +18,21 @@ class DownloadResponseFactory
*/
public function directly(string $content, string $fileName): Response
{
return response()->make($content, 200, $this->getHeaders($fileName));
return response()->make($content, 200, $this->getHeaders($fileName, strlen($content)));
}
/**
* Create a response that forces a download, from a given stream of content.
*/
public function streamedDirectly($stream, string $fileName): StreamedResponse
public function streamedDirectly($stream, string $fileName, int $fileSize): StreamedResponse
{
return response()->stream(function () use ($stream) {
// End & flush the output buffer, if we're in one, otherwise we still use memory.
// Output buffer may or may not exist depending on PHP `output_buffering` setting.
// Ignore in testing since output buffers are used to gather a response.
if (!empty(ob_get_status()) && !app()->runningUnitTests()) {
ob_end_clean();
}
fpassthru($stream);
fclose($stream);
}, 200, $this->getHeaders($fileName));
$rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request);
$headers = array_merge($this->getHeaders($fileName, $fileSize), $rangeStream->getResponseHeaders());
return response()->stream(
fn() => $rangeStream->outputAndClose(),
$rangeStream->getResponseStatus(),
$headers,
);
}
/**
@ -48,28 +40,30 @@ class DownloadResponseFactory
* correct for the file, in a way so the browser can show the content in browser,
* for a given content stream.
*/
public function streamedInline($stream, string $fileName): StreamedResponse
public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse
{
$sniffContent = fread($stream, 2000);
$mime = (new WebSafeMimeSniffer())->sniff($sniffContent);
$rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request);
$mime = $rangeStream->sniffMime();
$headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders());
return response()->stream(function () use ($sniffContent, $stream) {
echo $sniffContent;
fpassthru($stream);
fclose($stream);
}, 200, $this->getHeaders($fileName, $mime));
return response()->stream(
fn() => $rangeStream->outputAndClose(),
$rangeStream->getResponseStatus(),
$headers,
);
}
/**
* Get the common headers to provide for a download response.
*/
protected function getHeaders(string $fileName, string $mime = 'application/octet-stream'): array
protected function getHeaders(string $fileName, int $fileSize, string $mime = 'application/octet-stream'): array
{
$disposition = ($mime === 'application/octet-stream') ? 'attachment' : 'inline';
$downloadName = str_replace('"', '', $fileName);
return [
'Content-Type' => $mime,
'Content-Length' => $fileSize,
'Content-Disposition' => "{$disposition}; filename=\"{$downloadName}\"",
'X-Content-Type-Options' => 'nosniff',
];

View File

@ -28,7 +28,7 @@ class Kernel extends HttpKernel
\BookStack\Http\Middleware\ApplyCspRules::class,
\BookStack\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\BookStack\Http\Middleware\StartSessionExtended::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\BookStack\Http\Middleware\VerifyCsrfToken::class,
\BookStack\Http\Middleware\CheckEmailConfirmed::class,
@ -45,11 +45,11 @@ class Kernel extends HttpKernel
];
/**
* The application's route middleware.
* The application's middleware aliases.
*
* @var array
*/
protected $routeMiddleware = [
protected $middlewareAliases = [
'auth' => \BookStack\Http\Middleware\Authenticate::class,
'can' => \BookStack\Http\Middleware\CheckUserHasPermission::class,
'guest' => \BookStack\Http\Middleware\RedirectIfAuthenticated::class,

View File

@ -6,19 +6,16 @@ use BookStack\App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null ...$guards
*
* @return mixed
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, ...$guards)
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;

View File

@ -0,0 +1,34 @@
<?php
namespace BookStack\Http\Middleware;
use Illuminate\Http\Request;
use Illuminate\Session\Middleware\StartSession as Middleware;
/**
* An extended version of the default Laravel "StartSession" middleware
* with customizations applied as required:
*
* - Adds filtering for the request URLs stored in session history.
*/
class StartSessionExtended extends Middleware
{
protected static array $pathPrefixesExcludedFromHistory = [
'uploads/images/'
];
/**
* @inheritdoc
*/
protected function storeCurrentUrl(Request $request, $session): void
{
$requestPath = strtolower($request->path());
foreach (static::$pathPrefixesExcludedFromHistory as $excludedPath) {
if (str_starts_with($requestPath, $excludedPath)) {
return;
}
}
parent::storeCurrentUrl($request, $session);
}
}

View File

@ -9,7 +9,7 @@ class ThrottleApiRequests extends Middleware
/**
* Resolve the number of attempts if the user is authenticated or not.
*/
protected function resolveMaxAttempts($request, $maxAttempts)
protected function resolveMaxAttempts($request, $maxAttempts): int
{
return (int) config('api.requests_per_minute');
}

View File

@ -9,9 +9,9 @@ class TrustHosts extends Middleware
/**
* Get the host patterns that should be trusted.
*
* @return array
* @return array<int, string|null>
*/
public function hosts()
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),

View File

@ -0,0 +1,134 @@
<?php
namespace BookStack\Http;
use BookStack\Util\WebSafeMimeSniffer;
use Illuminate\Http\Request;
/**
* Helper wrapper for range-based stream response handling.
* Much of this used symfony/http-foundation as a reference during build.
* URL: https://github.com/symfony/http-foundation/blob/v6.0.20/BinaryFileResponse.php
* License: MIT license, Copyright (c) Fabien Potencier.
*/
class RangeSupportedStream
{
protected string $sniffContent = '';
protected array $responseHeaders = [];
protected int $responseStatus = 200;
protected int $responseLength = 0;
protected int $responseOffset = 0;
public function __construct(
protected $stream,
protected int $fileSize,
Request $request,
) {
$this->responseLength = $this->fileSize;
$this->parseRequest($request);
}
/**
* Sniff a mime type from the stream.
*/
public function sniffMime(): string
{
$offset = min(2000, $this->fileSize);
$this->sniffContent = fread($this->stream, $offset);
return (new WebSafeMimeSniffer())->sniff($this->sniffContent);
}
/**
* Output the current stream to stdout before closing out the stream.
*/
public function outputAndClose(): void
{
// End & flush the output buffer, if we're in one, otherwise we still use memory.
// Output buffer may or may not exist depending on PHP `output_buffering` setting.
// Ignore in testing since output buffers are used to gather a response.
if (!empty(ob_get_status()) && !app()->runningUnitTests()) {
ob_end_clean();
}
$outStream = fopen('php://output', 'w');
$sniffLength = strlen($this->sniffContent);
$bytesToWrite = $this->responseLength;
if ($sniffLength > 0 && $this->responseOffset < $sniffLength) {
$sniffEnd = min($sniffLength, $bytesToWrite + $this->responseOffset);
$sniffOutLength = $sniffEnd - $this->responseOffset;
$sniffOutput = substr($this->sniffContent, $this->responseOffset, $sniffOutLength);
fwrite($outStream, $sniffOutput);
$bytesToWrite -= $sniffOutLength;
} else if ($this->responseOffset !== 0) {
fseek($this->stream, $this->responseOffset);
}
stream_copy_to_stream($this->stream, $outStream, $bytesToWrite);
fclose($this->stream);
fclose($outStream);
}
public function getResponseHeaders(): array
{
return $this->responseHeaders;
}
public function getResponseStatus(): int
{
return $this->responseStatus;
}
protected function parseRequest(Request $request): void
{
$this->responseHeaders['Accept-Ranges'] = $request->isMethodSafe() ? 'bytes' : 'none';
$range = $this->getRangeFromRequest($request);
if ($range) {
[$start, $end] = $range;
if ($start < 0 || $start > $end) {
$this->responseStatus = 416;
$this->responseHeaders['Content-Range'] = sprintf('bytes */%s', $this->fileSize);
} elseif ($end - $start < $this->fileSize - 1) {
$this->responseLength = $end < $this->fileSize ? $end - $start + 1 : -1;
$this->responseOffset = $start;
$this->responseStatus = 206;
$this->responseHeaders['Content-Range'] = sprintf('bytes %s-%s/%s', $start, $end, $this->fileSize);
$this->responseHeaders['Content-Length'] = $end - $start + 1;
}
}
if ($request->isMethod('HEAD')) {
$this->responseLength = 0;
}
}
protected function getRangeFromRequest(Request $request): ?array
{
$range = $request->headers->get('Range');
if (!$range || !$request->isMethod('GET') || !str_starts_with($range, 'bytes=')) {
return null;
}
if ($request->headers->has('If-Range')) {
return null;
}
[$start, $end] = explode('-', substr($range, 6), 2) + [0];
$end = ('' === $end) ? $this->fileSize - 1 : (int) $end;
if ('' === $start) {
$start = $this->fileSize - $end;
$end = $this->fileSize - 1;
} else {
$start = (int) $start;
}
$end = min($end, $this->fileSize - 1);
return [$start, $end];
}
}

View File

@ -9,11 +9,9 @@ use Illuminate\Database\Eloquent\Builder;
class EntityPermissionEvaluator
{
protected string $action;
public function __construct(string $action)
{
$this->action = $action;
public function __construct(
protected string $action
) {
}
public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool
@ -82,23 +80,25 @@ class EntityPermissionEvaluator
*/
protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): array
{
$query = EntityPermission::query()->where(function (Builder $query) use ($typeIdChain) {
foreach ($typeIdChain as $typeId) {
$query->orWhere(function (Builder $query) use ($typeId) {
[$type, $id] = explode(':', $typeId);
$query->where('entity_type', '=', $type)
->where('entity_id', '=', $id);
});
$idsByType = [];
foreach ($typeIdChain as $typeId) {
[$type, $id] = explode(':', $typeId);
if (!isset($idsByType[$type])) {
$idsByType[$type] = [];
}
});
if (!empty($filterRoleIds)) {
$query->where(function (Builder $query) use ($filterRoleIds) {
$query->whereIn('role_id', [...$filterRoleIds, 0]);
});
$idsByType[$type][] = $id;
}
$relevantPermissions = $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
$relevantPermissions = [];
foreach ($idsByType as $type => $ids) {
$idsChunked = array_chunk($ids, 10000);
foreach ($idsChunked as $idChunk) {
$permissions = $this->getPermissionsForEntityIdsOfType($type, $idChunk, $filterRoleIds);
array_push($relevantPermissions, ...$permissions);
}
}
$map = [];
foreach ($relevantPermissions as $permission) {
@ -113,6 +113,26 @@ class EntityPermissionEvaluator
return $map;
}
/**
* @param string[] $ids
* @param int[] $filterRoleIds
* @return EntityPermission[]
*/
protected function getPermissionsForEntityIdsOfType(string $type, array $ids, array $filterRoleIds): array
{
$query = EntityPermission::query()
->where('entity_type', '=', $type)
->whereIn('entity_id', $ids);
if (!empty($filterRoleIds)) {
$query->where(function (Builder $query) use ($filterRoleIds) {
$query->whereIn('role_id', [...$filterRoleIds, 0]);
});
}
return $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
}
/**
* @return string[]
*/

Some files were not shown because too many files have changed in this diff Show More