Compare commits

..

8 Commits

Author SHA1 Message Date
Dan Brown
bf0ba9f756 Replaced embeds with images in exports 2022-05-24 18:05:47 +01:00
Dan Brown
05f8034439 Added embed support for contained HTML exports
Unfortunately CSP rules will block embeds anyway.
Need to either relax CSP rules on exports, or instead convert to img
tags?

Also cleaned up existing regexes.
2022-05-23 16:11:28 +01:00
Dan Brown
1d1186c901 Replaced markdown preview display iframe with div
No longer need to use the iframe sandboxing techniques, since we now have
CSP rules in-place. Means that embed tags can load without CSP
complications.

Causes slight change to contents of `editor-markdown::setup` editor
event data, since we're changing the `displayEl` property.

Updates markdown editor component to make better use of the component
system.
2022-05-23 15:16:23 +01:00
Dan Brown
641a26cdf7 Updated markdown editor to use svg drawio images
- Also tweaked page editor to not error when the current user does not
  have permission to change editor type.
2022-05-23 14:38:34 +01:00
Dan Brown
5fd8e7e0e9 Updated drawio tinymce plugin to use embeds
- Adds support for handling drawings as embeds, based on image
  extension.
- Adds additional attribute to drawio elements within editor to prevent
  tinymce replacing embeds with a placeholder.
- Updates how contenteditable is applied to drawio blocks within editor,
  to use proper filters instead of using the SetContent event.
2022-05-23 12:24:40 +01:00
Dan Brown
d926ca5f71 Updated draw.io code to support SVGs as primary data type 2022-05-22 12:38:50 +01:00
Dan Brown
b69722c3b5 Fixed issue caused by changing test method defaults 2022-05-22 11:58:22 +01:00
Dan Brown
c9aa1c979f Added SVG support to the image gallery. 2022-05-22 11:52:42 +01:00
681 changed files with 8816 additions and 14993 deletions

View File

@@ -143,10 +143,6 @@ STORAGE_URL=false
# Can be 'standard', 'ldap', 'saml2' or 'oidc'
AUTH_METHOD=standard
# Automatically initiate login via external auth system if it's the only auth method.
# Works with saml2 or oidc auth methods.
AUTH_AUTO_INITIATE=false
# Social authentication configuration
# All disabled by default.
# Refer to https://www.bookstackapp.com/docs/admin/third-party-auth/
@@ -263,11 +259,7 @@ OIDC_ISSUER_DISCOVER=false
OIDC_PUBLIC_KEY=null
OIDC_AUTH_ENDPOINT=null
OIDC_TOKEN_ENDPOINT=null
OIDC_ADDITIONAL_SCOPES=null
OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false
OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option
@@ -299,7 +291,7 @@ APP_DEFAULT_DARK_MODE=false
# Page revision limit
# Number of page revisions to keep in the system before deleting old revisions.
# If set to 'false' a limit will not be enforced.
REVISION_LIMIT=100
REVISION_LIMIT=50
# Recycle Bin Lifetime
# The number of days that content will remain in the recycle bin before
@@ -361,11 +353,3 @@ API_REQUESTS_PER_MIN=180
# user identifier (Username or email).
LOG_FAILED_LOGIN_MESSAGE=false
LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
# Alter the precision of IP addresses stored by BookStack.
# Should be a number between 0 and 4, where 4 retains the full IP address
# and 0 completely hides the IP address. As an example, a value of 2 for the
# IP address '146.191.42.4' would result in '146.191.x.x' being logged.
# For the IPv6 address '2001:db8:85a3:8d3:1319:8a2e:370:7348' this would result as:
# '2001:db8:85a3:8d3:x:x:x:x'
IP_ADDRESS_PRECISION=4

1
.github/FUNDING.yml vendored
View File

@@ -1,4 +1,3 @@
# These are supported funding model platforms
github: [ssddanbrown]
ko_fi: ssddanbrown

View File

@@ -1,5 +1,5 @@
name: Language Request
description: Request a new language to be added to Crowdin for you to translate
description: Request a new language to be added to CrowdIn for you to translate
labels: [":earth_africa: Translations"]
assignees:
- ssddanbrown
@@ -23,7 +23,7 @@ body:
This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack).
Please don't use this template to request a new language that you are not prepared to provide translations for.
options:
- label: I confirm I'm offering to help translate for this new language via Crowdin.
- label: I confirm I'm offering to help translate for this new language via CrowdIn.
required: true
- type: markdown
attributes:

View File

@@ -55,8 +55,6 @@ Name :: Languages
@Baptistou :: French
@arcoai :: Spanish
@Jokuna :: Korean
@smartshogu :: German; German Informal
@samadha56 :: Persian
cipi1965 :: Italian
Mykola Ronik (Mantikor) :: Ukrainian
furkanoyk :: Turkish
@@ -138,7 +136,7 @@ Xiphoseer :: German
MerlinSVK (merlinsvk) :: Slovak
Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
MatthieuParis :: French
Douradinho :: Portuguese, Brazilian; Portuguese
Douradinho :: Portuguese, Brazilian
Gaku Yaguchi (tama11) :: Japanese
johnroyer :: Chinese Traditional
jackaaa :: Chinese Traditional
@@ -176,7 +174,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: Dutch; Turkish
REMOVED_USER :: Turkish
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -245,32 +243,3 @@ Shukrullo (vodiylik) :: Uzbek
William W. (Nevnt) :: Chinese Traditional
eamaro :: Portuguese
Ypsilon-dev :: Arabic
Hieu Vuong Trung (vuongtrunghieu) :: Vietnamese
David Clubb (davidoclubb) :: Welsh
welles freire (wellesximenes) :: Portuguese, Brazilian
Magnus Jensen (MagnusHJensen) :: Danish
Hesley Magno (hesleymagno) :: Portuguese, Brazilian
Éric Gaspar (erga) :: French
Fr3shlama :: German
DSR :: Spanish, Argentina
Andrii Bodnar (andrii-bodnar) :: Ukrainian
Younes el Anjri (younesea28) :: Dutch
Guclu Ozturk (gucluoz) :: Turkish
Atmis :: French
redjack666 :: Chinese Traditional
Ashita007 :: Russian
lihaorr :: Chinese Simplified
Marcus Silber (marcus.silber82) :: German
PellNet :: Croatian
Winetradr :: German
Sebastian Klaus (sebklaus) :: German
Filip Antala (AntalaFilip) :: Slovak
mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional
Nanang Setia Budi (sefidananang) :: Indonesian
Андрей Павлов (andrei.pavlov) :: Russian
Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian
Ji-Hyeon Gim (PotatoGim) :: Korean
Mihai Ochian (soulstorm19) :: Romanian
HeartCore :: German Informal; German
simon.pct :: French
okaeiz :: Persian

6
.gitignore vendored
View File

@@ -5,10 +5,10 @@ Homestead.yaml
.idea
npm-debug.log
yarn-error.log
/public/dist/*.map
/public/dist
/public/plugins
/public/css/*.map
/public/js/*.map
/public/css
/public/js
/public/bower
/public/build/
/storage/images

View File

@@ -2,15 +2,21 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Theme;
use BookStack\Interfaces\Loggable;
use BookStack\Theming\ThemeEvents;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
class ActivityLogger
{
protected $permissionService;
public function __construct(PermissionService $permissionService)
{
$this->permissionService = $permissionService;
}
/**
* Add a generic activity event to the database.
*
@@ -29,10 +35,8 @@ class ActivityLogger
}
$activity->save();
$this->setNotification($type);
$this->dispatchWebhooks($type, $detail);
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
}
/**
@@ -40,10 +44,12 @@ class ActivityLogger
*/
protected function newActivityForUser(string $type): Activity
{
$ip = request()->ip() ?? '';
return (new Activity())->forceFill([
'type' => strtolower($type),
'user_id' => user()->id,
'ip' => IpFormatter::fromCurrentRequest()->format(),
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
]);
}

View File

@@ -2,7 +2,7 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
@@ -13,11 +13,11 @@ use Illuminate\Database\Eloquent\Relations\Relation;
class ActivityQueries
{
protected PermissionApplicator $permissions;
protected $permissionService;
public function __construct(PermissionApplicator $permissions)
public function __construct(PermissionService $permissionService)
{
$this->permissions = $permissions;
$this->permissionService = $permissionService;
}
/**
@@ -25,8 +25,8 @@ class ActivityQueries
*/
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
$activityList = $this->permissionService
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->skip($count * $page)
@@ -78,8 +78,8 @@ class ActivityQueries
*/
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
$activityList = $this->permissionService
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)

View File

@@ -16,22 +16,17 @@ class ActivityType
const CHAPTER_MOVE = 'chapter_move';
const BOOK_CREATE = 'book_create';
const BOOK_CREATE_FROM_CHAPTER = 'book_create_from_chapter';
const BOOK_UPDATE = 'book_update';
const BOOK_DELETE = 'book_delete';
const BOOK_SORT = 'book_sort';
const BOOKSHELF_CREATE = 'bookshelf_create';
const BOOKSHELF_CREATE_FROM_BOOK = 'bookshelf_create_from_book';
const BOOKSHELF_UPDATE = 'bookshelf_update';
const BOOKSHELF_DELETE = 'bookshelf_delete';
const COMMENTED_ON = 'commented_on';
const PERMISSIONS_UPDATE = 'permissions_update';
const REVISION_RESTORE = 'revision_restore';
const REVISION_DELETE = 'revision_delete';
const SETTINGS_UPDATE = 'settings_update';
const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';

View File

@@ -1,81 +0,0 @@
<?php
namespace BookStack\Actions;
class IpFormatter
{
protected string $ip;
protected int $precision;
public function __construct(string $ip, int $precision)
{
$this->ip = trim($ip);
$this->precision = max(0, min($precision, 4));
}
public function format(): string
{
if (empty($this->ip) || $this->precision === 4) {
return $this->ip;
}
return $this->isIpv6() ? $this->maskIpv6() : $this->maskIpv4();
}
protected function maskIpv4(): string
{
$exploded = $this->explodeAndExpandIp('.', 4);
$maskGroupCount = min(4 - $this->precision, count($exploded));
for ($i = 0; $i < $maskGroupCount; $i++) {
$exploded[3 - $i] = 'x';
}
return implode('.', $exploded);
}
protected function maskIpv6(): string
{
$exploded = $this->explodeAndExpandIp(':', 8);
$maskGroupCount = min(8 - ($this->precision * 2), count($exploded));
for ($i = 0; $i < $maskGroupCount; $i++) {
$exploded[7 - $i] = 'x';
}
return implode(':', $exploded);
}
protected function isIpv6(): bool
{
return strpos($this->ip, ':') !== false;
}
protected function explodeAndExpandIp(string $separator, int $targetLength): array
{
$exploded = explode($separator, $this->ip);
while (count($exploded) < $targetLength) {
$emptyIndex = array_search('', $exploded) ?: count($exploded) - 1;
array_splice($exploded, $emptyIndex, 0, '0');
}
$emptyIndex = array_search('', $exploded);
if ($emptyIndex !== false) {
$exploded[$emptyIndex] = '0';
}
return $exploded;
}
public static function fromCurrentRequest(): self
{
$ip = request()->ip() ?? '';
if (config('app.env') === 'demo') {
$ip = '127.0.0.1';
}
return new self($ip, config('app.ip_address_precision'));
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace BookStack\Actions;
class TagClassGenerator
{
protected array $tags;
/**
* @param Tag[] $tags
*/
public function __construct(array $tags)
{
$this->tags = $tags;
}
/**
* @return string[]
*/
public function generate(): array
{
$classes = [];
foreach ($this->tags as $tag) {
$name = $this->normalizeTagClassString($tag->name);
$value = $this->normalizeTagClassString($tag->value);
$classes[] = 'tag-name-' . $name;
if ($value) {
$classes[] = 'tag-value-' . $value;
$classes[] = 'tag-pair-' . $name . '-' . $value;
}
}
return array_unique($classes);
}
public function generateAsString(): string
{
return implode(' ', $this->generate());
}
protected function normalizeTagClassString(string $value): string
{
$value = str_replace(' ', '', strtolower($value));
$value = str_replace('-', '', strtolower($value));
return $value;
}
}

View File

@@ -2,7 +2,7 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
@@ -10,11 +10,12 @@ use Illuminate\Support\Facades\DB;
class TagRepo
{
protected PermissionApplicator $permissions;
protected $tag;
protected $permissionService;
public function __construct(PermissionApplicator $permissions)
public function __construct(PermissionService $ps)
{
$this->permissions = $permissions;
$this->permissionService = $ps;
}
/**
@@ -50,7 +51,7 @@ class TagRepo
});
}
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
return $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
}
/**
@@ -69,7 +70,7 @@ class TagRepo
$query = $query->orderBy('count', 'desc')->take(50);
}
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['name'])->pluck('name');
}
@@ -95,7 +96,7 @@ class TagRepo
$query = $query->where('name', '=', $tagName);
}
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['value'])->pluck('value');
}

View File

@@ -28,8 +28,10 @@ class GroupSyncService
*/
protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool
{
foreach ($this->parseRoleExternalAuthId($externalId) as $externalAuthId) {
if (in_array($externalAuthId, $groupNames)) {
$externalAuthIds = explode(',', strtolower($externalId));
foreach ($externalAuthIds as $externalAuthId) {
if (in_array(trim($externalAuthId), $groupNames)) {
return true;
}
}
@@ -37,18 +39,6 @@ class GroupSyncService
return false;
}
protected function parseRoleExternalAuthId(string $externalId): array
{
$inputIds = preg_split('/(?<!\\\),/', strtolower($externalId));
$cleanIds = [];
foreach ($inputIds as $inputId) {
$cleanIds[] = str_replace('\,', ',', trim($inputId));
}
return $cleanIds;
}
/**
* Match an array of group names to BookStack system roles.
* Formats group names to be lower-case and hyphenated.

View File

@@ -30,11 +30,6 @@ class OidcOAuthProvider extends AbstractProvider
*/
protected $tokenEndpoint;
/**
* Scopes to use for the OIDC authorization call.
*/
protected array $scopes = ['openid', 'profile', 'email'];
/**
* Returns the base URL for authorizing a client.
*/
@@ -59,15 +54,6 @@ class OidcOAuthProvider extends AbstractProvider
return '';
}
/**
* Add an additional scope to this provider upon the default.
*/
public function addScope(string $scope): void
{
$this->scopes[] = $scope;
$this->scopes = array_unique($this->scopes);
}
/**
* Returns the default scopes used by this provider.
*
@@ -76,7 +62,7 @@ class OidcOAuthProvider extends AbstractProvider
*/
protected function getDefaultScopes(): array
{
return $this->scopes;
return ['openid', 'profile', 'email'];
}
/**

View File

@@ -3,7 +3,6 @@
namespace BookStack\Auth\Access\Oidc;
use function auth;
use BookStack\Auth\Access\GroupSyncService;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
@@ -11,7 +10,6 @@ use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use function config;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
@@ -28,21 +26,15 @@ class OidcService
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected HttpClient $httpClient;
protected GroupSyncService $groupService;
/**
* OpenIdService constructor.
*/
public function __construct(
RegistrationService $registrationService,
LoginService $loginService,
HttpClient $httpClient,
GroupSyncService $groupService
) {
public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
{
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->httpClient = $httpClient;
$this->groupService = $groupService;
}
/**
@@ -125,31 +117,10 @@ class OidcService
*/
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
return new OidcOAuthProvider($settings->arrayForProvider(), [
'httpClient' => $this->httpClient,
'optionProvider' => new HttpBasicAuthOptionProvider(),
]);
foreach ($this->getAdditionalScopes() as $scope) {
$provider->addScope($scope);
}
return $provider;
}
/**
* Get any user-defined addition/custom scopes to apply to the authentication request.
*
* @return string[]
*/
protected function getAdditionalScopes(): array
{
$scopeConfig = $this->config()['additional_scopes'] ?: '';
$scopeArr = explode(',', $scopeConfig);
$scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr);
return array_filter($scopeArr);
}
/**
@@ -174,32 +145,10 @@ class OidcService
return implode(' ', $displayName);
}
/**
* Extract the assigned groups from the id token.
*
* @return string[]
*/
protected function getUserGroups(OidcIdToken $token): array
{
$groupsAttr = $this->config()['groups_claim'];
if (empty($groupsAttr)) {
return [];
}
$groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
if (!is_array($groupsList)) {
return [];
}
return array_values(array_filter($groupsList, function ($val) {
return is_string($val);
}));
}
/**
* Extract the details of a user from an ID token.
*
* @return array{name: string, email: string, external_id: string, groups: string[]}
* @return array{name: string, email: string, external_id: string}
*/
protected function getUserDetails(OidcIdToken $token): array
{
@@ -209,7 +158,6 @@ class OidcService
'external_id' => $id,
'email' => $token->getClaim('email'),
'name' => $this->getUserDisplayName($token, $id),
'groups' => $this->getUserGroups($token),
];
}
@@ -261,12 +209,6 @@ class OidcService
throw new OidcException($exception->getMessage());
}
if ($this->shouldSyncGroups()) {
$groups = $userDetails['groups'];
$detachExisting = $this->config()['remove_from_groups'];
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
}
$this->loginService->login($user, 'oidc');
return $user;
@@ -279,12 +221,4 @@ class OidcService
{
return config('oidc');
}
/**
* Check if groups should be synced.
*/
protected function shouldSyncGroups(): bool
{
return $this->config()['user_to_groups'] !== false;
}
}

View File

