mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-09 19:06:13 +03:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf0ba9f756 | ||
|
|
05f8034439 | ||
|
|
1d1186c901 | ||
|
|
641a26cdf7 | ||
|
|
5fd8e7e0e9 | ||
|
|
d926ca5f71 | ||
|
|
b69722c3b5 | ||
|
|
c9aa1c979f |
@@ -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
1
.github/FUNDING.yml
vendored
@@ -1,4 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [ssddanbrown]
|
||||
ko_fi: ssddanbrown
|
||||
4
.github/ISSUE_TEMPLATE/language_request.yml
vendored
4
.github/ISSUE_TEMPLATE/language_request.yml
vendored
@@ -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:
|
||||
|
||||
35
.github/translators.txt
vendored
35
.github/translators.txt
vendored
@@ -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
6
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
719
app/Auth/Permissions/PermissionService.php
Normal file
719
app/Auth/Permissions/PermissionService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search;
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Model;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search;
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\Tag;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,6 @@ class ShelfContext
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var Bookshelf $shelf */
|
||||
$shelf = Bookshelf::visible()->find($contextBookshelfId);
|
||||
$shelfContainsBook = $shelf && $shelf->contains($book);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,7 +21,6 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
protected $dontReport = [
|
||||
NotFoundException::class,
|
||||
StoppedAuthenticationException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace BookStack\Facades;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
/**
|
||||
* @mixin \BookStack\Actions\ActivityLogger
|
||||
* @see \BookStack\Actions\ActivityLogger
|
||||
*/
|
||||
class Activity extends Facade
|
||||
{
|
||||
|
||||
18
app/Facades/Permissions.php
Normal file
18
app/Facades/Permissions.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
]);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user