@@ -1,405 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\DB;
/**
* Joint permissions provide a pre-query "cached" table of view permissions for all core entity
* types for all roles in the system. This class generates out that table for different scenarios.
*/
class JointPermissionBuilder
{
/**
* @var array<string, array<int, SimpleEntityData>>
*/
protected $entityCache;
/**
* Re-generate all entity permission from scratch.
*/
public function rebuildForAll()
{
JointPermission::query()->truncate();
// Get all roles (Should be the most limited dimension)
$roles = Role::query()->with('permissions')->get()->all();
// Chunk through all books
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
$this->createManyJointPermissions($shelves->all(), $roles);
});
}
/**
* Rebuild the entity jointPermissions for a particular entity.
*/
public function rebuildForEntity(Entity $entity)
{
$entities = [$entity];
if ($entity instanceof Book) {
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
$this->buildJointPermissionsForBooks($books, Role::query()->with('permissions')->get()->all(), true);
return;
}
/** @var BookChild $entity */
if ($entity->book) {
$entities[] = $entity->book;
}
if ($entity instanceof Page && $entity->chapter_id) {
$entities[] = $entity->chapter;
}
if ($entity instanceof Chapter) {
foreach ($entity->pages as $page) {
$entities[] = $page;
}
}
$this->buildJointPermissionsForEntities($entities);
}
/**
* Build the entity jointPermissions for a particular role.
*/
public function rebuildForRole(Role $role)
{
$roles = [$role];
$role->jointPermissions()->delete();
$role->load('permissions');
// Chunk through all books
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
->chunk(50, function ($shelves) use ($roles) {
$this->createManyJointPermissions($shelves->all(), $roles);
});
}
/**
* Prepare the local entity cache and ensure it's empty.
*
* @param SimpleEntityData[] $entities
*/
protected function readyEntityCache(array $entities)
{
$this->entityCache = [];
foreach ($entities as $entity) {
if (!isset($this->entityCache[$entity->type])) {
$this->entityCache[$entity->type] = [];
}
$this->entityCache[$entity->type][$entity->id] = $entity;
}
}
/**
* Get a book via ID, Checks local cache.
*/
protected function getBook(int $bookId): SimpleEntityData
{
return $this->entityCache['book'][$bookId];
}
/**
* Get a chapter via ID, Checks local cache.
*/
protected function getChapter(int $chapterId): SimpleEntityData
{
return $this->entityCache['chapter'][$chapterId];
}
/**
* Get a query for fetching a book with its children.
*/
protected function bookFetchQuery(): Builder
{
return Book::query()->withTrashed()
->select(['id', 'restricted', 'owned_by'])->with([
'chapters' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
},
'pages' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
},
]);
}
/**
* Build joint permissions for the given book and role combinations.
*/
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
{
$entities = clone $books;
/** @var Book $book */
foreach ($books->all() as $book) {
foreach ($book->getRelation('chapters') as $chapter) {
$entities->push($chapter);
}
foreach ($book->getRelation('pages') as $page) {
$entities->push($page);
}
}
if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($entities->all());
}
$this->createManyJointPermissions($entities->all(), $roles);
}
/**
* Rebuild the entity jointPermissions for a collection of entities.
*/
protected function buildJointPermissionsForEntities(array $entities)
{
$roles = Role::query()->get()->values()->all();
$this->deleteManyJointPermissionsForEntities($entities);
$this->createManyJointPermissions($entities, $roles);
}
/**
* Delete all the entity jointPermissions for a list of entities.
*
* @param Entity[] $entities
*/
protected function deleteManyJointPermissionsForEntities(array $entities)
{
$simpleEntities = $this->entitiesToSimpleEntities($entities);
$idsByType = $this->entitiesToTypeIdMap($simpleEntities);
DB::transaction(function () use ($idsByType) {
foreach ($idsByType as $type => $ids) {
foreach (array_chunk($ids, 1000) as $idChunk) {
DB::table('joint_permissions')
->where('entity_type', '=', $type)
->whereIn('entity_id', $idChunk)
->delete();
}
}
});
}
/**
* @param Entity[] $entities
*
* @return SimpleEntityData[]
*/
protected function entitiesToSimpleEntities(array $entities): array
{
$simpleEntities = [];
foreach ($entities as $entity) {
$attrs = $entity->getAttributes();
$simple = new SimpleEntityData();
$simple->id = $attrs['id'];
$simple->type = $entity->getMorphClass();
$simple->restricted = boolval($attrs['restricted'] ?? 0);
$simple->owned_by = $attrs['owned_by'] ?? 0;
$simple->book_id = $attrs['book_id'] ?? null;
$simple->chapter_id = $attrs['chapter_id'] ?? null;
$simpleEntities[] = $simple;
}
return $simpleEntities;
}
/**
* Create & Save entity jointPermissions for many entities and roles.
*
* @param Entity[] $entities
* @param Role[] $roles
*/
protected function createManyJointPermissions(array $originalEntities, array $roles)
{
$entities = $this->entitiesToSimpleEntities($originalEntities);
$this->readyEntityCache($entities);
$jointPermissions = [];
// Create a mapping of entity restricted statuses
$entityRestrictedMap = [];
foreach ($entities as $entity) {
$entityRestrictedMap[$entity->type . ':' . $entity->id] = $entity->restricted;
}
// Fetch related entity permissions
$permissions = $this->getEntityPermissionsForEntities($entities);
// Create a mapping of explicit entity permissions
$permissionMap = [];
foreach ($permissions as $permission) {
$key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id;
$isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
$permissionMap[$key] = $isRestricted;
}
// Create a mapping of role permissions
$rolePermissionMap = [];
foreach ($roles as $role) {
foreach ($role->permissions as $permission) {
$rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
}
}
// Create Joint Permission Data
foreach ($entities as $entity) {
foreach ($roles as $role) {
$jointPermissions[] = $this->createJointPermissionData(
$entity,
$role->getRawAttribute('id'),
$permissionMap,
$rolePermissionMap,
$role->system_name === 'admin'
);
}
}
DB::transaction(function () use ($jointPermissions) {
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
DB::table('joint_permissions')->insert($jointPermissionChunk);
}
});
}
/**
* From the given entity list, provide back a mapping of entity types to
* the ids of that given type. The type used is the DB morph class.
*
* @param SimpleEntityData[] $entities
*
* @return array<string, int[]>
*/
protected function entitiesToTypeIdMap(array $entities): array
{
$idsByType = [];
foreach ($entities as $entity) {
if (!isset($idsByType[$entity->type])) {
$idsByType[$entity->type] = [];
}
$idsByType[$entity->type][] = $entity->id;
}
return $idsByType;
}
/**
* Get the entity permissions for all the given entities.
*
* @param SimpleEntityData[] $entities
*
* @return EntityPermission[]
*/
protected function getEntityPermissionsForEntities(array $entities): array
{
$idsByType = $this->entitiesToTypeIdMap($entities);
$permissionFetch = EntityPermission::query()
->where('action', '=', 'view')
->where(function (Builder $query) use ($idsByType) {
foreach ($idsByType as $type => $ids) {
$query->orWhere(function (Builder $query) use ($type, $ids) {
$query->where('restrictable_type', '=', $type)->whereIn('restrictable_id', $ids);
});
}
});
return $permissionFetch->get()->all();
}
/**
* Create entity permission data for an entity and role
* for a particular action.
*/
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, array $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
{
$permissionPrefix = $entity->type . '-view';
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
if ($isAdminRole) {
return $this->createJointPermissionDataArray($entity, $roleId, true, true);
}
if ($entity->restricted) {
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId);
return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess);
}
if ($entity->type === 'book' || $entity->type === 'bookshelf') {
return $this->createJointPermissionDataArray($entity, $roleId, $roleHasPermission, $roleHasPermissionOwn);
}
// For chapters and pages, Check if explicit permissions are set on the Book.
$book = $this->getBook($entity->book_id);
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId);
$hasPermissiveAccessToParents = !$book->restricted;
// For pages with a chapter, Check if explicit permissions are set on the Chapter
if ($entity->type === 'page' && $entity->chapter_id !== 0) {
$chapter = $this->getChapter($entity->chapter_id);
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
if ($chapter->restricted) {
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
}
}
return $this->createJointPermissionDataArray(
$entity,
$roleId,
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
);
}
/**
* Check for an active restriction in an entity map.
*/
protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
{
$key = $entity->type . ':' . $entity->id . ':' . $roleId;
return $entityMap[$key] ?? false;
}
/**
* Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion.
*/
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, bool $permissionAll, bool $permissionOwn): array
{
return [
'entity_id' => $entity->id,
'entity_type' => $entity->type,
'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn,
'owned_by' => $entity->owned_by,
'role_id' => $roleId,
];
}
}

View File

@@ -1,258 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use InvalidArgumentException;
class PermissionApplicator
{
/**
* Checks if an entity has a restriction set upon it.
*
* @param HasCreatorAndUpdater|HasOwner $ownable
*/
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
{
$explodedPermission = explode('-', $permission);
$action = $explodedPermission[1] ?? $explodedPermission[0];
$fullPermission = count($explodedPermission) > 1 ? $permission : $ownable->getMorphClass() . '-' . $permission;
$user = $this->currentUser();
$userRoleIds = $this->getCurrentUserRoleIds();
$allRolePermission = $user->can($fullPermission . '-all');
$ownRolePermission = $user->can($fullPermission . '-own');
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
$ownableFieldVal = $ownable->getAttribute($ownerField);
if (is_null($ownableFieldVal)) {
throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
}
$isOwner = $user->id === $ownableFieldVal;
$hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
// Handle non entity specific jointPermissions
if (in_array($explodedPermission[0], $nonJointPermissions)) {
return $hasRolePermission;
}
$hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action);
return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions;
}
/**
* Check if there are permissions that are applicable for the given entity item, action and roles.
* Returns null when no entity permissions are in force.
*/
protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
{
$adminRoleId = Role::getSystemRole('admin')->id;
if (in_array($adminRoleId, $userRoleIds)) {
return true;
}
$chain = [$entity];
if ($entity instanceof Page && $entity->chapter_id) {
$chain[] = $entity->chapter;
}
if ($entity instanceof Page || $entity instanceof Chapter) {
$chain[] = $entity->book;
}
foreach ($chain as $currentEntity) {
if (is_null($currentEntity->restricted)) {
throw new InvalidArgumentException('Entity restricted field used but has not been loaded');
}
if ($currentEntity->restricted) {
return $currentEntity->permissions()
->whereIn('role_id', $userRoleIds)
->where('action', '=', $action)
->count() > 0;
}
}
return null;
}
/**
* Checks if a user has the given permission for any items in the system.
* Can be passed an entity instance to filter on a specific type.
*/
public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
{
if (strpos($action, '-') !== false) {
throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
}
$permissionQuery = EntityPermission::query()
->where('action', '=', $action)
->whereIn('role_id', $this->getCurrentUserRoleIds());
if (!empty($entityClass)) {
/** @var Entity $entityInstance */
$entityInstance = app()->make($entityClass);
$permissionQuery = $permissionQuery->where('restrictable_type', '=', $entityInstance->getMorphClass());
}
$hasPermission = $permissionQuery->count() > 0;
return $hasPermission;
}
/**
* Limit the given entity query so that the query will only
* return items that the user has view permission for.
*/
public function restrictEntityQuery(Builder $query): Builder
{
return $query->where(function (Builder $parentQuery) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) {
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoleIds())
->where(function (Builder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
});
}
/**
* Extend the given page query to ensure draft items are not visible
* unless created by the given user.
*/
public function restrictDraftsOnPageQuery(Builder $query): Builder
{
return $query->where(function (Builder $query) {
$query->where('draft', '=', false)
->orWhere(function (Builder $query) {
$query->where('draft', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
});
}
/**
* Filter items that have entities set as a polymorphic relation.
* For simplicity, this will not return results attached to draft pages.
* Draft pages should never really have related items though.
*
* @param Builder|QueryBuilder $query
*/
public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn)
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$pageMorphClass = (new Page())->getMorphClass();
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails) {
/** @var Builder $permissionQuery */
$permissionQuery->select(['role_id'])->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
})->where(function ($query) use ($tableDetails, $pageMorphClass) {
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
->where('pages.draft', '=', false);
});
});
return $q;
}
/**
* Add conditions to a query for a model that's a relation of a page, so only the model results
* on visible pages are returned by the query.
* Is effectively the same as "restrictEntityRelationQuery" but takes into account page drafts
* while not expecting a polymorphic relation, Just a simpler one-page-to-many-relations set-up.
*/
public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
{
$fullPageIdColumn = $tableName . '.' . $pageIdColumn;
$morphClass = (new Page())->getMorphClass();
$existsQuery = function ($permissionQuery) use ($fullPageIdColumn, $morphClass) {
/** @var Builder $permissionQuery */
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $fullPageIdColumn)
->where('joint_permissions.entity_type', '=', $morphClass)
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
};
$q = $query->where(function ($query) use ($existsQuery, $fullPageIdColumn) {
$query->whereExists($existsQuery)
->orWhere($fullPageIdColumn, '=', 0);
});
// Prevent visibility of non-owned draft pages
$q->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where(function (QueryBuilder $query) {
$query->where('pages.draft', '=', false)
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
});
});
return $q;
}
/**
* Add the query for checking the given user id has permission
* within the join_permissions table.
*
* @param QueryBuilder|Builder $query
*/
protected function addJointHasPermissionCheck($query, int $userIdToCheck)
{
$query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
$query->where('joint_permissions.has_permission_own', '=', true)
->where('joint_permissions.owned_by', '=', $userIdToCheck);
});
}
/**
* Get the current user.
*/
protected function currentUser(): User
{
return user();
}
/**
* Get the roles for the current logged-in user.
*
* @return int[]
*/
protected function getCurrentUserRoleIds(): array
{
if (auth()->guest()) {
return [Role::getSystemRole('public')->id];
}
return $this->currentUser()->roles->pluck('id')->values()->all();
}
}

View File

@@ -0,0 +1,719 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Throwable;
class PermissionService
{
/**
* @var ?array
*/
protected $userRoles = null;
/**
* @var ?User
*/
protected $currentUserModel = null;
/**
* @var Connection
*/
protected $db;
/**
* @var array
*/
protected $entityCache;
/**
* PermissionService constructor.
*/
public function __construct(Connection $db)
{
$this->db = $db;
}
/**
* Set the database connection.
*/
public function setConnection(Connection $connection)
{
$this->db = $connection;
}
/**
* Prepare the local entity cache and ensure it's empty.
*
* @param Entity[] $entities
*/
protected function readyEntityCache(array $entities = [])
{
$this->entityCache = [];
foreach ($entities as $entity) {
$class = get_class($entity);
if (!isset($this->entityCache[$class])) {
$this->entityCache[$class] = collect();
}
$this->entityCache[$class]->put($entity->id, $entity);
}
}
/**
* Get a book via ID, Checks local cache.
*/
protected function getBook(int $bookId): ?Book
{
if (isset($this->entityCache[Book::class]) && $this->entityCache[Book::class]->has($bookId)) {
return $this->entityCache[Book::class]->get($bookId);
}
return Book::query()->withTrashed()->find($bookId);
}
/**
* Get a chapter via ID, Checks local cache.
*/
protected function getChapter(int $chapterId): ?Chapter
{
if (isset($this->entityCache[Chapter::class]) && $this->entityCache[Chapter::class]->has($chapterId)) {
return $this->entityCache[Chapter::class]->get($chapterId);
}
return Chapter::query()
->withTrashed()
->find($chapterId);
}
/**
* Get the roles for the current logged in user.
*/
protected function getCurrentUserRoles(): array
{
if (!is_null($this->userRoles)) {
return $this->userRoles;
}
if (auth()->guest()) {
$this->userRoles = [Role::getSystemRole('public')->id];
} else {
$this->userRoles = $this->currentUser()->roles->pluck('id')->values()->all();
}
return $this->userRoles;
}
/**
* Re-generate all entity permission from scratch.
*/
public function buildJointPermissions()
{
JointPermission::query()->truncate();
$this->readyEntityCache();
// Get all roles (Should be the most limited dimension)
$roles = Role::query()->with('permissions')->get()->all();
// Chunk through all books
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles);
});
}
/**
* Get a query for fetching a book with it's children.
*/
protected function bookFetchQuery(): Builder
{
return Book::query()->withTrashed()
->select(['id', 'restricted', 'owned_by'])->with([
'chapters' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
},
'pages' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
},
]);
}
/**
* Build joint permissions for the given shelf and role combinations.
*
* @throws Throwable
*/
protected function buildJointPermissionsForShelves(EloquentCollection $shelves, array $roles, bool $deleteOld = false)
{
if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($shelves->all());
}
$this->createManyJointPermissions($shelves->all(), $roles);
}
/**
* Build joint permissions for the given book and role combinations.
*
* @throws Throwable
*/
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
{
$entities = clone $books;
/** @var Book $book */
foreach ($books->all() as $book) {
foreach ($book->getRelation('chapters') as $chapter) {
$entities->push($chapter);
}
foreach ($book->getRelation('pages') as $page) {
$entities->push($page);
}
}
if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($entities->all());
}
$this->createManyJointPermissions($entities->all(), $roles);
}
/**
* Rebuild the entity jointPermissions for a particular entity.
*
* @throws Throwable
*/
public function buildJointPermissionsForEntity(Entity $entity)
{
$entities = [$entity];
if ($entity instanceof Book) {
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
$this->buildJointPermissionsForBooks($books, Role::query()->get()->all(), true);
return;
}
/** @var BookChild $entity */
if ($entity->book) {
$entities[] = $entity->book;
}
if ($entity instanceof Page && $entity->chapter_id) {
$entities[] = $entity->chapter;
}
if ($entity instanceof Chapter) {
foreach ($entity->pages as $page) {
$entities[] = $page;
}
}
$this->buildJointPermissionsForEntities($entities);
}
/**
* Rebuild the entity jointPermissions for a collection of entities.
*
* @throws Throwable
*/
public function buildJointPermissionsForEntities(array $entities)
{
$roles = Role::query()->get()->values()->all();
$this->deleteManyJointPermissionsForEntities($entities);
$this->createManyJointPermissions($entities, $roles);
}
/**
* Build the entity jointPermissions for a particular role.
*/
public function buildJointPermissionForRole(Role $role)
{
$roles = [$role];
$this->deleteManyJointPermissionsForRoles($roles);
// Chunk through all books
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
->chunk(50, function ($shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles);
});
}
/**
* Delete the entity jointPermissions attached to a particular role.
*/
public function deleteJointPermissionsForRole(Role $role)
{
$this->deleteManyJointPermissionsForRoles([$role]);
}
/**
* Delete all of the entity jointPermissions for a list of entities.
*
* @param Role[] $roles
*/
protected function deleteManyJointPermissionsForRoles($roles)
{
$roleIds = array_map(function ($role) {
return $role->id;
}, $roles);
JointPermission::query()->whereIn('role_id', $roleIds)->delete();
}
/**
* Delete the entity jointPermissions for a particular entity.
*
* @param Entity $entity
*
* @throws Throwable
*/
public function deleteJointPermissionsForEntity(Entity $entity)
{
$this->deleteManyJointPermissionsForEntities([$entity]);
}
/**
* Delete all of the entity jointPermissions for a list of entities.
*
* @param Entity[] $entities
*
* @throws Throwable
*/
protected function deleteManyJointPermissionsForEntities(array $entities)
{
if (count($entities) === 0) {
return;
}
$this->db->transaction(function () use ($entities) {
foreach (array_chunk($entities, 1000) as $entityChunk) {
$query = $this->db->table('joint_permissions');
foreach ($entityChunk as $entity) {
$query->orWhere(function (QueryBuilder $query) use ($entity) {
$query->where('entity_id', '=', $entity->id)
->where('entity_type', '=', $entity->getMorphClass());
});
}
$query->delete();
}
});
}
/**
* Create & Save entity jointPermissions for many entities and roles.
*
* @param Entity[] $entities
* @param Role[] $roles
*
* @throws Throwable
*/
protected function createManyJointPermissions(array $entities, array $roles)
{
$this->readyEntityCache($entities);
$jointPermissions = [];
// Fetch Entity Permissions and create a mapping of entity restricted statuses
$entityRestrictedMap = [];
$permissionFetch = EntityPermission::query();
foreach ($entities as $entity) {
$entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted'));
$permissionFetch->orWhere(function ($query) use ($entity) {
$query->where('restrictable_id', '=', $entity->id)->where('restrictable_type', '=', $entity->getMorphClass());
});
}
$permissions = $permissionFetch->get();
// Create a mapping of explicit entity permissions
$permissionMap = [];
foreach ($permissions as $permission) {
$key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id . ':' . $permission->action;
$isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
$permissionMap[$key] = $isRestricted;
}
// Create a mapping of role permissions
$rolePermissionMap = [];
foreach ($roles as $role) {
foreach ($role->permissions as $permission) {
$rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
}
}
// Create Joint Permission Data
foreach ($entities as $entity) {
foreach ($roles as $role) {
foreach ($this->getActions($entity) as $action) {
$jointPermissions[] = $this->createJointPermissionData($entity, $role, $action, $permissionMap, $rolePermissionMap);
}
}
}
$this->db->transaction(function () use ($jointPermissions) {
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
$this->db->table('joint_permissions')->insert($jointPermissionChunk);
}
});
}
/**
* Get the actions related to an entity.
*/
protected function getActions(Entity $entity): array
{
$baseActions = ['view', 'update', 'delete'];
if ($entity instanceof Chapter || $entity instanceof Book) {
$baseActions[] = 'page-create';
}
if ($entity instanceof Book) {
$baseActions[] = 'chapter-create';
}
return $baseActions;
}
/**
* Create entity permission data for an entity and role
* for a particular action.
*/
protected function createJointPermissionData(Entity $entity, Role $role, string $action, array $permissionMap, array $rolePermissionMap): array
{
$permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
$roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']);
$roleHasPermissionOwn = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-own']);
$explodedAction = explode('-', $action);
$restrictionAction = end($explodedAction);
if ($role->system_name === 'admin') {
return $this->createJointPermissionDataArray($entity, $role, $action, true, true);
}
if ($entity->restricted) {
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction);
return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
}
if ($entity instanceof Book || $entity instanceof Bookshelf) {
return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn);
}
// For chapters and pages, Check if explicit permissions are set on the Book.
$book = $this->getBook($entity->book_id);
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $role, $restrictionAction);
$hasPermissiveAccessToParents = !$book->restricted;
// For pages with a chapter, Check if explicit permissions are set on the Chapter
if ($entity instanceof Page && intval($entity->chapter_id) !== 0) {
$chapter = $this->getChapter($entity->chapter_id);
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
if ($chapter->restricted) {
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $role, $restrictionAction);
}
}
return $this->createJointPermissionDataArray(
$entity,
$role,
$action,
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
);
}
/**
* Check for an active restriction in an entity map.
*/
protected function mapHasActiveRestriction(array $entityMap, Entity $entity, Role $role, string $action): bool
{
$key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action;
return $entityMap[$key] ?? false;
}
/**
* Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion.
*/
protected function createJointPermissionDataArray(Entity $entity, Role $role, string $action, bool $permissionAll, bool $permissionOwn): array
{
return [
'role_id' => $role->getRawAttribute('id'),
'entity_id' => $entity->getRawAttribute('id'),
'entity_type' => $entity->getMorphClass(),
'action' => $action,
'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn,
'owned_by' => $entity->getRawAttribute('owned_by'),
];
}
/**
* Checks if an entity has a restriction set upon it.
*
* @param HasCreatorAndUpdater|HasOwner $ownable
*/
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
{
$explodedPermission = explode('-', $permission);
$baseQuery = $ownable->newQuery()->where('id', '=', $ownable->id);
$action = end($explodedPermission);
$user = $this->currentUser();
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
// Handle non entity specific jointPermissions
if (in_array($explodedPermission[0], $nonJointPermissions)) {
$allPermission = $user && $user->can($permission . '-all');
$ownPermission = $user && $user->can($permission . '-own');
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
$isOwner = $user && $user->id === $ownable->$ownerField;
return $allPermission || ($isOwner && $ownPermission);
}
// Handle abnormal create jointPermissions
if ($action === 'create') {
$action = $permission;
}
$hasAccess = $this->entityRestrictionQuery($baseQuery, $action)->count() > 0;
$this->clean();
return $hasAccess;
}
/**
* Checks if a user has the given permission for any items in the system.
* Can be passed an entity instance to filter on a specific type.
*/
public function checkUserHasPermissionOnAnything(string $permission, ?string $entityClass = null): bool
{
$userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray();
$userId = $this->currentUser()->id;
$permissionQuery = JointPermission::query()
->where('action', '=', $permission)
->whereIn('role_id', $userRoleIds)
->where(function (Builder $query) use ($userId) {
$this->addJointHasPermissionCheck($query, $userId);
});
if (!is_null($entityClass)) {
$entityInstance = app($entityClass);
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
}
$hasPermission = $permissionQuery->count() > 0;
$this->clean();
return $hasPermission;
}
/**
* The general query filter to remove all entities
* that the current user does not have access to.
*/
protected function entityRestrictionQuery(Builder $query, string $action): Builder
{
$q = $query->where(function ($parentQuery) use ($action) {
$parentQuery->whereHas('jointPermissions', function ($permissionQuery) use ($action) {
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
->where('action', '=', $action)
->where(function (Builder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
});
$this->clean();
return $q;
}
/**
* Limited the given entity query so that the query will only
* return items that the user has permission for the given ability.
*/
public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
{
$this->clean();
return $query->where(function (Builder $parentQuery) use ($ability) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
->where('action', '=', $ability)
->where(function (Builder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
});
}
/**
* Extend the given page query to ensure draft items are not visible
* unless created by the given user.
*/
public function enforceDraftVisibilityOnQuery(Builder $query): Builder
{
return $query->where(function (Builder $query) {
$query->where('draft', '=', false)
->orWhere(function (Builder $query) {
$query->where('draft', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
});
}
/**
* Add restrictions for a generic entity.
*/
public function enforceEntityRestrictions(Entity $entity, Builder $query, string $action = 'view'): Builder
{
if ($entity instanceof Page) {
// Prevent drafts being visible to others.
$this->enforceDraftVisibilityOnQuery($query);
}
return $this->entityRestrictionQuery($query, $action);
}
/**
* Filter items that have entities set as a polymorphic relation.
* For simplicity, this will not return results attached to draft pages.
* Draft pages should never really have related items though.
*
* @param Builder|QueryBuilder $query
*/
public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$pageMorphClass = (new Page())->getMorphClass();
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
/** @var Builder $permissionQuery */
$permissionQuery->select(['role_id'])->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->where('joint_permissions.action', '=', $action)
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
})->where(function ($query) use ($tableDetails, $pageMorphClass) {
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
->where('pages.draft', '=', false);
});
});
$this->clean();
return $q;
}
/**
* Add conditions to a query to filter the selection to related entities
* where view permissions are granted.
*/
public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
{
$fullEntityIdColumn = $tableName . '.' . $entityIdColumn;
$instance = new $entityClass();
$morphClass = $instance->getMorphClass();
$existsQuery = function ($permissionQuery) use ($fullEntityIdColumn, $morphClass) {
/** @var Builder $permissionQuery */
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $fullEntityIdColumn)
->where('joint_permissions.entity_type', '=', $morphClass)
->where('joint_permissions.action', '=', 'view')
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
};
$q = $query->where(function ($query) use ($existsQuery, $fullEntityIdColumn) {
$query->whereExists($existsQuery)
->orWhere($fullEntityIdColumn, '=', 0);
});
if ($instance instanceof Page) {
// Prevent visibility of non-owned draft pages
$q->whereExists(function (QueryBuilder $query) use ($fullEntityIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullEntityIdColumn)
->where(function (QueryBuilder $query) {
$query->where('pages.draft', '=', false)
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
});
});
}
$this->clean();
return $q;
}
/**
* Add the query for checking the given user id has permission
* within the join_permissions table.
*
* @param QueryBuilder|Builder $query
*/
protected function addJointHasPermissionCheck($query, int $userIdToCheck)
{
$query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
$query->where('joint_permissions.has_permission_own', '=', true)
->where('joint_permissions.owned_by', '=', $userIdToCheck);
});
}
/**
* Get the current user.
*/
private function currentUser(): User
{
if (is_null($this->currentUserModel)) {
$this->currentUserModel = user();
}
return $this->currentUserModel;
}
/**
* Clean the cached user elements.
*/
private function clean(): void
{
$this->currentUserModel = null;
$this->userRoles = null;
}
}

View File

@@ -11,15 +11,20 @@ use Illuminate\Database\Eloquent\Collection;
class PermissionsRepo
{
protected JointPermissionBuilder $permissionBuilder;
protected $permission;
protected $role;
protected $permissionService;
protected $systemRoles = ['admin', 'public'];
/**
* PermissionsRepo constructor.
*/
public function __construct(JointPermissionBuilder $permissionBuilder)
public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
{
$this->permissionBuilder = $permissionBuilder;
$this->permission = $permission;
$this->role = $role;
$this->permissionService = $permissionService;
}
/**
@@ -27,7 +32,7 @@ class PermissionsRepo
*/
public function getAllRoles(): Collection
{
return Role::query()->get();
return $this->role->all();
}
/**
@@ -35,7 +40,7 @@ class PermissionsRepo
*/
public function getAllRolesExcept(Role $role): Collection
{
return Role::query()->where('id', '!=', $role->id)->get();
return $this->role->where('id', '!=', $role->id)->get();
}
/**
@@ -43,7 +48,7 @@ class PermissionsRepo
*/
public function getRoleById($id): Role
{
return Role::query()->findOrFail($id);
return $this->role->newQuery()->findOrFail($id);
}
/**
@@ -51,14 +56,13 @@ class PermissionsRepo
*/
public function saveNewRole(array $roleData): Role
{
$role = new Role($roleData);
$role = $this->role->newInstance($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->save();
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions);
$this->permissionBuilder->rebuildForRole($role);
$this->permissionService->buildJointPermissionForRole($role);
Activity::add(ActivityType::ROLE_CREATE, $role);
return $role;
@@ -70,7 +74,8 @@ class PermissionsRepo
*/
public function updateRole($roleId, array $roleData)
{
$role = $this->getRoleById($roleId);
/** @var Role $role */
$role = $this->role->newQuery()->findOrFail($roleId);
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
if ($role->system_name === 'admin') {
@@ -88,13 +93,12 @@ class PermissionsRepo
$role->fill($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->save();
$this->permissionBuilder->rebuildForRole($role);
$this->permissionService->buildJointPermissionForRole($role);
Activity::add(ActivityType::ROLE_UPDATE, $role);
}
/**
* Assign a list of permission names to a role.
* Assign an list of permission names to an role.
*/
protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
{
@@ -102,7 +106,7 @@ class PermissionsRepo
$permissionNameArray = array_values($permissionNameArray);
if ($permissionNameArray) {
$permissions = RolePermission::query()
$permissions = $this->permission->newQuery()
->whereIn('name', $permissionNameArray)
->pluck('id')
->toArray();
@@ -122,7 +126,8 @@ class PermissionsRepo
*/
public function deleteRole($roleId, $migrateRoleId)
{
$role = $this->getRoleById($roleId);
/** @var Role $role */
$role = $this->role->newQuery()->findOrFail($roleId);
// Prevent deleting admin role or default registration role.
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
@@ -132,14 +137,14 @@ class PermissionsRepo
}
if ($migrateRoleId) {
$newRole = Role::query()->find($migrateRoleId);
$newRole = $this->role->newQuery()->find($migrateRoleId);
if ($newRole) {
$users = $role->users()->pluck('id')->toArray();
$newRole->users()->sync($users);
}
}
$role->jointPermissions()->delete();
$this->permissionService->deleteJointPermissionsForRole($role);
Activity::add(ActivityType::ROLE_DELETE, $role);
$role->delete();
}

View File

@@ -1,13 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
class SimpleEntityData
{
public int $id;
public string $type;
public bool $restricted;
public int $owned_by;
public ?int $book_id;
public ?int $chapter_id;
}

View File

@@ -80,11 +80,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected ?Collection $permissions;
/**
* This holds the user's avatar URL when loaded to prevent re-calculating within the same request.
*/
protected string $avatarUrl = '';
/**
* This holds the default user when loaded.
*
@@ -168,7 +163,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
/**
* Get all permissions belonging to the current user.
* Get all permissions belonging to a the current user.
*/
protected function permissions(): Collection
{
@@ -238,18 +233,12 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $default;
}
if (!empty($this->avatarUrl)) {
return $this->avatarUrl;
}
try {
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
} catch (Exception $err) {
$avatar = $default;
}
$this->avatarUrl = $avatar;
return $avatar;
}

View File

@@ -22,7 +22,7 @@ return [
// The number of revisions to keep in the database.
// Once this limit is reached older revisions will be deleted.
// If set to false then a limit will not be enforced.
'revision_limit' => env('REVISION_LIMIT', 100),
'revision_limit' => env('REVISION_LIMIT', 50),
// The number of days that content will remain in the recycle bin before
// being considered for auto-removal. It is not a guarantee that content will
@@ -64,10 +64,6 @@ return [
// Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),
// Alter the precision of IP addresses stored by BookStack.
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
// Application timezone for back-end date functions.
'timezone' => env('APP_TIMEZONE', 'UTC'),
@@ -75,7 +71,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
// Application Fallback Locale
'fallback_locale' => 'en',
@@ -201,9 +197,12 @@ return [
// Third Party
'ImageTool' => Intervention\Image\Facades\Image::class,
'DomPDF' => Barryvdh\DomPDF\Facade::class,
'SnappyPDF' => Barryvdh\Snappy\Facades\SnappyPdf::class,
// Custom BookStack
'Activity' => BookStack\Facades\Activity::class,
'Permissions' => BookStack\Facades\Permissions::class,
'Theme' => BookStack\Facades\Theme::class,
],

View File

@@ -13,10 +13,6 @@ return [
// Options: standard, ldap, saml2, oidc
'method' => env('AUTH_METHOD', 'standard'),
// Automatically initiate login via external auth system if it's the sole auth method.
// Works with saml2 or oidc auth methods.
'auto_initiate' => env('AUTH_AUTO_INITIATE', false),
// Authentication Defaults
// This option controls the default authentication "guard" and password
// reset options for your application.

View File

@@ -15,8 +15,8 @@ $dompdfPaperSizeMap = [
return [
'show_warnings' => false, // Throw an Exception on warnings from dompdf
'options' => [
'orientation' => 'portrait',
'defines' => [
/**
* The location of the DOMPDF font directory.
*
@@ -77,25 +77,15 @@ return [
'chroot' => realpath(public_path()),
/**
* Protocol whitelist.
* Whether to use Unicode fonts or not.
*
* Protocols and PHP wrappers allowed in URIs, and the validation rules
* that determine if a resouce may be loaded. Full support is not guaranteed
* for the protocols/wrappers specified
* by this array.
* When set to true the PDF backend must be set to "CPDF" and fonts must be
* loaded via load_font.php.
*
* @var array
* When enabled, dompdf can support all Unicode glyphs. Any glyphs used in a
* document must be present in your fonts, however.
*/
'allowed_protocols' => [
'file://' => ['rules' => []],
'http://' => ['rules' => []],
'https://' => ['rules' => []],
],
/**
* @var string
*/
'log_output_file' => null,
'unicode_enabled' => true,
/**
* Whether to enable font subsetting or not.
@@ -166,15 +156,6 @@ return [
*/
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
/**
* The default paper orientation.
*
* The orientation of the page (portrait or landscape).
*
* @var string
*/
'default_paper_orientation' => 'portrait',
/**
* The default font family.
*
@@ -277,13 +258,10 @@ return [
'enable_css_float' => true,
/**
* Use the HTML5 Lib parser.
*
* @deprecated This feature is now always on in dompdf 2.x
*
* @var bool
* Use the more-than-experimental HTML5 Lib parser.
*/
'enable_html5_parser' => true,
'enable_html5parser' => true,
],
];

View File

@@ -32,16 +32,4 @@ return [
// OAuth2 endpoints.
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
// Add extra scopes, upon those required, to the OIDC authentication request
// Multiple values can be provided comma seperated.
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
// Group sync options
// Enable syncing, upon login, of OIDC groups to BookStack roles
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
// Attribute, within a OIDC ID token, to find group names within
'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'),
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
];

View File

@@ -5,7 +5,6 @@ namespace BookStack\Console\Commands;
use BookStack\Actions\Comment;
use BookStack\Actions\CommentRepo;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RegenerateCommentContent extends Command
{
@@ -44,9 +43,9 @@ class RegenerateCommentContent extends Command
*/
public function handle()
{
$connection = DB::getDefaultConnection();
$connection = \DB::getDefaultConnection();
if ($this->option('database') !== null) {
DB::setDefaultConnection($this->option('database'));
\DB::setDefaultConnection($this->option('database'));
}
Comment::query()->chunk(100, function ($comments) {
@@ -56,9 +55,7 @@ class RegenerateCommentContent extends Command
}
});
DB::setDefaultConnection($connection);
\DB::setDefaultConnection($connection);
$this->comment('Comment HTML content has been regenerated');
return 0;
}
}

View File

@@ -2,9 +2,8 @@
namespace BookStack\Console\Commands;
use BookStack\Auth\Permissions\JointPermissionBuilder;
use BookStack\Auth\Permissions\PermissionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RegeneratePermissions extends Command
{
@@ -22,14 +21,19 @@ class RegeneratePermissions extends Command
*/
protected $description = 'Regenerate all system permissions';
protected JointPermissionBuilder $permissionBuilder;
/**
* The service to handle the permission system.
*
* @var PermissionService
*/
protected $permissionService;
/**
* Create a new command instance.
*/
public function __construct(JointPermissionBuilder $permissionBuilder)
public function __construct(PermissionService $permissionService)
{
$this->permissionBuilder = $permissionBuilder;
$this->permissionService = $permissionService;
parent::__construct();
}
@@ -40,17 +44,15 @@ class RegeneratePermissions extends Command
*/
public function handle()
{
$connection = DB::getDefaultConnection();
if ($this->option('database')) {
DB::setDefaultConnection($this->option('database'));
$connection = \DB::getDefaultConnection();
if ($this->option('database') !== null) {
\DB::setDefaultConnection($this->option('database'));
$this->permissionService->setConnection(\DB::connection($this->option('database')));
}
$this->permissionBuilder->rebuildForAll();
$this->permissionService->buildJointPermissions();
DB::setDefaultConnection($connection);
\DB::setDefaultConnection($connection);
$this->comment('Permissions regenerated');
return 0;
}
}

View File

@@ -1,59 +0,0 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\References\ReferenceStore;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RegenerateReferences extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:regenerate-references {--database= : The database connection to use.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Regenerate all the cross-item model reference index';
protected ReferenceStore $references;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(ReferenceStore $references)
{
$this->references = $references;
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$connection = DB::getDefaultConnection();
if ($this->option('database')) {
DB::setDefaultConnection($this->option('database'));
}
$this->references->updateForAllPages();
DB::setDefaultConnection($connection);
$this->comment('References have been regenerated');
return 0;
}
}

View File

@@ -3,7 +3,7 @@
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\Entity;
use BookStack\Search\SearchIndex;
use BookStack\Entities\Tools\SearchIndex;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

View File

@@ -2,7 +2,6 @@
namespace BookStack\Entities\Models;
use BookStack\References\ReferenceUpdater;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -58,16 +57,11 @@ abstract class BookChild extends Entity
*/
public function changeBook(int $newBookId): Entity
{
$oldUrl = $this->getUrl();
$this->book_id = $newBookId;
$this->refreshSlug();
$this->save();
$this->refresh();
if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityPageReferences($this, $oldUrl);
}
// Update all child pages if a chapter
if ($this instanceof Chapter) {
foreach ($this->pages()->withTrashed()->get() as $page) {

View File

@@ -86,11 +86,15 @@ class Bookshelf extends Entity implements HasCoverImage
*/
public function coverImageTypeKey(): string
{
return 'cover_bookshelf';
return 'cover_shelf';
}
/**
* Check if this shelf contains the given book.
*
* @param Book $book
*
* @return bool
*/
public function contains(Book $book): bool
{
@@ -99,6 +103,8 @@ class Bookshelf extends Entity implements HasCoverImage
/**
* Add a book to the end of this shelf.
*
* @param Book $book
*/
public function appendBook(Book $book)
{

View File

@@ -9,18 +9,15 @@ use BookStack\Actions\Tag;
use BookStack\Actions\View;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\JointPermissionBuilder;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Tools\SearchIndex;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Facades\Permissions;
use BookStack\Interfaces\Deletable;
use BookStack\Interfaces\Favouritable;
use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
use BookStack\References\Reference;
use BookStack\Search\SearchIndex;
use BookStack\Search\SearchTerm;
use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner;
use Carbon\Carbon;
@@ -46,6 +43,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property Collection $tags
*
* @method static Entity|Builder visible()
* @method static Entity|Builder hasPermission(string $permission)
* @method static Builder withLastView()
* @method static Builder withViewCount()
*/
@@ -70,7 +68,15 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function scopeVisible(Builder $query): Builder
{
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
return $this->scopeHasPermission($query, 'view');
}
/**
* Scope the query to those entities that the current user has the given permission for.
*/
public function scopeHasPermission(Builder $query, string $permission)
{
return Permissions::restrictEntityQuery($query, $permission);
}
/**
@@ -204,22 +210,6 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
return $this->morphMany(Deletion::class, 'deletable');
}
/**
* Get the references pointing from this entity to other items.
*/
public function referencesFrom(): MorphMany
{
return $this->morphMany(Reference::class, 'from');
}
/**
* Get the references pointing to this entity from other items.
*/
public function referencesTo(): MorphMany
{
return $this->morphMany(Reference::class, 'to');
}
/**
* Check if this instance or class is a certain type of entity.
* Examples of $type are 'page', 'book', 'chapter'.
@@ -294,7 +284,8 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function rebuildPermissions()
{
app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
/** @noinspection PhpUnhandledExceptionInspection */
Permissions::buildJointPermissionsForEntity(clone $this);
}
/**
@@ -302,7 +293,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function indexForSearch()
{
app()->make(SearchIndex::class)->indexEntity(clone $this);
app(SearchIndex::class)->indexEntity(clone $this);
}
/**
@@ -310,7 +301,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function refreshSlug(): string
{
$this->slug = app()->make(SlugGenerator::class)->generate($this);
$this->slug = app(SlugGenerator::class)->generate($this);
return $this->slug;
}

View File

@@ -2,8 +2,8 @@
namespace BookStack\Entities\Models;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Tools\PageContent;
use BookStack\Facades\Permissions;
use BookStack\Uploads\Attachment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
@@ -51,7 +51,7 @@ class Page extends BookChild
*/
public function scopeVisible(Builder $query): Builder
{
$query = app()->make(PermissionApplicator::class)->restrictDraftsOnPageQuery($query);
$query = Permissions::enforceDraftVisibilityOnQuery($query);
return parent::scopeVisible($query);
}

View File

@@ -3,7 +3,6 @@
namespace BookStack\Entities\Models;
use BookStack\Auth\User;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -28,7 +27,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property Page $page
* @property-read ?User $createdBy
*/
class PageRevision extends Model implements Loggable
class PageRevision extends Model
{
protected $fillable = ['name', 'text', 'summary'];
protected $hidden = ['html', 'markdown', 'restricted', 'text'];
@@ -84,9 +83,4 @@ class PageRevision extends Model implements Loggable
{
return $type === 'revision';
}
public function logDescriptor(): string
{
return "Revision #{$this->revision_number} (ID: {$this->id}) for page ID {$this->page_id}";
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Search;
namespace BookStack\Entities\Models;
use BookStack\Model;

View File

@@ -2,14 +2,14 @@
namespace BookStack\Entities\Queries;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\EntityProvider;
abstract class EntityQuery
{
protected function permissionService(): PermissionApplicator
protected function permissionService(): PermissionService
{
return app()->make(PermissionApplicator::class);
return app()->make(PermissionService::class);
}
protected function entityProvider(): EntityProvider

View File

@@ -7,10 +7,10 @@ use Illuminate\Support\Facades\DB;
class Popular extends EntityQuery
{
public function run(int $count, int $page, array $filterModels = null)
public function run(int $count, int $page, array $filterModels = null, string $action = 'view')
{
$query = $this->permissionService()
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
->filterRestrictedEntityRelations(View::query(), 'views', 'viewable_id', 'viewable_type', $action)
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');

View File

@@ -14,11 +14,12 @@ class RecentlyViewed extends EntityQuery
return collect();
}
$query = $this->permissionService()->restrictEntityRelationQuery(
$query = $this->permissionService()->filterRestrictedEntityRelations(
View::query(),
'views',
'viewable_id',
'viewable_type'
'viewable_type',
'view'
)
->orderBy('views.updated_at', 'desc')
->where('user_id', '=', user()->id);

View File

@@ -15,7 +15,7 @@ class TopFavourites extends EntityQuery
}
$query = $this->permissionService()
->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type')
->filterRestrictedEntityRelations(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type', 'view')
->select('favourites.*')
->leftJoin('views', function (JoinClause $join) {
$join->on('favourites.favouritable_id', '=', 'views.viewable_id');

View File

@@ -6,7 +6,6 @@ use BookStack\Actions\TagRepo;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceUpdater;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\UploadedFile;
@@ -14,13 +13,11 @@ class BaseRepo
{
protected TagRepo $tagRepo;
protected ImageRepo $imageRepo;
protected ReferenceUpdater $referenceUpdater;
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater)
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
{
$this->tagRepo = $tagRepo;
$this->imageRepo = $imageRepo;
$this->referenceUpdater = $referenceUpdater;
}
/**
@@ -41,7 +38,6 @@ class BaseRepo
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
}
$entity->refresh();
$entity->rebuildPermissions();
$entity->indexForSearch();
}
@@ -51,12 +47,10 @@ class BaseRepo
*/
public function update(Entity $entity, array $input)
{
$oldUrl = $entity->getUrl();
$entity->fill($input);
$entity->updated_by = user()->id;
if ($entity->isDirty('name') || empty($entity->slug)) {
if ($entity->isDirty('name')) {
$entity->refreshSlug();
}
@@ -69,10 +63,6 @@ class BaseRepo
$entity->rebuildPermissions();
$entity->indexForSearch();
if ($oldUrl !== $entity->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
}
}
/**
@@ -86,9 +76,8 @@ class BaseRepo
public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false)
{
if ($coverImage) {
$imageType = $entity->coverImageTypeKey();
$this->imageRepo->destroyImage($entity->cover);
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
$image = $this->imageRepo->saveNew($coverImage, 'cover_book', $entity->id, 512, 512, true);
$entity->cover()->associate($image);
$entity->save();
}

View File

@@ -91,7 +91,6 @@ class BookRepo
{
$book = new Book();
$this->baseRepo->create($book, $input);
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
Activity::add(ActivityType::BOOK_CREATE, $book);
return $book;
@@ -103,11 +102,6 @@ class BookRepo
public function update(Book $book, array $input): Book
{
$this->baseRepo->update($book, $input);
if (array_key_exists('image', $input)) {
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
}
Activity::add(ActivityType::BOOK_UPDATE, $book);
return $book;

View File

@@ -6,10 +6,12 @@ use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BookshelfRepo
@@ -87,7 +89,6 @@ class BookshelfRepo
{
$shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input);
$this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
$this->updateBooks($shelf, $bookIds);
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
@@ -105,17 +106,14 @@ class BookshelfRepo
$this->updateBooks($shelf, $bookIds);
}
if (array_key_exists('image', $input)) {
$this->baseRepo->updateCoverImage($shelf, $input['image'], $input['image'] === null);
}
Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf);
return $shelf;
}
/**
* Update which books are assigned to this shelf by syncing the given book ids.
* Update which books are assigned to this shelf by
* syncing the given book ids.
* Function ensures the books are visible to the current user and existing.
*/
protected function updateBooks(Bookshelf $shelf, array $bookIds)
@@ -134,13 +132,24 @@ class BookshelfRepo
$shelf->books()->sync($syncData);
}
/**
* Update the given shelf cover image, or clear it.
*
* @throws ImageUploadException
* @throws Exception
*/
public function updateCoverImage(Bookshelf $shelf, ?UploadedFile $coverImage, bool $removeImage = false)
{
$this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage);
}
/**
* Copy down the permissions of the given shelf to all child books.
*/
public function copyDownPermissions(Bookshelf $shelf, $checkUserPermissions = true): int
{
$shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
$shelfBooks = $shelf->books()->get(['id', 'restricted', 'owned_by']);
$shelfBooks = $shelf->books()->get(['id', 'restricted']);
$updatedBookCount = 0;
/** @var Book $book */

View File

@@ -16,31 +16,20 @@ 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\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
class PageRepo
{
protected BaseRepo $baseRepo;
protected RevisionRepo $revisionRepo;
protected ReferenceStore $referenceStore;
protected ReferenceUpdater $referenceUpdater;
protected $baseRepo;
/**
* PageRepo constructor.
*/
public function __construct(
BaseRepo $baseRepo,
RevisionRepo $revisionRepo,
ReferenceStore $referenceStore,
ReferenceUpdater $referenceUpdater
) {
public function __construct(BaseRepo $baseRepo)
{
$this->baseRepo = $baseRepo;
$this->revisionRepo = $revisionRepo;
$this->referenceStore = $referenceStore;
$this->referenceUpdater = $referenceUpdater;
}
/**
@@ -50,7 +39,6 @@ class PageRepo
*/
public function getById(int $id, array $relations = ['book']): Page
{
/** @var Page $page */
$page = Page::visible()->with($relations)->find($id);
if (!$page) {
@@ -82,7 +70,17 @@ class PageRepo
*/
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
{
$revision = $this->revisionRepo->getBySlugs($bookSlug, $pageSlug);
/** @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->page ?? null;
}
@@ -114,7 +112,7 @@ class PageRepo
public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity
{
if ($chapterSlug !== null) {
return Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
}
return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
@@ -125,7 +123,9 @@ class PageRepo
*/
public function getUserDraft(Page $page): ?PageRevision
{
return $this->revisionRepo->getLatestDraftForCurrentUser($page);
$revision = $this->getUserDraftQuery($page)->first();
return $revision;
}
/**
@@ -165,10 +165,11 @@ class PageRepo
$draft->draft = false;
$draft->revision_count = 1;
$draft->priority = $this->getNewPriority($draft);
$draft->refreshSlug();
$draft->save();
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
$this->referenceStore->updateForPage($draft);
$this->savePageRevision($draft, trans('entities.pages_initial_revision'));
$draft->indexForSearch();
$draft->refresh();
Activity::add(ActivityType::PAGE_CREATE, $draft);
@@ -188,14 +189,13 @@ class PageRepo
$this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, $input);
$this->referenceStore->updateForPage($page);
// Update with new details
$page->revision_count++;
$page->save();
// Remove all update drafts for this user & page.
$this->revisionRepo->deleteDraftsForCurrentUser($page);
$this->getUserDraftQuery($page)->delete();
// Save a revision after updating
$summary = trim($input['summary'] ?? '');
@@ -203,7 +203,7 @@ class PageRepo
$nameChanged = isset($input['name']) && $input['name'] !== $oldName;
$markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown;
if ($htmlChanged || $nameChanged || $markdownChanged || $summary) {
$this->revisionRepo->storeNewForPage($page, $summary);
$this->savePageRevision($page, $summary);
}
Activity::add(ActivityType::PAGE_UPDATE, $page);
@@ -239,6 +239,32 @@ class PageRepo
}
}
/**
* Saves a page revision into the system.
*/
protected function savePageRevision(Page $page, string $summary = null): PageRevision
{
$revision = new PageRevision();
$revision->name = $page->name;
$revision->html = $page->html;
$revision->markdown = $page->markdown;
$revision->text = $page->text;
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
$revision->created_by = user()->id;
$revision->created_at = $page->updated_at;
$revision->type = 'version';
$revision->summary = $summary;
$revision->revision_number = $page->revision_count;
$revision->save();
$this->deleteOldRevisions($page);
return $revision;
}
/**
* Save a page update draft.
*/
@@ -254,7 +280,7 @@ class PageRepo
}
// Otherwise, save the data to a revision
$draft = $this->revisionRepo->getNewDraftForCurrentUser($page);
$draft = $this->getPageRevisionToUpdate($page);
$draft->fill($input);
if (!empty($input['markdown'])) {
@@ -288,7 +314,6 @@ class PageRepo
*/
public function restoreRevision(Page $page, int $revisionId): Page
{
$oldUrl = $page->getUrl();
$page->revision_count++;
/** @var PageRevision $revision */
@@ -307,17 +332,11 @@ class PageRepo
$page->refreshSlug();
$page->save();
$page->indexForSearch();
$this->referenceStore->updateForPage($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->savePageRevision($page, $summary);
Activity::add(ActivityType::PAGE_RESTORE, $page);
Activity::add(ActivityType::REVISION_RESTORE, $revision);
return $page;
}
@@ -373,6 +392,65 @@ class PageRepo
return $parentClass::visible()->where('id', '=', $entityId)->first();
}
/**
* Change the page's parent to the given entity.
*/
protected function changeParent(Page $page, Entity $parent)
{
$book = ($parent instanceof Chapter) ? $parent->book : $parent;
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
$page->save();
if ($page->book->id !== $book->id) {
$page->changeBook($book->id);
}
$page->load('book');
$book->rebuildPermissions();
}
/**
* Get a page revision to update for the given page.
* Checks for an existing revisions before providing a fresh one.
*/
protected function getPageRevisionToUpdate(Page $page): PageRevision
{
$drafts = $this->getUserDraftQuery($page)->get();
if ($drafts->count() > 0) {
return $drafts->first();
}
$draft = new PageRevision();
$draft->page_id = $page->id;
$draft->slug = $page->slug;
$draft->book_slug = $page->book->slug;
$draft->created_by = user()->id;
$draft->type = 'update_draft';
return $draft;
}
/**
* Delete old revisions, for the given page, from the system.
*/
protected function deleteOldRevisions(Page $page)
{
$revisionLimit = config('app.revision_limit');
if ($revisionLimit === false) {
return;
}
$revisionsToDelete = PageRevision::query()
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc')
->skip(intval($revisionLimit))
->take(10)
->get(['id']);
if ($revisionsToDelete->count() > 0) {
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
}
}
/**
* Get a new priority for a page.
*/
@@ -388,4 +466,15 @@ class PageRepo
return (new BookContents($page->book))->getLastPriority() + 1;
}
/**
* Get the query to find the user's draft copies of the given page.
*/
protected function getUserDraftQuery(Page $page)
{
return PageRevision::query()->where('created_by', '=', user()->id)
->where('type', 'update_draft')
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc');
}
}

View File

@@ -1,131 +0,0 @@
<?php
namespace BookStack\Entities\Repos;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
use Illuminate\Database\Eloquent\Builder;
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;
}
/**
* Delete all drafts revisions, for the given page, belonging to the current user.
*/
public function deleteDraftsForCurrentUser(Page $page): void
{
$this->queryForCurrentUserDraft($page->id)->delete();
}
/**
* Get a user update_draft page revision to update for the given page.
* Checks for an existing revisions before providing a fresh one.
*/
public function getNewDraftForCurrentUser(Page $page): PageRevision
{
$draft = $this->getLatestDraftForCurrentUser($page);
if ($draft) {
return $draft;
}
$draft = new PageRevision();
$draft->page_id = $page->id;
$draft->slug = $page->slug;
$draft->book_slug = $page->book->slug;
$draft->created_by = user()->id;
$draft->type = 'update_draft';
return $draft;
}
/**
* Store a new revision in the system for the given page.
*/
public function storeNewForPage(Page $page, string $summary = null): PageRevision
{
$revision = new PageRevision();
$revision->name = $page->name;
$revision->html = $page->html;
$revision->markdown = $page->markdown;
$revision->text = $page->text;
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
$revision->created_by = user()->id;
$revision->created_at = $page->updated_at;
$revision->type = 'version';
$revision->summary = $summary;
$revision->revision_number = $page->revision_count;
$revision->save();
$this->deleteOldRevisions($page);
return $revision;
}
/**
* Delete old revisions, for the given page, from the system.
*/
protected function deleteOldRevisions(Page $page)
{
$revisionLimit = config('app.revision_limit');
if ($revisionLimit === false) {
return;
}
$revisionsToDelete = PageRevision::query()
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc')
->skip(intval($revisionLimit))
->take(10)
->get(['id']);
if ($revisionsToDelete->count() > 0) {
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

@@ -16,10 +16,25 @@ use Illuminate\Http\UploadedFile;
class Cloner
{
protected PageRepo $pageRepo;
protected ChapterRepo $chapterRepo;
protected BookRepo $bookRepo;
protected ImageService $imageService;
/**
* @var PageRepo
*/
protected $pageRepo;
/**
* @var ChapterRepo
*/
protected $chapterRepo;
/**
* @var BookRepo
*/
protected $bookRepo;
/**
* @var ImageService
*/
protected $imageService;
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
{
@@ -35,8 +50,11 @@ class Cloner
public function clonePage(Page $original, Entity $parent, string $newName): Page
{
$copyPage = $this->pageRepo->getNewDraftPage($parent);
$pageData = $this->entityToInputData($original);
$pageData = $original->getAttributes();
// Update name & tags
$pageData['name'] = $newName;
$pageData['tags'] = $this->entityTagsToInputArray($original);
return $this->pageRepo->publishDraft($copyPage, $pageData);
}
@@ -47,8 +65,9 @@ class Cloner
*/
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
{
$chapterDetails = $this->entityToInputData($original);
$chapterDetails = $original->getAttributes();
$chapterDetails['name'] = $newName;
$chapterDetails['tags'] = $this->entityTagsToInputArray($original);
$copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
@@ -68,8 +87,9 @@ class Cloner
*/
public function cloneBook(Book $original, string $newName): Book
{
$bookDetails = $this->entityToInputData($original);
$bookDetails = $original->getAttributes();
$bookDetails['name'] = $newName;
$bookDetails['tags'] = $this->entityTagsToInputArray($original);
$copyBook = $this->bookRepo->create($bookDetails);
@@ -84,48 +104,26 @@ class Cloner
}
}
return $copyBook;
}
/**
* Convert an entity to a raw data array of input data.
*
* @return array<string, mixed>
*/
public function entityToInputData(Entity $entity): array
{
$inputData = $entity->getAttributes();
$inputData['tags'] = $this->entityTagsToInputArray($entity);
// Add a cover to the data if existing on the original entity
if ($entity->cover instanceof Image) {
$uploadedFile = $this->imageToUploadedFile($entity->cover);
$inputData['image'] = $uploadedFile;
if ($original->cover) {
try {
$tmpImgFile = tmpfile();
$uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile);
$this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false);
} catch (\Exception $exception) {
}
}
return $inputData;
}
/**
* Copy the permission settings from the source entity to the target entity.
*/
public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void
{
$targetEntity->restricted = $sourceEntity->restricted;
$permissions = $sourceEntity->permissions()->get(['role_id', 'action'])->toArray();
$targetEntity->permissions()->delete();
$targetEntity->permissions()->createMany($permissions);
$targetEntity->rebuildPermissions();
return $copyBook;
}
/**
* Convert an image instance to an UploadedFile instance to mimic
* a file being uploaded.
*/
protected function imageToUploadedFile(Image $image): ?UploadedFile
protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile
{
$imgData = $this->imageService->getImageData($image);
$tmpImgFilePath = tempnam(sys_get_temp_dir(), 'bs_cover_clone_');
$tmpImgFilePath = stream_get_meta_data($tmpFile)['uri'];
file_put_contents($tmpImgFilePath, $imgData);
return new UploadedFile($tmpImgFilePath, basename($image->path));

View File

@@ -39,7 +39,7 @@ class ExportFormatter
public function pageToContainedHtml(Page $page)
{
$page->html = (new PageContent($page))->render();
$pageHtml = view('exports.page', [
$pageHtml = view('pages.export', [
'page' => $page,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
@@ -59,7 +59,7 @@ class ExportFormatter
$pages->each(function ($page) {
$page->html = (new PageContent($page))->render();
});
$html = view('exports.chapter', [
$html = view('chapters.export', [
'chapter' => $chapter,
'pages' => $pages,
'format' => 'html',
@@ -77,7 +77,7 @@ class ExportFormatter
public function bookToContainedHtml(Book $book)
{
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('exports.book', [
$html = view('books.export', [
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'html',
@@ -95,7 +95,7 @@ class ExportFormatter
public function pageToPdf(Page $page)
{
$page->html = (new PageContent($page))->render();
$html = view('exports.page', [
$html = view('pages.export', [
'page' => $page,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
@@ -116,7 +116,7 @@ class ExportFormatter
$page->html = (new PageContent($page))->render();
});
$html = view('exports.chapter', [
$html = view('chapters.export', [
'chapter' => $chapter,
'pages' => $pages,
'format' => 'pdf',
@@ -134,7 +134,7 @@ class ExportFormatter
public function bookToPdf(Book $book)
{
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('exports.book', [
$html = view('books.export', [
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'pdf',
@@ -215,14 +215,16 @@ class ExportFormatter
*/
protected function containHtml(string $htmlContent): string
{
$imageTagsOutput = [];
preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
// Replace embed tags with images
$htmlContent = preg_replace("/<embed (.*?)>/i", '<img $1>', $htmlContent);
// Replace image src with base64 encoded image strings
// Replace image & embed src attributes with base64 encoded data strings
$imageTagsOutput = [];
preg_match_all("/<img .*?src=['\"](.*?)['\"].*?>/i", $htmlContent, $imageTagsOutput);
if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
foreach ($imageTagsOutput[0] as $index => $imgMatch) {
$oldImgTagString = $imgMatch;
$srcString = $imageTagsOutput[2][$index];
$srcString = $imageTagsOutput[1][$index];
$imageEncoded = $this->imageService->imageUriToBase64($srcString);
if ($imageEncoded === null) {
$imageEncoded = $srcString;
@@ -232,14 +234,13 @@ class ExportFormatter
}
}
// Replace any relative links with full system URL
$linksOutput = [];
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
// Update relative links to be absolute, with instance url
preg_match_all("/<a .*href=['\"](.*?)['\"].*?>/i", $htmlContent, $linksOutput);
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
foreach ($linksOutput[0] as $index => $linkMatch) {
$oldLinkString = $linkMatch;
$srcString = $linksOutput[2][$index];
$srcString = $linksOutput[1][$index];
if (strpos(trim($srcString), 'http') !== 0) {
$newSrcString = url($srcString);
$newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);

View File

@@ -1,87 +0,0 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Facades\Activity;
class HierarchyTransformer
{
protected BookRepo $bookRepo;
protected BookshelfRepo $shelfRepo;
protected Cloner $cloner;
protected TrashCan $trashCan;
public function __construct(BookRepo $bookRepo, BookshelfRepo $shelfRepo, Cloner $cloner, TrashCan $trashCan)
{
$this->bookRepo = $bookRepo;
$this->shelfRepo = $shelfRepo;
$this->cloner = $cloner;
$this->trashCan = $trashCan;
}
/**
* Transform a chapter into a book.
* Does not check permissions, check before calling.
*/
public function transformChapterToBook(Chapter $chapter): Book
{
$inputData = $this->cloner->entityToInputData($chapter);
$book = $this->bookRepo->create($inputData);
$this->cloner->copyEntityPermissions($chapter, $book);
/** @var Page $page */
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->changeBook($book->id);
}
$this->trashCan->destroyEntity($chapter);
Activity::add(ActivityType::BOOK_CREATE_FROM_CHAPTER, $book);
return $book;
}
/**
* Transform a book into a shelf.
* Does not check permissions, check before calling.
*/
public function transformBookToShelf(Book $book): Bookshelf
{
$inputData = $this->cloner->entityToInputData($book);
$shelf = $this->shelfRepo->create($inputData, []);
$this->cloner->copyEntityPermissions($book, $shelf);
$shelfBookSyncData = [];
/** @var Chapter $chapter */
foreach ($book->chapters as $index => $chapter) {
$newBook = $this->transformChapterToBook($chapter);
$shelfBookSyncData[$newBook->id] = ['order' => $index];
if (!$newBook->restricted) {
$this->cloner->copyEntityPermissions($shelf, $newBook);
}
}
if ($book->directPages->count() > 0) {
$book->name .= ' ' . trans('entities.pages');
$shelfBookSyncData[$book->id] = ['order' => count($shelfBookSyncData) + 1];
$book->save();
} else {
$this->trashCan->destroyEntity($book);
}
$shelf->books()->sync($shelfBookSyncData);
Activity::add(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $shelf);
return $shelf;
}
}

View File

@@ -5,8 +5,6 @@ namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService;
use BookStack\Util\HtmlContentFilter;
@@ -374,30 +372,23 @@ class PageContent
continue;
}
// Find page to use, and default replacement to empty string for non-matches.
// Find page and skip this if page not found
/** @var ?Page $matchedPage */
$matchedPage = Page::visible()->find($pageId);
$replacement = '';
if ($matchedPage && count($splitInclude) === 1) {
// If we only have page id, just insert all page html and continue.
$replacement = $matchedPage->html;
} elseif ($matchedPage && count($splitInclude) > 1) {
// Otherwise, if our include tag defines a section, load that specific content
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
$replacement = trim($innerContent);
if ($matchedPage === null) {
$html = str_replace($fullMatch, '', $html);
continue;
}
$themeReplacement = Theme::dispatch(
ThemeEvents::PAGE_INCLUDE_PARSE,
$includeId,
$replacement,
clone $this->page,
$matchedPage ? (clone $matchedPage) : null,
);
// If we only have page id, just insert all page html and continue.
if (count($splitInclude) === 1) {
$html = str_replace($fullMatch, $matchedPage->html, $html);
continue;
}
// Perform the content replacement
$html = str_replace($fullMatch, $themeReplacement ?? $replacement, $html);
// Create and load HTML into a document
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
$html = str_replace($fullMatch, trim($innerContent), $html);
}
return $html;

View File

@@ -2,7 +2,7 @@
namespace BookStack\Entities\Tools;
use Barryvdh\DomPDF\Facade\Pdf as DomPDF;
use Barryvdh\DomPDF\Facade as DomPDF;
use Barryvdh\Snappy\Facades\SnappyPdf;
class PdfGenerator

View File

@@ -1,11 +1,12 @@
<?php
namespace BookStack\Search;
namespace BookStack\Entities\Tools;
use BookStack\Actions\Tag;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\SearchTerm;
use DOMDocument;
use DOMNode;
use Illuminate\Database\Eloquent\Builder;
@@ -146,8 +147,6 @@ class SearchIndex
];
$html = '<body>' . $html . '</body>';
$html = str_ireplace(['<br>', '<br />', '<br/>'], "\n", $html);
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));

View File

@@ -1,15 +1,30 @@
<?php
namespace BookStack\Search;
namespace BookStack\Entities\Tools;
use Illuminate\Http\Request;
class SearchOptions
{
public array $searches = [];
public array $exacts = [];
public array $tags = [];
public array $filters = [];
/**
* @var array
*/
public $searches = [];
/**
* @var array
*/
public $exacts = [];
/**
* @var array
*/
public $tags = [];
/**
* @var array
*/
public $filters = [];
/**
* Create a new instance from a search string.

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Search;
namespace BookStack\Entities\Tools;
use BookStack\Actions\Tag;
use BookStack\Entities\Models\Entity;

View File

@@ -1,13 +1,14 @@
<?php
namespace BookStack\Search;
namespace BookStack\Entities\Tools;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\SearchTerm;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@@ -20,15 +21,22 @@ use SplObjectStorage;
class SearchRunner
{
protected EntityProvider $entityProvider;
protected PermissionApplicator $permissions;
/**
* @var EntityProvider
*/
protected $entityProvider;
/**
* @var PermissionService
*/
protected $permissionService;
/**
* Acceptable operators to be used in a query.
*
* @var string[]
* @var array
*/
protected array $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
/**
* Retain a cache of score adjusted terms for specific search options.
@@ -38,10 +46,10 @@ class SearchRunner
*/
protected $termAdjustmentCache;
public function __construct(EntityProvider $entityProvider, PermissionApplicator $permissions)
public function __construct(EntityProvider $entityProvider, PermissionService $permissionService)
{
$this->entityProvider = $entityProvider;
$this->permissions = $permissions;
$this->permissionService = $permissionService;
$this->termAdjustmentCache = new SplObjectStorage();
}
@@ -52,7 +60,7 @@ class SearchRunner
*
* @return array{total: int, count: int, has_more: bool, results: Entity[]}
*/
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
{
$entityTypes = array_keys($this->entityProvider->all());
$entityTypesToSearch = $entityTypes;
@@ -73,7 +81,7 @@ class SearchRunner
}
$entityModelInstance = $this->entityProvider->get($entityType);
$searchQuery = $this->buildQuery($searchOpts, $entityModelInstance);
$searchQuery = $this->buildQuery($searchOpts, $entityModelInstance, $action);
$entityTotal = $searchQuery->count();
$searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityModelInstance, $page, $count);
@@ -157,12 +165,12 @@ class SearchRunner
/**
* Create a search query for an entity.
*/
protected function buildQuery(SearchOptions $searchOpts, Entity $entityModelInstance): EloquentBuilder
protected function buildQuery(SearchOptions $searchOpts, Entity $entityModelInstance, string $action = 'view'): EloquentBuilder
{
$entityQuery = $entityModelInstance->newQuery()->scopes('visible');
$entityQuery = $entityModelInstance->newQuery();
if ($entityModelInstance instanceof Page) {
$entityQuery->select(array_merge($entityModelInstance::$listAttributes, ['restricted', 'owned_by']));
$entityQuery->select($entityModelInstance::$listAttributes);
} else {
$entityQuery->select(['*']);
}
@@ -191,7 +199,7 @@ class SearchRunner
}
}
return $entityQuery;
return $this->permissionService->enforceEntityRestrictions($entityModelInstance, $entityQuery, $action);
}
/**

View File

@@ -20,7 +20,6 @@ class ShelfContext
return null;
}
/** @var Bookshelf $shelf */
$shelf = Bookshelf::visible()->find($contextBookshelfId);
$shelfContainsBook = $shelf && $shelf->contains($book);

View File

@@ -344,7 +344,7 @@ class TrashCan
*
* @throws Exception
*/
public function destroyEntity(Entity $entity): int
protected function destroyEntity(Entity $entity): int
{
if ($entity instanceof Page) {
return $this->destroyPage($entity);
@@ -376,8 +376,6 @@ class TrashCan
$entity->searchTerms()->delete();
$entity->deletions()->delete();
$entity->favourites()->delete();
$entity->referencesTo()->delete();
$entity->referencesFrom()->delete();
if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
$imageService = app()->make(ImageService::class);

View File

@@ -21,7 +21,6 @@ class Handler extends ExceptionHandler
*/
protected $dontReport = [
NotFoundException::class,
StoppedAuthenticationException::class,
];
/**

View File

@@ -5,7 +5,7 @@ namespace BookStack\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @mixin \BookStack\Actions\ActivityLogger
* @see \BookStack\Actions\ActivityLogger
*/
class Activity extends Facade
{

View File

@@ -0,0 +1,18 @@
<?php
namespace BookStack\Facades;
use Illuminate\Support\Facades\Facade;
class Permissions extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'permissions';
}
}

View File

@@ -11,6 +11,19 @@ class BookApiController extends ApiController
{
protected $bookRepo;
protected $rules = [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
],
];
public function __construct(BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
@@ -24,21 +37,19 @@ class BookApiController extends ApiController
$books = Book::visible();
return $this->apiListingResponse($books, [
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', 'image_id',
]);
}
/**
* Create a new book in the system.
* The cover image of a book can be set by sending a file via an 'image' property within a 'multipart/form-data' request.
* If the 'image' property is null then the book cover image will be removed.
*
* @throws ValidationException
*/
public function create(Request $request)
{
$this->checkPermission('book-create-all');
$requestData = $this->validate($request, $this->rules()['create']);
$requestData = $this->validate($request, $this->rules['create']);
$book = $this->bookRepo->create($requestData);
@@ -57,8 +68,6 @@ class BookApiController extends ApiController
/**
* Update the details of a single book.
* The cover image of a book can be set by sending a file via an 'image' property within a 'multipart/form-data' request.
* If the 'image' property is null then the book cover image will be removed.
*
* @throws ValidationException
*/
@@ -67,7 +76,7 @@ class BookApiController extends ApiController
$book = Book::visible()->findOrFail($id);
$this->checkOwnablePermission('book-update', $book);
$requestData = $this->validate($request, $this->rules()['update']);
$requestData = $this->validate($request, $this->rules['update']);
$book = $this->bookRepo->update($book, $requestData);
return response()->json($book);
@@ -88,22 +97,4 @@ class BookApiController extends ApiController
return response('', 204);
}
protected function rules(): array
{
return [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
],
];
}
}

View File

@@ -26,7 +26,7 @@ class BookExportApiController extends ApiController
$book = Book::visible()->findOrFail($id);
$pdfContent = $this->exportFormatter->bookToPdf($book);
return $this->download()->directly($pdfContent, $book->slug . '.pdf');
return $this->downloadResponse($pdfContent, $book->slug . '.pdf');
}
/**
@@ -39,7 +39,7 @@ class BookExportApiController extends ApiController
$book = Book::visible()->findOrFail($id);
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
return $this->download()->directly($htmlContent, $book->slug . '.html');
return $this->downloadResponse($htmlContent, $book->slug . '.html');
}
/**
@@ -50,7 +50,7 @@ class BookExportApiController extends ApiController
$book = Book::visible()->findOrFail($id);
$textContent = $this->exportFormatter->bookToPlainText($book);
return $this->download()->directly($textContent, $book->slug . '.txt');
return $this->downloadResponse($textContent, $book->slug . '.txt');
}
/**
@@ -61,6 +61,6 @@ class BookExportApiController extends ApiController
$book = Book::visible()->findOrFail($id);
$markdown = $this->exportFormatter->bookToMarkdown($book);
return $this->download()->directly($markdown, $book->slug . '.md');
return $this->downloadResponse($markdown, $book->slug . '.md');
}
}

View File

@@ -13,6 +13,21 @@ class BookshelfApiController extends ApiController
{
protected BookshelfRepo $bookshelfRepo;
protected $rules = [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
],
];
/**
* BookshelfApiController constructor.
*/
@@ -29,7 +44,7 @@ class BookshelfApiController extends ApiController
$shelves = Bookshelf::visible();
return $this->apiListingResponse($shelves, [
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', 'image_id',
]);
}
@@ -37,15 +52,13 @@ class BookshelfApiController extends ApiController
* Create a new shelf in the system.
* An array of books IDs can be provided in the request. These
* will be added to the shelf in the same order as provided.
* The cover image of a shelf can be set by sending a file via an 'image' property within a 'multipart/form-data' request.
* If the 'image' property is null then the shelf cover image will be removed.
*
* @throws ValidationException
*/
public function create(Request $request)
{
$this->checkPermission('bookshelf-create-all');
$requestData = $this->validate($request, $this->rules()['create']);
$requestData = $this->validate($request, $this->rules['create']);
$bookIds = $request->get('books', []);
$shelf = $this->bookshelfRepo->create($requestData, $bookIds);
@@ -73,8 +86,6 @@ class BookshelfApiController extends ApiController
* An array of books IDs can be provided in the request. These
* will be added to the shelf in the same order as provided and overwrite
* any existing book assignments.
* The cover image of a shelf can be set by sending a file via an 'image' property within a 'multipart/form-data' request.
* If the 'image' property is null then the shelf cover image will be removed.
*
* @throws ValidationException
*/
@@ -83,7 +94,7 @@ class BookshelfApiController extends ApiController
$shelf = Bookshelf::visible()->findOrFail($id);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$requestData = $this->validate($request, $this->rules()['update']);
$requestData = $this->validate($request, $this->rules['update']);
$bookIds = $request->get('books', null);
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
@@ -106,24 +117,4 @@ class BookshelfApiController extends ApiController
return response('', 204);
}
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()),
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
],
];
}
}

View File

@@ -29,7 +29,7 @@ class ChapterExportApiController extends ApiController
$chapter = Chapter::visible()->findOrFail($id);
$pdfContent = $this->exportFormatter->chapterToPdf($chapter);
return $this->download()->directly($pdfContent, $chapter->slug . '.pdf');
return $this->downloadResponse($pdfContent, $chapter->slug . '.pdf');
}
/**
@@ -42,7 +42,7 @@ class ChapterExportApiController extends ApiController
$chapter = Chapter::visible()->findOrFail($id);
$htmlContent = $this->exportFormatter->chapterToContainedHtml($chapter);
return $this->download()->directly($htmlContent, $chapter->slug . '.html');
return $this->downloadResponse($htmlContent, $chapter->slug . '.html');
}
/**
@@ -53,7 +53,7 @@ class ChapterExportApiController extends ApiController
$chapter = Chapter::visible()->findOrFail($id);
$textContent = $this->exportFormatter->chapterToPlainText($chapter);
return $this->download()->directly($textContent, $chapter->slug . '.txt');
return $this->downloadResponse($textContent, $chapter->slug . '.txt');
}
/**
@@ -64,6 +64,6 @@ class ChapterExportApiController extends ApiController
$chapter = Chapter::visible()->findOrFail($id);
$markdown = $this->exportFormatter->chapterToMarkdown($chapter);
return $this->download()->directly($markdown, $chapter->slug . '.md');
return $this->downloadResponse($markdown, $chapter->slug . '.md');
}
}

View File

@@ -86,9 +86,6 @@ class PageApiController extends ApiController
*
* Pages will always have HTML content. They may have markdown content
* if the markdown editor was used to last update the page.
*
* See the "Content Security" section of these docs for security considerations when using
* the page content returned from this endpoint.
*/
public function read(string $id)
{

View File

@@ -26,7 +26,7 @@ class PageExportApiController extends ApiController
$page = Page::visible()->findOrFail($id);
$pdfContent = $this->exportFormatter->pageToPdf($page);
return $this->download()->directly($pdfContent, $page->slug . '.pdf');
return $this->downloadResponse($pdfContent, $page->slug . '.pdf');
}
/**
@@ -39,7 +39,7 @@ class PageExportApiController extends ApiController
$page = Page::visible()->findOrFail($id);
$htmlContent = $this->exportFormatter->pageToContainedHtml($page);
return $this->download()->directly($htmlContent, $page->slug . '.html');
return $this->downloadResponse($htmlContent, $page->slug . '.html');
}
/**
@@ -50,7 +50,7 @@ class PageExportApiController extends ApiController
$page = Page::visible()->findOrFail($id);
$textContent = $this->exportFormatter->pageToPlainText($page);
return $this->download()->directly($textContent, $page->slug . '.txt');
return $this->downloadResponse($textContent, $page->slug . '.txt');
}
/**
@@ -61,6 +61,6 @@ class PageExportApiController extends ApiController
$page = Page::visible()->findOrFail($id);
$markdown = $this->exportFormatter->pageToMarkdown($page);
return $this->download()->directly($markdown, $page->slug . '.md');
return $this->downloadResponse($markdown, $page->slug . '.md');
}
}

View File

@@ -3,9 +3,9 @@
namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Models\Entity;
use BookStack\Search\SearchOptions;
use BookStack\Search\SearchResultsFormatter;
use BookStack\Search\SearchRunner;
use BookStack\Entities\Tools\SearchOptions;
use BookStack\Entities\Tools\SearchResultsFormatter;
use BookStack\Entities\Tools\SearchRunner;
use Illuminate\Http\Request;
class SearchApiController extends ApiController

View File

@@ -36,26 +36,26 @@ class UserApiController extends ApiController
{
return [
'create' => [
'name' => ['required', 'min:2', 'max:100'],
'name' => ['required', 'min:2'],
'email' => [
'required', 'min:2', 'email', new Unique('users', 'email'),
],
'external_auth_id' => ['string'],
'language' => ['string', 'max:15', 'alpha_dash'],
'language' => ['string'],
'password' => [Password::default()],
'roles' => ['array'],
'roles.*' => ['integer'],
'send_invite' => ['boolean'],
],
'update' => [
'name' => ['min:2', 'max:100'],
'name' => ['min:2'],
'email' => [
'min:2',
'email',
(new Unique('users', 'email'))->ignore($userId ?? null),
],
'external_auth_id' => ['string'],
'language' => ['string', 'max:15', 'alpha_dash'],
'language' => ['string'],
'password' => [Password::default()],
'roles' => ['array'],
'roles.*' => ['integer'],

View File

@@ -233,10 +233,10 @@ class AttachmentController extends Controller
$attachmentStream = $this->attachmentService->streamAttachmentFromStorage($attachment);
if ($request->get('open') === 'true') {
return $this->download()->streamedInline($attachmentStream, $fileName);
return $this->streamedInlineDownloadResponse($attachmentStream, $fileName);
}
return $this->download()->streamedDirectly($attachmentStream, $fileName);
return $this->streamedDownloadResponse($attachmentStream, $fileName);
}
/**

View File

@@ -25,16 +25,17 @@ class LoginController extends Controller
|
*/
use AuthenticatesUsers { logout as traitLogout; }
use AuthenticatesUsers;
/**
* Redirection paths.
*/
protected $redirectTo = '/';
protected $redirectPath = '/';
protected $redirectAfterLogout = '/login';
protected SocialAuthService $socialAuthService;
protected LoginService $loginService;
protected $socialAuthService;
protected $loginService;
/**
* Create a new controller instance.
@@ -49,6 +50,7 @@ class LoginController extends Controller
$this->loginService = $loginService;
$this->redirectPath = url('/');
$this->redirectAfterLogout = url('/login');
}
public function username()
@@ -71,7 +73,6 @@ class LoginController extends Controller
{
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$authMethod = config('auth.method');
$preventInitiation = $request->get('prevent_auto_init') === 'true';
if ($request->has('email')) {
session()->flashInput([
@@ -83,12 +84,6 @@ class LoginController extends Controller
// Store the previous location for redirect after login
$this->updateIntendedFromPrevious();
if (!$preventInitiation && $this->shouldAutoInitiate()) {
return view('auth.login-initiate', [
'authMethod' => $authMethod,
]);
}
return view('auth.login', [
'socialDrivers' => $socialDrivers,
'authMethod' => $authMethod,
@@ -256,32 +251,4 @@ class LoginController extends Controller
redirect()->setIntendedUrl($previous);
}
/**
* Check if login auto-initiate should be valid based upon authentication config.
*/
protected function shouldAutoInitiate(): bool
{
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$authMethod = config('auth.method');
$autoRedirect = config('auth.auto_initiate');
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
}
/**
* Logout user and perform subsequent redirect.
*
* @param \Illuminate\Http\Request $request
*
* @return mixed
*/
public function logout(Request $request)
{
$this->traitLogout($request);
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
return redirect($redirectUri);
}
}

View File

@@ -30,9 +30,9 @@ class RegisterController extends Controller
use RegistersUsers;
protected SocialAuthService $socialAuthService;
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected $socialAuthService;
protected $registrationService;
protected $loginService;
/**
* Where to redirect users after login / registration.
@@ -69,7 +69,7 @@ class RegisterController extends Controller
protected function validator(array $data)
{
return Validator::make($data, [
'name' => ['required', 'min:2', 'max:100'],
'name' => ['required', 'min:2', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => ['required', Password::default()],
]);

View File

@@ -9,28 +9,24 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
use BookStack\Entities\Tools\HierarchyTransformer;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use BookStack\References\ReferenceFetcher;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
class BookController extends Controller
{
protected BookRepo $bookRepo;
protected ShelfContext $shelfContext;
protected ReferenceFetcher $referenceFetcher;
protected $bookRepo;
protected $entityContextManager;
public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo, ReferenceFetcher $referenceFetcher)
public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
$this->shelfContext = $entityContextManager;
$this->referenceFetcher = $referenceFetcher;
$this->entityContextManager = $entityContextManager;
}
/**
@@ -47,7 +43,7 @@ class BookController extends Controller
$popular = $this->bookRepo->getPopular(4);
$new = $this->bookRepo->getRecentlyCreated(4);
$this->shelfContext->clearShelfContext();
$this->entityContextManager->clearShelfContext();
$this->setPageTitle(trans('entities.books'));
@@ -91,11 +87,10 @@ class BookController extends Controller
public function store(Request $request, string $shelfSlug = null)
{
$this->checkPermission('book-create-all');
$validated = $this->validate($request, [
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
]);
$bookshelf = null;
@@ -104,7 +99,8 @@ class BookController extends Controller
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
}
$book = $this->bookRepo->create($validated);
$book = $this->bookRepo->create($request->all());
$this->bookRepo->updateCoverImage($book, $request->file('image', null));
if ($bookshelf) {
$bookshelf->appendBook($book);
@@ -125,7 +121,7 @@ class BookController extends Controller
View::incrementFor($book);
if ($request->has('shelf')) {
$this->shelfContext->setShelfContext(intval($request->get('shelf')));
$this->entityContextManager->setShelfContext(intval($request->get('shelf')));
}
$this->setPageTitle($book->getShortName());
@@ -136,7 +132,6 @@ class BookController extends Controller
'bookChildren' => $bookChildren,
'bookParentShelves' => $bookParentShelves,
'activity' => $activities->entityActivity($book, 20, 1),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book),
]);
}
@@ -163,21 +158,15 @@ class BookController extends Controller
{
$book = $this->bookRepo->getBySlug($slug);
$this->checkOwnablePermission('book-update', $book);
$validated = $this->validate($request, [
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
]);
if ($request->has('image_reset')) {
$validated['image'] = null;
} elseif (array_key_exists('image', $validated) && is_null($validated['image'])) {
unset($validated['image']);
}
$book = $this->bookRepo->update($book, $validated);
$book = $this->bookRepo->update($book, $request->all());
$resetCover = $request->has('image_reset');
$this->bookRepo->updateCoverImage($book, $request->file('image', null), $resetCover);
return redirect($book->getUrl());
}
@@ -273,20 +262,4 @@ class BookController extends Controller
return redirect($bookCopy->getUrl());
}
/**
* Convert the chapter to a book.
*/
public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$this->checkOwnablePermission('book-delete', $book);
$this->checkPermission('bookshelf-create-all');
$this->checkPermission('book-create-all');
$shelf = $transformer->transformBookToShelf($book);
return redirect($shelf->getUrl());
}
}

View File

@@ -31,7 +31,7 @@ class BookExportController extends Controller
$book = $this->bookRepo->getBySlug($bookSlug);
$pdfContent = $this->exportFormatter->bookToPdf($book);
return $this->download()->directly($pdfContent, $bookSlug . '.pdf');
return $this->downloadResponse($pdfContent, $bookSlug . '.pdf');
}
/**
@@ -44,7 +44,7 @@ class BookExportController extends Controller
$book = $this->bookRepo->getBySlug($bookSlug);
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
return $this->download()->directly($htmlContent, $bookSlug . '.html');
return $this->downloadResponse($htmlContent, $bookSlug . '.html');
}
/**
@@ -55,7 +55,7 @@ class BookExportController extends Controller
$book = $this->bookRepo->getBySlug($bookSlug);
$textContent = $this->exportFormatter->bookToPlainText($book);
return $this->download()->directly($textContent, $bookSlug . '.txt');
return $this->downloadResponse($textContent, $bookSlug . '.txt');
}
/**
@@ -66,6 +66,6 @@ class BookExportController extends Controller
$book = $this->bookRepo->getBySlug($bookSlug);
$textContent = $this->exportFormatter->bookToMarkdown($book);
return $this->download()->directly($textContent, $bookSlug . '.md');
return $this->downloadResponse($textContent, $bookSlug . '.md');
}
}

View File

@@ -10,22 +10,22 @@ use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\References\ReferenceFetcher;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class BookshelfController extends Controller
{
protected BookshelfRepo $shelfRepo;
protected ShelfContext $shelfContext;
protected ReferenceFetcher $referenceFetcher;
protected $bookshelfRepo;
protected $entityContextManager;
protected $imageRepo;
public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext, ReferenceFetcher $referenceFetcher)
public function __construct(BookshelfRepo $bookshelfRepo, ShelfContext $entityContextManager, ImageRepo $imageRepo)
{
$this->shelfRepo = $shelfRepo;
$this->shelfContext = $shelfContext;
$this->referenceFetcher = $referenceFetcher;
$this->bookshelfRepo = $bookshelfRepo;
$this->entityContextManager = $entityContextManager;
$this->imageRepo = $imageRepo;
}
/**
@@ -42,12 +42,12 @@ class BookshelfController extends Controller
'updated_at' => trans('common.sort_updated_at'),
];
$shelves = $this->shelfRepo->getAllPaginated(18, $sort, $order);
$recents = $this->isSignedIn() ? $this->shelfRepo->getRecentlyViewed(4) : false;
$popular = $this->shelfRepo->getPopular(4);
$new = $this->shelfRepo->getRecentlyCreated(4);
$shelves = $this->bookshelfRepo->getAllPaginated(18, $sort, $order);
$recents = $this->isSignedIn() ? $this->bookshelfRepo->getRecentlyViewed(4) : false;
$popular = $this->bookshelfRepo->getPopular(4);
$new = $this->bookshelfRepo->getRecentlyCreated(4);
$this->shelfContext->clearShelfContext();
$this->entityContextManager->clearShelfContext();
$this->setPageTitle(trans('entities.shelves'));
return view('shelves.index', [
@@ -68,7 +68,7 @@ class BookshelfController extends Controller
public function create()
{
$this->checkPermission('bookshelf-create-all');
$books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug']);
$books = Book::hasPermission('update')->get();
$this->setPageTitle(trans('entities.shelves_create'));
return view('shelves.create', ['books' => $books]);
@@ -83,15 +83,15 @@ class BookshelfController extends Controller
public function store(Request $request)
{
$this->checkPermission('bookshelf-create-all');
$validated = $this->validate($request, [
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
]);
$bookIds = explode(',', $request->get('books', ''));
$shelf = $this->shelfRepo->create($validated, $bookIds);
$shelf = $this->bookshelfRepo->create($request->all(), $bookIds);
$this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null));
return redirect($shelf->getUrl());
}
@@ -103,8 +103,8 @@ class BookshelfController extends Controller
*/
public function show(ActivityQueries $activities, string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-view', $shelf);
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('book-view', $shelf);
$sort = setting()->getForCurrentUser('shelf_books_sort', 'default');
$order = setting()->getForCurrentUser('shelf_books_sort_order', 'asc');
@@ -115,7 +115,7 @@ class BookshelfController extends Controller
->all();
View::incrementFor($shelf);
$this->shelfContext->setShelfContext($shelf->id);
$this->entityContextManager->setShelfContext($shelf->id);
$view = setting()->getForCurrentUser('bookshelf_view_type');
$this->setPageTitle($shelf->getShortName());
@@ -127,7 +127,6 @@ class BookshelfController extends Controller
'activity' => $activities->entityActivity($shelf, 20, 1),
'order' => $order,
'sort' => $sort,
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
]);
}
@@ -136,11 +135,11 @@ class BookshelfController extends Controller
*/
public function edit(string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->bookshelfRepo->getBySlug($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']);
$books = Book::hasPermission('update')->whereNotIn('id', $shelfBookIds)->get();
$this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));
@@ -159,23 +158,18 @@ class BookshelfController extends Controller
*/
public function update(Request $request, string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$validated = $this->validate($request, [
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
]);
if ($request->has('image_reset')) {
$validated['image'] = null;
} elseif (array_key_exists('image', $validated) && is_null($validated['image'])) {
unset($validated['image']);
}
$bookIds = explode(',', $request->get('books', ''));
$shelf = $this->shelfRepo->update($shelf, $validated, $bookIds);
$shelf = $this->bookshelfRepo->update($shelf, $request->all(), $bookIds);
$resetCover = $request->has('image_reset');
$this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null), $resetCover);
return redirect($shelf->getUrl());
}
@@ -185,7 +179,7 @@ class BookshelfController extends Controller
*/
public function showDelete(string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
@@ -200,10 +194,10 @@ class BookshelfController extends Controller
*/
public function destroy(string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->shelfRepo->destroy($shelf);
$this->bookshelfRepo->destroy($shelf);
return redirect('/shelves');
}
@@ -213,7 +207,7 @@ class BookshelfController extends Controller
*/
public function showPermissions(string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('restrictions-manage', $shelf);
return view('shelves.permissions', [
@@ -226,7 +220,7 @@ class BookshelfController extends Controller
*/
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('restrictions-manage', $shelf);
$permissionsUpdater->updateFromPermissionsForm($shelf, $request);
@@ -241,10 +235,10 @@ class BookshelfController extends Controller
*/
public function copyPermissions(string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('restrictions-manage', $shelf);
$updateCount = $this->shelfRepo->copyDownPermissions($shelf);
$updateCount = $this->bookshelfRepo->copyDownPermissions($shelf);
$this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
return redirect($shelf->getUrl());

View File

@@ -7,26 +7,25 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
use BookStack\Entities\Tools\HierarchyTransformer;
use BookStack\Entities\Tools\NextPreviousContentLocator;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use BookStack\References\ReferenceFetcher;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
class ChapterController extends Controller
{
protected ChapterRepo $chapterRepo;
protected ReferenceFetcher $referenceFetcher;
protected $chapterRepo;
public function __construct(ChapterRepo $chapterRepo, ReferenceFetcher $referenceFetcher)
/**
* ChapterController constructor.
*/
public function __construct(ChapterRepo $chapterRepo)
{
$this->chapterRepo = $chapterRepo;
$this->referenceFetcher = $referenceFetcher;
}
/**
@@ -77,14 +76,13 @@ class ChapterController extends Controller
$this->setPageTitle($chapter->getShortName());
return view('chapters.show', [
'book' => $chapter->book,
'chapter' => $chapter,
'current' => $chapter,
'sidebarTree' => $sidebarTree,
'pages' => $pages,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($chapter),
'book' => $chapter->book,
'chapter' => $chapter,
'current' => $chapter,
'sidebarTree' => $sidebarTree,
'pages' => $pages,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
]);
}
@@ -274,19 +272,4 @@ class ChapterController extends Controller
return redirect($chapter->getUrl());
}
/**
* Convert the chapter to a book.
*/
public function convertToBook(HierarchyTransformer $transformer, string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkPermission('book-create-all');
$book = $transformer->transformChapterToBook($chapter);
return redirect($book->getUrl());
}
}

View File

@@ -33,7 +33,7 @@ class ChapterExportController extends Controller
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$pdfContent = $this->exportFormatter->chapterToPdf($chapter);
return $this->download()->directly($pdfContent, $chapterSlug . '.pdf');
return $this->downloadResponse($pdfContent, $chapterSlug . '.pdf');
}
/**
@@ -47,7 +47,7 @@ class ChapterExportController extends Controller
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$containedHtml = $this->exportFormatter->chapterToContainedHtml($chapter);
return $this->download()->directly($containedHtml, $chapterSlug . '.html');
return $this->downloadResponse($containedHtml, $chapterSlug . '.html');
}
/**
@@ -60,7 +60,7 @@ class ChapterExportController extends Controller
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapterText = $this->exportFormatter->chapterToPlainText($chapter);
return $this->download()->directly($chapterText, $chapterSlug . '.txt');
return $this->downloadResponse($chapterText, $chapterSlug . '.txt');
}
/**
@@ -70,9 +70,10 @@ class ChapterExportController extends Controller
*/
public function markdown(string $bookSlug, string $chapterSlug)
{
// TODO: This should probably export to a zip file.
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapterText = $this->exportFormatter->chapterToMarkdown($chapter);
return $this->download()->directly($chapterText, $chapterSlug . '.md');
return $this->downloadResponse($chapterText, $chapterSlug . '.md');
}
}

View File

@@ -4,13 +4,15 @@ namespace BookStack\Http\Controllers;
use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity;
use BookStack\Http\Responses\DownloadResponseFactory;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use BookStack\Util\WebSafeMimeSniffer;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller as BaseController;
use Symfony\Component\HttpFoundation\StreamedResponse;
abstract class Controller extends BaseController
{
@@ -108,11 +110,74 @@ abstract class Controller extends BaseController
}
/**
* Create and return a new download response factory using the current request.
* Create a response that forces a download in the browser.
*/
protected function download(): DownloadResponseFactory
protected function downloadResponse(string $content, string $fileName): Response
{
return new DownloadResponseFactory(request());
return response()->make($content, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
/**
* Create a response that forces a download, from a given stream of content.
*/
protected function streamedDownloadResponse($stream, string $fileName): 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, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
/**
* Create a file download response that provides the file with a content-type
* correct for the file, in a way so the browser can show the content in browser.
*/
protected function inlineDownloadResponse(string $content, string $fileName): Response
{
$mime = (new WebSafeMimeSniffer())->sniff($content);
return response()->make($content, 200, [
'Content-Type' => $mime,
'Content-Disposition' => 'inline; filename="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
/**
* Create a file download response that provides the file with a content-type
* correct for the file, in a way so the browser can show the content in browser,
* for a given content stream.
*/
protected function streamedInlineDownloadResponse($stream, string $fileName): StreamedResponse
{
$sniffContent = fread($stream, 1000);
$mime = (new WebSafeMimeSniffer())->sniff($sniffContent);
return response()->stream(function () use ($sniffContent, $stream) {
echo $sniffContent;
fpassthru($stream);
fclose($stream);
}, 200, [
'Content-Type' => $mime,
'Content-Disposition' => 'inline; filename="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
/**
@@ -154,6 +219,6 @@ abstract class Controller extends BaseController
*/
protected function getImageValidationRules(): array
{
return ['image_extension', 'mimes:jpeg,png,gif,webp', 'max:' . (config('app.upload_limit') * 1000)];
return ['image_extension', 'mimes:jpeg,png,gif,webp,svg', 'max:' . (config('app.upload_limit') * 1000)];
}
}

View File

@@ -87,7 +87,7 @@ class FavouriteController extends Controller
$modelInstance = $model->newQuery()
->where('id', '=', $modelInfo['id'])
->first(['id', 'name', 'restricted', 'owned_by']);
->first(['id', 'name']);
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
if (is_null($modelInstance) || $inaccessibleEntity) {

View File

@@ -76,8 +76,11 @@ class DrawioImageController extends Controller
return $this->jsonError('Image data could not be found');
}
$isSvg = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'svg';
$uriPrefix = $isSvg ? 'data:image/svg+xml;base64,' : 'data:image/png;base64,';
return response()->json([
'content' => base64_encode($imageData),
'content' => $uriPrefix . base64_encode($imageData),
]);
}
}

View File

@@ -14,9 +14,12 @@ use Illuminate\Validation\ValidationException;
class ImageController extends Controller
{
protected ImageRepo $imageRepo;
protected ImageService $imageService;
protected $imageRepo;
protected $imageService;
/**
* ImageController constructor.
*/
public function __construct(ImageRepo $imageRepo, ImageService $imageService)
{
$this->imageRepo = $imageRepo;
@@ -30,7 +33,7 @@ class ImageController extends Controller
*/
public function showImage(string $path)
{
if (!$this->imageService->pathAccessibleInLocalSecure($path)) {
if (!$this->imageService->pathExistsInLocalSecure($path)) {
throw (new NotFoundException(trans('errors.image_not_found')))
->setSubtitle(trans('errors.image_not_found_subtitle'))
->setDetails(trans('errors.image_not_found_details'));

View File

@@ -5,7 +5,6 @@ namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Notifications\TestEmail;
use BookStack\References\ReferenceStore;
use BookStack\Uploads\ImageService;
use Illuminate\Http\Request;
@@ -75,24 +74,6 @@ class MaintenanceController extends Controller
$this->showErrorNotification($errorMessage);
}
return redirect('/settings/maintenance#image-cleanup');
}
/**
* Action to regenerate the reference index in the system.
*/
public function regenerateReferences(ReferenceStore $referenceStore)
{
$this->checkPermission('settings-manage');
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'regenerate-references');
try {
$referenceStore->updateForAllPages();
$this->showSuccessNotification(trans('settings.maint_regen_references_success'));
} catch (\Exception $exception) {
$this->showErrorNotification($exception->getMessage());
}
return redirect('/settings/maintenance#regenerate-references');
return redirect('/settings/maintenance#image-cleanup')->withInput();
}
}

View File

@@ -14,7 +14,6 @@ use BookStack\Entities\Tools\PageEditorData;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use BookStack\References\ReferenceFetcher;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Http\Request;
@@ -24,15 +23,13 @@ use Throwable;
class PageController extends Controller
{
protected PageRepo $pageRepo;
protected ReferenceFetcher $referenceFetcher;
/**
* PageController constructor.
*/
public function __construct(PageRepo $pageRepo, ReferenceFetcher $referenceFetcher)
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
$this->referenceFetcher = $referenceFetcher;
}
/**
@@ -163,7 +160,6 @@ class PageController extends Controller
'pageNav' => $pageNav,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($page),
]);
}

View File

@@ -36,7 +36,7 @@ class PageExportController extends Controller
$page->html = (new PageContent($page))->render();
$pdfContent = $this->exportFormatter->pageToPdf($page);
return $this->download()->directly($pdfContent, $pageSlug . '.pdf');
return $this->downloadResponse($pdfContent, $pageSlug . '.pdf');
}
/**
@@ -51,7 +51,7 @@ class PageExportController extends Controller
$page->html = (new PageContent($page))->render();
$containedHtml = $this->exportFormatter->pageToContainedHtml($page);
return $this->download()->directly($containedHtml, $pageSlug . '.html');
return $this->downloadResponse($containedHtml, $pageSlug . '.html');
}
/**
@@ -64,7 +64,7 @@ class PageExportController extends Controller
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$pageText = $this->exportFormatter->pageToPlainText($page);
return $this->download()->directly($pageText, $pageSlug . '.txt');
return $this->downloadResponse($pageText, $pageSlug . '.txt');
}
/**
@@ -77,6 +77,6 @@ class PageExportController extends Controller
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$pageText = $this->exportFormatter->pageToMarkdown($page);
return $this->download()->directly($pageText, $pageSlug . '.md');
return $this->downloadResponse($pageText, $pageSlug . '.md');
}
}

View File

@@ -2,17 +2,18 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\PageContent;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use Ssddanbrown\HtmlDiff\Diff;
class PageRevisionController extends Controller
{
protected PageRepo $pageRepo;
protected $pageRepo;
/**
* PageRevisionController constructor.
*/
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
@@ -26,19 +27,11 @@ class PageRevisionController extends Controller
public function index(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$revisions = $page->revisions()->select([
'id', 'page_id', 'name', 'created_at', 'created_by', 'updated_at',
'type', 'revision_number', 'summary',
])
->selectRaw("IF(markdown = '', false, true) as is_markdown")
->with(['page.book', 'createdBy'])
->get();
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName' => $page->getShortName()]));
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName'=>$page->getShortName()]));
return view('pages.revisions', [
'revisions' => $revisions,
'page' => $page,
'page' => $page,
'current' => $page,
]);
}
@@ -139,7 +132,6 @@ class PageRevisionController extends Controller
}
$revision->delete();
Activity::add(ActivityType::REVISION_DELETE, $revision);
$this->showSuccessNotification(trans('entities.revision_delete_success'));
return redirect($page->getUrl('/revisions'));

View File

@@ -1,77 +0,0 @@
<?php
namespace BookStack\Http\Controllers;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\References\ReferenceFetcher;
class ReferenceController extends Controller
{
protected ReferenceFetcher $referenceFetcher;
public function __construct(ReferenceFetcher $referenceFetcher)
{
$this->referenceFetcher = $referenceFetcher;
}
/**
* Display the references to a given page.
*/
public function page(string $bookSlug, string $pageSlug)
{
/** @var Page $page */
$page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail();
$references = $this->referenceFetcher->getPageReferencesToEntity($page);
return view('pages.references', [
'page' => $page,
'references' => $references,
]);
}
/**
* Display the references to a given chapter.
*/
public function chapter(string $bookSlug, string $chapterSlug)
{
/** @var Chapter $chapter */
$chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
$references = $this->referenceFetcher->getPageReferencesToEntity($chapter);
return view('chapters.references', [
'chapter' => $chapter,
'references' => $references,
]);
}
/**
* Display the references to a given book.
*/
public function book(string $slug)
{
$book = Book::visible()->where('slug', '=', $slug)->firstOrFail();
$references = $this->referenceFetcher->getPageReferencesToEntity($book);
return view('books.references', [
'book' => $book,
'references' => $references,
]);
}
/**
* Display the references to a given shelf.
*/
public function shelf(string $slug)
{
$shelf = Bookshelf::visible()->where('slug', '=', $slug)->firstOrFail();
$references = $this->referenceFetcher->getPageReferencesToEntity($shelf);
return view('shelves.references', [
'shelf' => $shelf,
'references' => $references,
]);
}
}

View File

@@ -3,15 +3,16 @@
namespace BookStack\Http\Controllers;
use BookStack\Entities\Queries\Popular;
use BookStack\Entities\Tools\SearchOptions;
use BookStack\Entities\Tools\SearchResultsFormatter;
use BookStack\Entities\Tools\SearchRunner;
use BookStack\Entities\Tools\SiblingFetcher;
use BookStack\Search\SearchOptions;
use BookStack\Search\SearchResultsFormatter;
use BookStack\Search\SearchRunner;
use Illuminate\Http\Request;
class SearchController extends Controller
{
protected $searchRunner;
protected $entityContextManager;
public function __construct(SearchRunner $searchRunner)
{
@@ -78,12 +79,12 @@ class SearchController extends Controller
// Search for entities otherwise show most popular
if ($searchTerm !== false) {
$searchTerm .= ' {type:' . implode('|', $entityTypes) . '}';
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20)['results'];
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
} else {
$entities = (new Popular())->run(20, 0, $entityTypes);
$entities = (new Popular())->run(20, 0, $entityTypes, $permission);
}
return view('search.parts.entity-ajax-list', ['entities' => $entities, 'permission' => $permission]);
return view('search.parts.entity-ajax-list', ['entities' => $entities]);
}
/**

View File

@@ -18,8 +18,8 @@ use Illuminate\Validation\ValidationException;
class UserController extends Controller
{
protected UserRepo $userRepo;
protected ImageRepo $imageRepo;
protected $userRepo;
protected $imageRepo;
/**
* UserController constructor.
@@ -81,9 +81,9 @@ class UserController extends Controller
$passwordRequired = ($authMethod === 'standard' && !$sendInvite);
$validationRules = [
'name' => ['required', 'max:100'],
'name' => ['required'],
'email' => ['required', 'email', 'unique:users,email'],
'language' => ['string', 'max:15', 'alpha_dash'],
'language' => ['string'],
'roles' => ['array'],
'roles.*' => ['integer'],
'password' => $passwordRequired ? ['required', Password::default()] : null,
@@ -139,11 +139,11 @@ class UserController extends Controller
$this->checkPermissionOrCurrentUser('users-manage', $id);
$validated = $this->validate($request, [
'name' => ['min:2', 'max:100'],
'name' => ['min:2'],
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
'password' => ['required_with:password_confirm', Password::default()],
'password-confirm' => ['same:password', 'required_with:password'],
'language' => ['string', 'max:15', 'alpha_dash'],
'language' => ['string'],
'roles' => ['array'],
'roles.*' => ['integer'],
'external_auth_id' => ['string'],
@@ -289,27 +289,6 @@ class UserController extends Controller
return response('', 204);
}
public function updateCodeLanguageFavourite(Request $request)
{
$validated = $this->validate($request, [
'language' => ['required', 'string', 'max:20'],
'active' => ['required', 'bool'],
]);
$currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', '');
$currentFavorites = array_filter(explode(',', $currentFavoritesStr));
$isFav = in_array($validated['language'], $currentFavorites);
if (!$isFav && $validated['active']) {
$currentFavorites[] = $validated['language'];
} elseif ($isFav && !$validated['active']) {
$index = array_search($validated['language'], $currentFavorites);
array_splice($currentFavorites, $index, 1);
}
setting()->putUser(user(), 'code-language-favourites', implode(',', $currentFavorites));
}
/**
* Changed the stored preference for a list sort order.
*/

View File

@@ -2,18 +2,59 @@
namespace BookStack\Http\Middleware;
use BookStack\Util\LanguageManager;
use Carbon\Carbon;
use Closure;
use Illuminate\Http\Request;
class Localization
{
protected LanguageManager $languageManager;
/**
* Array of right-to-left locales.
*/
protected $rtlLocales = ['ar', 'fa', 'he'];
public function __construct(LanguageManager $languageManager)
{
$this->languageManager = $languageManager;
}
/**
* Map of BookStack locale names to best-estimate system locale names.
* Locales can often be found by running `locale -a` on a linux system.
*/
protected $localeMap = [
'ar' => 'ar',
'bg' => 'bg_BG',
'bs' => 'bs_BA',
'ca' => 'ca',
'da' => 'da_DK',
'de' => 'de_DE',
'de_informal' => 'de_DE',
'en' => 'en_GB',
'es' => 'es_ES',
'es_AR' => 'es_AR',
'et' => 'et_EE',
'eu' => 'eu_ES',
'fa' => 'fa_IR',
'fr' => 'fr_FR',
'he' => 'he_IL',
'hr' => 'hr_HR',
'id' => 'id_ID',
'it' => 'it_IT',
'ja' => 'ja',
'ko' => 'ko_KR',
'lt' => 'lt_LT',
'lv' => 'lv_LV',
'nl' => 'nl_NL',
'nb' => 'nb_NO',
'pl' => 'pl_PL',
'pt' => 'pt_PT',
'pt_BR' => 'pt_BR',
'ru' => 'ru',
'sk' => 'sk_SK',
'sl' => 'sl_SI',
'sv' => 'sv_SE',
'uk' => 'uk_UA',
'vi' => 'vi_VN',
'zh_CN' => 'zh_CN',
'zh_TW' => 'zh_TW',
'tr' => 'tr_TR',
];
/**
* Handle an incoming request.
@@ -25,23 +66,76 @@ class Localization
*/
public function handle($request, Closure $next)
{
// Get and record the default language in the config
$defaultLang = config('app.locale');
config()->set('app.default_locale', $defaultLang);
// Get the user's language and record that in the config for use in views
$userLang = $this->languageManager->getUserLanguage($request, $defaultLang);
config()->set('app.lang', str_replace('_', '-', $this->languageManager->getIsoName($userLang)));
$locale = $this->getUserLocale($request, $defaultLang);
config()->set('app.lang', str_replace('_', '-', $this->getLocaleIso($locale)));
// Set text direction
if ($this->languageManager->isRTL($userLang)) {
if (in_array($locale, $this->rtlLocales)) {
config()->set('app.rtl', true);
}
app()->setLocale($userLang);
Carbon::setLocale($userLang);
$this->languageManager->setPhpDateTimeLocale($userLang);
app()->setLocale($locale);
Carbon::setLocale($locale);
$this->setSystemDateLocale($locale);
return $next($request);
}
/**
* Get the locale specifically for the currently logged in user if available.
*/
protected function getUserLocale(Request $request, string $default): string
{
try {
$user = user();
} catch (\Exception $exception) {
return $default;
}
if ($user->isDefault() && config('app.auto_detect_locale')) {
return $this->autoDetectLocale($request, $default);
}
return setting()->getUser($user, 'language', $default);
}
/**
* Autodetect the visitors locale by matching locales in their headers
* against the locales supported by BookStack.
*/
protected function autoDetectLocale(Request $request, string $default): string
{
$availableLocales = config('app.locales');
foreach ($request->getLanguages() as $lang) {
if (in_array($lang, $availableLocales)) {
return $lang;
}
}
return $default;
}
/**
* Get the ISO version of a BookStack language name.
*/
public function getLocaleIso(string $locale): string
{
return $this->localeMap[$locale] ?? $locale;
}
/**
* Set the system date locale for localized date formatting.
* Will try both the standard locale name and the UTF8 variant.
*/
protected function setSystemDateLocale(string $locale)
{
$systemLocale = $this->getLocaleIso($locale);
$set = setlocale(LC_TIME, $systemLocale);
if ($set === false) {
setlocale(LC_TIME, $systemLocale . '.utf8');
}
}
}

View File

@@ -1,77 +0,0 @@
<?php
namespace BookStack\Http\Responses;
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;
}
/**
* Create a response that directly forces a download in the browser.
*/
public function directly(string $content, string $fileName): Response
{
return response()->make($content, 200, $this->getHeaders($fileName));
}
/**
* Create a response that forces a download, from a given stream of content.
*/
public function streamedDirectly($stream, string $fileName): 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));
}
/**
* Create a file download response that provides the file with a content-type
* 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
{
$sniffContent = fread($stream, 2000);
$mime = (new WebSafeMimeSniffer())->sniff($sniffContent);
return response()->stream(function () use ($sniffContent, $stream) {
echo $sniffContent;
fpassthru($stream);
fclose($stream);
}, 200, $this->getHeaders($fileName, $mime));
}
/**
* Get the common headers to provide for a download response.
*/
protected function getHeaders(string $fileName, string $mime = 'application/octet-stream'): array
{
$disposition = ($mime === 'application/octet-stream') ? 'attachment' : 'inline';
$downloadName = str_replace('"', '', $fileName);
return [
'Content-Type' => $mime,
'Content-Disposition' => "{$disposition}; filename=\"{$downloadName}\"",
'X-Content-Type-Options' => 'nosniff',
];
}
}

View File

@@ -3,7 +3,9 @@
namespace BookStack\Providers;
use BookStack\Actions\ActivityLogger;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Theming\ThemeService;
use BookStack\Uploads\ImageService;
use Illuminate\Support\ServiceProvider;
class CustomFacadeProvider extends ServiceProvider
@@ -29,6 +31,14 @@ class CustomFacadeProvider extends ServiceProvider
return $this->app->make(ActivityLogger::class);
});
$this->app->singleton('images', function () {
return $this->app->make(ImageService::class);
});
$this->app->singleton('permissions', function () {
return $this->app->make(PermissionService::class);
});
$this->app->singleton('theme', function () {
return $this->app->make(ThemeService::class);
});

View File

@@ -1,102 +0,0 @@
<?php
namespace BookStack\References;
use BookStack\Model;
use BookStack\References\ModelResolvers\BookLinkModelResolver;
use BookStack\References\ModelResolvers\BookshelfLinkModelResolver;
use BookStack\References\ModelResolvers\ChapterLinkModelResolver;
use BookStack\References\ModelResolvers\CrossLinkModelResolver;
use BookStack\References\ModelResolvers\PageLinkModelResolver;
use BookStack\References\ModelResolvers\PagePermalinkModelResolver;
use DOMDocument;
use DOMXPath;
class CrossLinkParser
{
/**
* @var CrossLinkModelResolver[]
*/
protected array $modelResolvers;
public function __construct(array $modelResolvers)
{
$this->modelResolvers = $modelResolvers;
}
/**
* Extract any found models within the given HTML content.
*
* @return Model[]
*/
public function extractLinkedModels(string $html): array
{
$models = [];
$links = $this->getLinksFromContent($html);
foreach ($links as $link) {
$model = $this->linkToModel($link);
if (!is_null($model)) {
$models[get_class($model) . ':' . $model->id] = $model;
}
}
return array_values($models);
}
/**
* Get a list of href values from the given document.
*
* @returns string[]
*/
protected function getLinksFromContent(string $html): array
{
$links = [];
$html = '<body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$anchors = $xPath->query('//a[@href]');
/** @var \DOMElement $anchor */
foreach ($anchors as $anchor) {
$links[] = $anchor->getAttribute('href');
}
return $links;
}
/**
* Attempt to resolve the given link to a model using the instance model resolvers.
*/
protected function linkToModel(string $link): ?Model
{
foreach ($this->modelResolvers as $resolver) {
$model = $resolver->resolve($link);
if (!is_null($model)) {
return $model;
}
}
return null;
}
/**
* Create a new instance with a pre-defined set of model resolvers, specifically for the
* default set of entities within BookStack.
*/
public static function createWithEntityResolvers(): self
{
return new self([
new PagePermalinkModelResolver(),
new PageLinkModelResolver(),
new ChapterLinkModelResolver(),
new BookLinkModelResolver(),
new BookshelfLinkModelResolver(),
]);
}
}

View File

@@ -1,26 +0,0 @@
<?php
namespace BookStack\References\ModelResolvers;
use BookStack\Entities\Models\Book;
use BookStack\Model;
class BookLinkModelResolver implements CrossLinkModelResolver
{
public function resolve(string $link): ?Model
{
$pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '([#?\/]|$)/';
$matches = [];
$match = preg_match($pattern, $link, $matches);
if (!$match) {
return null;
}
$bookSlug = $matches[1];
/** @var ?Book $model */
$model = Book::query()->where('slug', '=', $bookSlug)->first(['id']);
return $model;
}
}

View File

@@ -1,26 +0,0 @@
<?php
namespace BookStack\References\ModelResolvers;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Model;
class BookshelfLinkModelResolver implements CrossLinkModelResolver
{
public function resolve(string $link): ?Model
{
$pattern = '/^' . preg_quote(url('/shelves'), '/') . '\/([\w-]+)' . '([#?\/]|$)/';
$matches = [];
$match = preg_match($pattern, $link, $matches);
if (!$match) {
return null;
}
$shelfSlug = $matches[1];
/** @var ?Bookshelf $model */
$model = Bookshelf::query()->where('slug', '=', $shelfSlug)->first(['id']);
return $model;
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace BookStack\References\ModelResolvers;
use BookStack\Entities\Models\Chapter;
use BookStack\Model;
class ChapterLinkModelResolver implements CrossLinkModelResolver
{
public function resolve(string $link): ?Model
{
$pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '\/chapter\/' . '([\w-]+)' . '([#?\/]|$)/';
$matches = [];
$match = preg_match($pattern, $link, $matches);
if (!$match) {
return null;
}
$bookSlug = $matches[1];
$chapterSlug = $matches[2];
/** @var ?Chapter $model */
$model = Chapter::query()->whereSlugs($bookSlug, $chapterSlug)->first(['id']);
return $model;
}
}

View File

@@ -1,13 +0,0 @@
<?php
namespace BookStack\References\ModelResolvers;
use BookStack\Model;
interface CrossLinkModelResolver
{
/**
* Resolve the given href link value to a model.
*/
public function resolve(string $link): ?Model;
}

View File

@@ -1,27 +0,0 @@
<?php
namespace BookStack\References\ModelResolvers;
use BookStack\Entities\Models\Page;
use BookStack\Model;
class PageLinkModelResolver implements CrossLinkModelResolver
{
public function resolve(string $link): ?Model
{
$pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '\/page\/' . '([\w-]+)' . '([#?\/]|$)/';
$matches = [];
$match = preg_match($pattern, $link, $matches);
if (!$match) {
return null;
}
$bookSlug = $matches[1];
$pageSlug = $matches[2];
/** @var ?Page $model */
$model = Page::query()->whereSlugs($bookSlug, $pageSlug)->first(['id']);
return $model;
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace BookStack\References\ModelResolvers;
use BookStack\Entities\Models\Page;
use BookStack\Model;
class PagePermalinkModelResolver implements CrossLinkModelResolver
{
public function resolve(string $link): ?Model
{
$pattern = '/^' . preg_quote(url('/link'), '/') . '\/(\d+)/';
$matches = [];
$match = preg_match($pattern, $link, $matches);
if (!$match) {
return null;
}
$id = intval($matches[1]);
/** @var ?Page $model */
$model = Page::query()->find($id, ['id']);
return $model;
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace BookStack\References;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $from_id
* @property string $from_type
* @property int $to_id
* @property string $to_type
*/
class Reference extends Model
{
public $timestamps = false;
public function from(): MorphTo
{
return $this->morphTo('from');
}
public function to(): MorphTo
{
return $this->morphTo('to');
}
}

View File

@@ -1,62 +0,0 @@
<?php
namespace BookStack\References;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\Relation;
class ReferenceFetcher
{
protected PermissionApplicator $permissions;
public function __construct(PermissionApplicator $permissions)
{
$this->permissions = $permissions;
}
/**
* Query and return the page references pointing to the given entity.
* Loads the commonly required relations while taking permissions into account.
*/
public function getPageReferencesToEntity(Entity $entity): Collection
{
$baseQuery = $entity->referencesTo()
->where('from_type', '=', (new Page())->getMorphClass())
->with([
'from' => fn (Relation $query) => $query->select(Page::$listAttributes),
'from.book' => fn (Relation $query) => $query->scopes('visible'),
'from.chapter' => fn (Relation $query) => $query->scopes('visible'),
]);
$references = $this->permissions->restrictEntityRelationQuery(
$baseQuery,
'references',
'from_id',
'from_type'
)->get();
return $references;
}
/**
* Returns the count of page references pointing to the given entity.
* Takes permissions into account.
*/
public function getPageReferenceCountToEntity(Entity $entity): int
{
$baseQuery = $entity->referencesTo()
->where('from_type', '=', (new Page())->getMorphClass());
$count = $this->permissions->restrictEntityRelationQuery(
$baseQuery,
'references',
'from_id',
'from_type'
)->count();
return $count;
}
}

View File

@@ -1,69 +0,0 @@
<?php
namespace BookStack\References;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Collection;
class ReferenceStore
{
/**
* Update the outgoing references for the given page.
*/
public function updateForPage(Page $page): void
{
$this->updateForPages([$page]);
}
/**
* Update the outgoing references for all pages in the system.
*/
public function updateForAllPages(): void
{
Reference::query()
->where('from_type', '=', (new Page())->getMorphClass())
->delete();
Page::query()->select(['id', 'html'])->chunk(100, function (Collection $pages) {
$this->updateForPages($pages->all());
});
}
/**
* Update the outgoing references for the pages in the given array.
*
* @param Page[] $pages
*/
protected function updateForPages(array $pages): void
{
if (count($pages) === 0) {
return;
}
$parser = CrossLinkParser::createWithEntityResolvers();
$references = [];
$pageIds = array_map(fn (Page $page) => $page->id, $pages);
Reference::query()
->where('from_type', '=', $pages[0]->getMorphClass())
->whereIn('from_id', $pageIds)
->delete();
foreach ($pages as $page) {
$models = $parser->extractLinkedModels($page->html);
foreach ($models as $model) {
$references[] = [
'from_id' => $page->id,
'from_type' => $page->getMorphClass(),
'to_id' => $model->id,
'to_type' => $model->getMorphClass(),
];
}
}
foreach (array_chunk($references, 1000) as $referenceDataChunk) {
Reference::query()->insert($referenceDataChunk);
}
}
}

View File

@@ -1,122 +0,0 @@
<?php
namespace BookStack\References;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\RevisionRepo;
use DOMDocument;
use DOMXPath;
class ReferenceUpdater
{
protected ReferenceFetcher $referenceFetcher;
protected RevisionRepo $revisionRepo;
public function __construct(ReferenceFetcher $referenceFetcher, RevisionRepo $revisionRepo)
{
$this->referenceFetcher = $referenceFetcher;
$this->revisionRepo = $revisionRepo;
}
public function updateEntityPageReferences(Entity $entity, string $oldLink)
{
$references = $this->getReferencesToUpdate($entity);
$newLink = $entity->getUrl();
/** @var Reference $reference */
foreach ($references as $reference) {
/** @var Page $page */
$page = $reference->from;
$this->updateReferencesWithinPage($page, $oldLink, $newLink);
}
}
/**
* @return Reference[]
*/
protected function getReferencesToUpdate(Entity $entity): array
{
/** @var Reference[] $references */
$references = $this->referenceFetcher->getPageReferencesToEntity($entity)->values()->all();
if ($entity instanceof Book) {
$pages = $entity->pages()->get(['id']);
$chapters = $entity->chapters()->get(['id']);
$children = $pages->concat($chapters);
foreach ($children as $bookChild) {
$childRefs = $this->referenceFetcher->getPageReferencesToEntity($bookChild)->values()->all();
array_push($references, ...$childRefs);
}
}
$deduped = [];
foreach ($references as $reference) {
$key = $reference->from_id . ':' . $reference->from_type;
$deduped[$key] = $reference;
}
return array_values($deduped);
}
protected function updateReferencesWithinPage(Page $page, string $oldLink, string $newLink)
{
$page = (clone $page)->refresh();
$html = $this->updateLinksInHtml($page->html, $oldLink, $newLink);
$markdown = $this->updateLinksInMarkdown($page->markdown, $oldLink, $newLink);
$page->html = $html;
$page->markdown = $markdown;
$page->revision_count++;
$page->save();
$summary = trans('entities.pages_references_update_revision');
$this->revisionRepo->storeNewForPage($page, $summary);
}
protected function updateLinksInMarkdown(string $markdown, string $oldLink, string $newLink): string
{
if (empty($markdown)) {
return $markdown;
}
$commonLinkRegex = '/(\[.*?\]\()' . preg_quote($oldLink, '/') . '(.*?\))/i';
$markdown = preg_replace($commonLinkRegex, '$1' . $newLink . '$2', $markdown);
$referenceLinkRegex = '/(\[.*?\]:\s?)' . preg_quote($oldLink, '/') . '(.*?)($|\s)/i';
$markdown = preg_replace($referenceLinkRegex, '$1' . $newLink . '$2$3', $markdown);
return $markdown;
}
protected function updateLinksInHtml(string $html, string $oldLink, string $newLink): string
{
if (empty($html)) {
return $html;
}
$html = '<body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$anchors = $xPath->query('//a[@href]');
/** @var \DOMElement $anchor */
foreach ($anchors as $anchor) {
$link = $anchor->getAttribute('href');
$updated = str_ireplace($oldLink, $newLink, $link);
$anchor->setAttribute('href', $updated);
}
$html = '';
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
foreach ($topElems as $child) {
$html .= $doc->saveHTML($child);
}
return $html;
}
}

View File

@@ -2,8 +2,6 @@
namespace BookStack\Theming;
use BookStack\Entities\Models\Page;
/**
* The ThemeEvents used within BookStack.
*
@@ -17,19 +15,6 @@ use BookStack\Entities\Models\Page;
*/
class ThemeEvents
{
/**
* Activity logged event.
* Runs right after an activity is logged by bookstack.
* These are the activities that can be seen in the audit log area of BookStack.
* Activity types can be seen listed in the \BookStack\Actions\ActivityType class.
* The provided $detail can be a string or a loggable type of model. You should check
* the type before making use of this parameter.
*
* @param string $type
* @param string|\BookStack\Interfaces\Loggable $detail
*/
const ACTIVITY_LOGGED = 'activity_logged';
/**
* Application boot-up.
* After main services are registered.
@@ -38,6 +23,30 @@ class ThemeEvents
*/
const APP_BOOT = 'app_boot';
/**
* Web before middleware action.
* Runs before the request is handled but after all other middleware apart from those
* that depend on the current session user (Localization for example).
* Provides the original request to use.
* Return values, if provided, will be used as a new response to use.
*
* @param \Illuminate\Http\Request $request
* @returns \Illuminate\Http\Response|null
*/
const WEB_MIDDLEWARE_BEFORE = 'web_middleware_before';
/**
* Web after middleware action.
* Runs after the request is handled but before the response is sent.
* Provides both the original request and the currently resolved response.
* Return values, if provided, will be used as a new response to use.
*
* @param \Illuminate\Http\Request $request
* @param \Illuminate\Http\Response|Symfony\Component\HttpFoundation\BinaryFileResponse $response
* @returns \Illuminate\Http\Response|null
*/
const WEB_MIDDLEWARE_AFTER = 'web_middleware_after';
/**
* Auth login event.
* Runs right after a user is logged-in to the application by any authentication
@@ -62,7 +71,8 @@ class ThemeEvents
/**
* Commonmark environment configure.
* Provides the commonmark library environment for customization before it's used to render markdown content.
* Provides the commonmark library environment for customization
* before its used to render markdown content.
* If the listener returns a non-null value, that will be used as an environment instead.
*
* @param \League\CommonMark\ConfigurableEnvironmentInterface $environment
@@ -70,45 +80,6 @@ class ThemeEvents
*/
const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure';
/**
* Page include parse event.
* Runs when a page include tag is being parsed, typically when page content is being processed for viewing.
* Provides the "include tag" reference string, the default BookStack replacement content for the tag,
* the current page being processed, and the page that's being referenced by the include tag.
* The referenced page may be null where the page does not exist or where permissions prevent visibility.
* If the listener returns a non-null value, that will be used as the replacement HTML content instead.
*
* @param string $tagReference
* @param string $replacementHTML
* @param Page $currentPage
* @param ?Page $referencedPage
*/
const PAGE_INCLUDE_PARSE = 'page_include_parse';
/**
* Web before middleware action.
* Runs before the request is handled but after all other middleware apart from those
* that depend on the current session user (Localization for example).
* Provides the original request to use.
* Return values, if provided, will be used as a new response to use.
*
* @param \Illuminate\Http\Request $request
* @returns \Illuminate\Http\Response|null
*/
const WEB_MIDDLEWARE_BEFORE = 'web_middleware_before';
/**
* Web after middleware action.
* Runs after the request is handled but before the response is sent.
* Provides both the original request and the currently resolved response.
* Return values, if provided, will be used as a new response to use.
*
* @param \Illuminate\Http\Request $request
* @param \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\BinaryFileResponse $response
* @returns \Illuminate\Http\Response|null
*/
const WEB_MIDDLEWARE_AFTER = 'web_middleware_after';
/**
* Webhook call before event.
* Runs before a webhook endpoint is called. Allows for customization

View File

@@ -2,7 +2,7 @@
namespace BookStack\Uploads;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
@@ -89,9 +89,10 @@ class Attachment extends Model
*/
public function scopeVisible(): Builder
{
$permissions = app()->make(PermissionApplicator::class);
$permissionService = app()->make(PermissionService::class);
return $permissions->restrictPageRelationQuery(
return $permissionService->filterRelatedEntity(
Page::class,
self::query(),
'attachments',
'uploaded_to'

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