Compare commits

...

92 Commits

Author SHA1 Message Date
Dan Brown
ebd6e4d3a2 Updated version and assets for release v22.09.1 2022-09-20 13:19:34 +01:00
Dan Brown
80374aea5c Merge branch 'development' into release 2022-09-20 13:19:03 +01:00
Dan Brown
aec772c5eb Updated translator attribution before release v22.09.1 2022-09-20 13:18:41 +01:00
Dan Brown
2e4d29e062 New Crowdin updates (#3710) 2022-09-20 13:16:15 +01:00
Dan Brown
dce6a82954 Added reason, if existing, into SAML acs error
Closes #3731
2022-09-20 12:52:44 +01:00
Dan Brown
050d69ea27 Added extra setlocale format to help windows support
Related to #3650
2022-09-20 12:00:14 +01:00
Dan Brown
0cc68b7665 Fixed language request link in readme 2022-09-19 17:24:21 +01:00
Dan Brown
75d6b56072 Merge pull request #3728 from BookStackApp/php_formatting
Addition of PHPCS for formatting
2022-09-18 15:04:07 +01:00
Dan Brown
ac27b5aebb Updated readme for phpcs usage, aligned gh action workflows 2022-09-18 14:50:25 +01:00
Dan Brown
ecbc7344fc Added php lint gh action, updated composer scripts 2022-09-18 01:56:45 +01:00
Dan Brown
8a749c6acf Added and ran PHPCS 2022-09-18 01:25:20 +01:00
Dan Brown
2ac9efae7d Updated version and assets for release v22.09 2022-09-08 12:41:09 +01:00
Dan Brown
a11d565ba4 Merge branch 'development' into release 2022-09-08 12:40:57 +01:00
Dan Brown
d0dc5e5c5d Added a little protection to migration query
Just to be sure the query is filtered as expected to only affect
shelf-based images.
2022-09-08 12:26:14 +01:00
Dan Brown
e4642257a6 New Crowdin updates (#3701) 2022-09-08 11:59:57 +01:00
Dan Brown
f7418d0600 Updated translator attribution 2022-09-08 11:58:55 +01:00
Dan Brown
98aed794cc Made a range of rtl fixes
Mostly around dropdowns and other items that had right/left specific
styling.
For #3702
2022-09-06 21:31:18 +01:00
Dan Brown
623ccd4cfa Removed old thai files, added romanian as lang option
Also applied styleci changes
2022-09-06 17:41:32 +01:00
Dan Brown
d8672944a5 Added image view access notice to role form
Added to clarify the role permission in scenarios where users may have
not read the docs site to understand image access control.

Related to #3688
2022-09-06 17:20:35 +01:00
Dan Brown
6955b2fd5a Widened svg content attribute xss filtering
Takes care of additional cases that can occur.
Closes #3705
2022-09-06 17:01:56 +01:00
Dan Brown
24f82749ff Updated OIDC group attr option name
To match the existing option name for display names.
Closes #3704
2022-09-06 16:33:17 +01:00
Dan Brown
b9941e8e61 Merge pull request #3698 from BookStackApp/include_theme_event
Added "page_include_parse" theme event
2022-09-05 16:51:01 +01:00
Dan Brown
7101ce3050 Added "page_include_parse" theme event
For custom control of include tag parsing.
2022-09-05 16:40:42 +01:00
Dan Brown
fbef0d06f2 Added permission visiblity control to image-delete button
Includes test to cover.
For #3697
2022-09-05 15:52:12 +01:00
Dan Brown
b698bb0e07 Wrapped wysiwyg drawing change in editor transaction
To make the content changes made a undoable transaction that is picked
up as a change.
From my testing, should address #3682
2022-09-05 15:06:47 +01:00
Dan Brown
2d7552aa09 Addressed setlocale issue caught by phpstan
setlocale could be called with no second param if the language given to
the modified function was empty.
2022-09-05 13:33:05 +01:00
Dan Brown
ee1e936660 Applied styleci changes, updated composer deps 2022-09-05 13:18:37 +01:00
Dan Brown
50214d5fe6 New Crowdin updates (#3643) 2022-09-05 13:17:10 +01:00
Dan Brown
2fe261e207 Updated page revisions link visibility
To match the actual visibilities of the revisions listing page and
options.
Related to #2946
2022-09-03 12:32:21 +01:00
Dan Brown
9158a66bff Updated & improved language locale handling
Extracted much of the language and locale work to a seperate, focused class.
Updated php set_locale usage to prioritise UTF8 usage.
Added locale options for windows.
Clarified what's a locale and a bookstack language string.

For #3590 and maybe #3650
2022-09-02 19:19:01 +01:00
Dan Brown
7f8b3eff5a Fixed failing tests due to shelf text changes, applied styleci changes 2022-09-02 14:47:44 +01:00
Dan Brown
5736919836 Merge pull request #3693 from BookStackApp/local_secure_restricted
Addition of a `local_secure_restricted` image storage option
2022-09-02 14:41:25 +01:00
Dan Brown
c76b5e2ec4 Fixed local_secure_restricted preventing attachment uploads
Due to option name change and therefore lack of handling.
Added test case to cover.
2022-09-02 14:40:17 +01:00
Dan Brown
092b6d6378 Added test and handling for local_secure_restricted in exports 2022-09-02 14:21:43 +01:00
Dan Brown
f88330202b Added test to cover secure restricted functionality 2022-09-02 14:03:23 +01:00
Dan Brown
f28ed0ef0b Fixed shelf covers being stored as 'cover_book'
Are now stored as 'cover_bookshelf' as expected.
Added a migrate to alter existing shelf cover image types.
2022-09-02 12:54:54 +01:00
Dan Brown
27ac122502 Started work on local_secure_restricted image option 2022-09-01 16:17:14 +01:00
Dan Brown
9da3130a12 Aligned bookshelf terminology to consistently be 'Shelf'
For #3553
EN only, other languages should be handled via CrowdIn
2022-09-01 14:55:35 +01:00
Dan Brown
1afc915aed Fixed missing nested list indent next to floated content
Fixes #3672
2022-09-01 13:11:59 +01:00
Dan Brown
34c63e1c30 Added test & update to prevent page creation w/ empty slug
Caused by changes to page repo in reference work,
This adds back in the slug generate although at a more central place.
Adds a test case to cover the problematic scenario.
2022-09-01 12:53:34 +01:00
Dan Brown
f092c97748 Fixed lack of url reference updating on book child move 2022-08-30 22:12:52 +01:00
Dan Brown
9153be963d Added book child reference handling on book url change
Closes #3683
2022-08-30 22:00:32 +01:00
Dan Brown
1cc7c649dc Applied StyleCi changes, updated php deps 2022-08-29 17:46:41 +01:00
Dan Brown
e537d0c4e8 Merge pull request #3656 from BookStackApp/x_linking
Link reference tracking & updating
2022-08-29 17:45:05 +01:00
Dan Brown
961e418cb7 Fixed phpstan wanring about usage of static 2022-08-29 17:39:50 +01:00
Dan Brown
6edf2c155d Added maintenance action to regenerate references 2022-08-29 17:30:26 +01:00
Dan Brown
401c156687 Merge pull request #3616 from BookStackApp/oidc_group_sync
Added OIDC group sync functionality
2022-08-25 11:17:18 +01:00
Dan Brown
760eff397f Updated API docs with better request format explanation
Explained the content-types accepted by BookStack.
Made it clear that 'Content-Type' is expected on requests.
Added example to shown how to achieve more complex formats using
non-json requests.
Also added link to api-scripts repo.

Related to #3666 and #3652
2022-08-23 17:05:42 +01:00
Dan Brown
d134639eca Doubled default revision limit
Due to potential increase of revision entries due to auto-changes.
2022-08-23 16:32:07 +01:00
Dan Brown
b86ee6d252 Rolled out reference link updating logic usage
Added test to cover updating of content on reference url change
2022-08-21 18:05:19 +01:00
Dan Brown
0dbf08453f Built out cross link replacer, not yet tested 2022-08-21 11:29:34 +01:00
Dan Brown
26ccb7b644 Started work on reference on-change-updates
Refactored out revision-specific actions within PageRepo for
organisition and re-use for cross-linking work.
2022-08-20 21:09:07 +01:00
Dan Brown
f634b4ea57 Added entity meta link to reference page
Not totally happy with implementation as is requires extra service to be
injected to core controllers, but does the job.
Included test to cover.
Updated some controller properties to be typed while there.
2022-08-20 12:07:38 +01:00
Dan Brown
d198332d3c Rolled out reference pages to all entities, added testing
Including testing to check permissions applied to listed references.
2022-08-19 22:40:44 +01:00
Dan Brown
d5465726e2 Added inbound references listing for pages 2022-08-19 13:14:43 +01:00
Dan Brown
bbe504c559 Added reference handling on page actions
Page update/create/restore/clone/delete.
Added a couple of tests to cover a couple of those.
2022-08-17 17:37:27 +01:00
Dan Brown
3290ab3ac9 Added regenerate-references command test
Also updated model resolvers to only fetch model ID, to prevent bringing
back way more data from database than desired.
2022-08-17 16:59:23 +01:00
Dan Brown
5d29d0cc7b Added reference storage system, and command to re-index
Also re-named/orgranized some files for this, to make them "References"
specific instead of a subset of "Util".
2022-08-17 14:40:14 +01:00
Dan Brown
344b3a3615 Added system to extract model references from HTML content
For the start of a managed cross-linking system.
2022-08-16 13:23:53 +01:00
Dan Brown
837fd74bf6 Refactored search-based code to its own folder
Also applied StyleCI changes
2022-08-16 11:28:05 +01:00
Dan Brown
2b06e86d53 Merge pull request #3653 from krsriq/patch-1
Fix typos
2022-08-15 22:31:49 +01:00
Daniel Schmelz
9041e25476 Fix typos 2022-08-15 22:41:44 +02:00
Dan Brown
1fdf854ea7 Updated version and assets for release v22.07.3 2022-08-11 15:17:06 +01:00
Dan Brown
e9c9792cb9 Merge branch 'development' into release 2022-08-11 15:16:34 +01:00
Dan Brown
d6235bcf92 Merge branch '3636-security-patch' into development 2022-08-11 15:15:19 +01:00
Dan Brown
6a3f4f5e79 Updated translator attribution pre v22.07.3 release 2022-08-11 13:17:18 +01:00
Dan Brown
7b100ef361 Merge branch 'persian_translate_22_08_10' into development 2022-08-11 13:15:15 +01:00
Dan Brown
443415ea0d New Crowdin updates (#3635) 2022-08-11 13:12:55 +01:00
Dan Brown
e02bd5e57e Added content security section to the api docs
Related to #3636
2022-08-11 10:49:45 +01:00
Dan Brown
5f7cd735ea Added content filtering of tags with javascript or data in values attr
Case would be blocked by CSP but adding for cases where CSP may not be
active when content taken externally.

For #3636
2022-08-11 10:28:32 +01:00
samad hassan allafi
89ff0d43bb Completion of Persian translation 2022-08-10 2022-08-10 22:55:31 +04:30
Dan Brown
375abca1ee Merge pull request #3632 from BookStackApp/ownable_permission_fix
Fixed failed permission checks due to non-loaded fields
2022-08-10 17:59:46 +01:00
Dan Brown
031c67ba58 Reduced the memory usage, db queries and cache hits loading revisions
Updated revision listing to only fetch required fields, massively
reducing memory usage by not loading content.
This also updates user avatar handling to effectively cache the avatar
url within request to avoid re-searching from cache, which may improve
performance of others areas of the application.
This also upates handling of the revisions list view to extract table
row to its own view to break things down a bit.

For #3633
2022-08-10 17:50:35 +01:00
Dan Brown
764489e30b Improved WYSWYG editor code block layout update
To help prevent against empty areas during inital empty-cache loads.
This delays the original layout update a little to give time for the
layout to render as expected.

For #3637
2022-08-10 13:51:54 +01:00
Dan Brown
16eedc8264 Fixed failed permission checks due to non-loaded fields
Added additional exceptions to prevent such cases in the future, so
that they are caught in dev ideally.
Added test case specifically for reported favourite scenario.
2022-08-10 08:06:48 +01:00
Dan Brown
5ae524c25a Updated version and assets for release v22.07.2 2022-08-09 13:55:52 +01:00
Dan Brown
0d7287fc8b Merge branch 'development' into release 2022-08-09 13:55:40 +01:00
Dan Brown
219da9da9b Updated translator attribution before release v22.07.2 2022-08-09 13:55:26 +01:00
Dan Brown
38ce54ea0c Merge pull request #3630 from BookStackApp/export_template_parts
Export template partials
2022-08-09 13:51:24 +01:00
Dan Brown
97ec560282 Added test to cover export body start/end partial usage 2022-08-09 13:49:42 +01:00
Dan Brown
06b5a83d8f Added convenience theme system partials for export layouts
To allow easier additions to start/end of body tag in export formats.
2022-08-09 13:46:52 +01:00
Dan Brown
45dc28ba2a Applied latest styleci changes 2022-08-09 13:26:45 +01:00
Dan Brown
6e0a7344fa Added revision activity types to system and audit log
Closes #3628
2022-08-09 13:25:18 +01:00
Dan Brown
7fa934e7f2 New Crowdin updates (#3625) 2022-08-09 13:00:39 +01:00
Dan Brown
a90446796a Fixed issue preventing selection of activity type in audit log
For #3623
2022-08-09 12:58:10 +01:00
Dan Brown
4209f27f1a Set a fairly sensible limit on user name validation
Also updated controller properties with types within modified files.
Related to #3614
2022-08-09 12:40:59 +01:00
Dan Brown
89ec9a5081 Sprinkled in some user language validation
For #3615
2022-08-04 17:24:04 +01:00
Dan Brown
b987bea37a Added OIDC group sync functionality
Is generally aligned with out SAML2 group sync functionality, but for
OIDC based upon feedback in #3004.
Neeeded the tangental addition of being able to define custom scopes on
the initial auth request as some systems use this to provide additional
id token claims such as groups.

Includes tests to cover.
Tested live using Okta.
2022-08-02 16:56:56 +01:00
Dan Brown
e77c96f6b7 Updated version and assets for release v22.07.1 2022-08-02 11:47:25 +01:00
Dan Brown
9b8a10dd3a Merge branch 'development' into release 2022-08-02 11:47:08 +01:00
Dan Brown
42f4c9afae New Crowdin updates (#3605) 2022-08-02 11:31:24 +01:00
Dan Brown
8d6071cb84 Updated cache busting for tinymce library import
Changes from a manual cache buster string to a app-version-based cache
buster, as per our other scripts and styles.

To address #3611
2022-08-02 11:17:02 +01:00
336 changed files with 6368 additions and 3303 deletions

View File

@@ -263,7 +263,11 @@ 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
@@ -295,7 +299,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=50
REVISION_LIMIT=100
# Recycle Bin Lifetime
# The number of days that content will remain in the recycle bin before

View File

@@ -56,6 +56,7 @@ Name :: Languages
@arcoai :: Spanish
@Jokuna :: Korean
@smartshogu :: German; German Informal
@samadha56 :: Persian
cipi1965 :: Italian
Mykola Ronik (Mantikor) :: Ukrainian
furkanoyk :: Turkish
@@ -137,7 +138,7 @@ Xiphoseer :: German
MerlinSVK (merlinsvk) :: Slovak
Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
MatthieuParis :: French
Douradinho :: Portuguese, Brazilian
Douradinho :: Portuguese, Brazilian; Portuguese
Gaku Yaguchi (tama11) :: Japanese
johnroyer :: Chinese Traditional
jackaaa :: Chinese Traditional
@@ -175,7 +176,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: Dutch; Turkish
REMOVED_USER :: ; Dutch; Turkish
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -267,3 +268,15 @@ 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
Naoto Ishikawa (na3shkw) :: Japanese
sdhadi :: Persian
DerLinkman (derlinkman) :: German; German Informal
TurnArabic :: Arabic
Martin Sebek (sebekmartin) :: Czech
Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified

View File

@@ -1,21 +1,18 @@
name: phpstan
name: analyse-php
on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.4']
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
php-version: 8.1
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
@@ -24,13 +21,14 @@ jobs:
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
key: ${{ runner.os }}-composer-8.1
restore-keys: ${{ runner.os }}-composer-
- name: Install composer dependencies
run: composer install --prefer-dist --no-interaction --ansi
- name: Run PHPStan
run: php${{ matrix.php }} ./vendor/bin/phpstan analyse --memory-limit=2G
- name: Run static analysis check
run: composer check-static

19
.github/workflows/lint-php.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: lint-php
on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.1
tools: phpcs
- name: Run formatting check
run: composer lint

View File

@@ -5,7 +5,7 @@ on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['7.4', '8.0', '8.1']
@@ -24,10 +24,11 @@ jobs:
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
restore-keys: ${{ runner.os }}-composer-
- name: Start MySQL
run: |

View File

@@ -1,11 +1,11 @@
name: phpunit
name: test-php
on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['7.4', '8.0', '8.1']
@@ -24,10 +24,11 @@ jobs:
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
restore-keys: ${{ runner.os }}-composer-
- name: Start Database
run: |
@@ -48,5 +49,5 @@ jobs:
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
- name: phpunit
- name: Run PHP tests
run: php${{ matrix.php }} ./vendor/bin/phpunit

View File

@@ -29,6 +29,9 @@ class ActivityType
const COMMENTED_ON = 'commented_on';
const PERMISSIONS_UPDATE = 'permissions_update';
const REVISION_RESTORE = 'revision_restore';
const REVISION_DELETE = 'revision_delete';
const SETTINGS_UPDATE = 'settings_update';
const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';

View File

@@ -22,10 +22,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
*/
class Webhook extends Model implements Loggable
{
protected $fillable = ['name', 'endpoint', 'timeout'];
use HasFactory;
protected $fillable = ['name', 'endpoint', 'timeout'];
protected $casts = [
'last_called_at' => 'datetime',
'last_errored_at' => 'datetime',

View File

@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Model;
*/
class WebhookTrackedEvent extends Model
{
protected $fillable = ['event'];
use HasFactory;
protected $fillable = ['event'];
}

View File

@@ -105,7 +105,7 @@ class LdapService
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
'avatar'=> $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
];
if ($this->config['dump_user_details']) {

View File

@@ -30,6 +30,11 @@ 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.
*/
@@ -54,6 +59,15 @@ 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.
*
@@ -62,7 +76,7 @@ class OidcOAuthProvider extends AbstractProvider
*/
protected function getDefaultScopes(): array
{
return ['openid', 'profile', 'email'];
return $this->scopes;
}
/**

View File

@@ -2,20 +2,18 @@
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;
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;
use Psr\Http\Client\ClientInterface as HttpClient;
use function trans;
use function url;
/**
* Class OpenIdConnectService
@@ -26,15 +24,21 @@ 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)
{
public function __construct(
RegistrationService $registrationService,
LoginService $loginService,
HttpClient $httpClient,
GroupSyncService $groupService
) {
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->httpClient = $httpClient;
$this->groupService = $groupService;
}
/**
@@ -117,10 +121,31 @@ class OidcService
*/
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{
return new OidcOAuthProvider($settings->arrayForProvider(), [
$provider = 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);
}
/**
@@ -145,10 +170,32 @@ 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}
* @return array{name: string, email: string, external_id: string, groups: string[]}
*/
protected function getUserDetails(OidcIdToken $token): array
{
@@ -158,6 +205,7 @@ class OidcService
'external_id' => $id,
'email' => $token->getClaim('email'),
'name' => $this->getUserDisplayName($token, $id),
'groups' => $this->getUserGroups($token),
];
}
@@ -209,6 +257,12 @@ 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;
@@ -221,4 +275,12 @@ class OidcService
{
return config('oidc');
}
/**
* Check if groups should be synced.
*/
protected function shouldSyncGroups(): bool
{
return $this->config()['user_to_groups'] !== false;
}
}

View File

@@ -109,9 +109,10 @@ class Saml2Service
$errors = $toolkit->getErrors();
if (!empty($errors)) {
throw new Error(
'Invalid ACS Response: ' . implode(', ', $errors)
);
$reason = $toolkit->getLastErrorReason();
$message = 'Invalid ACS Response; Errors: ' . implode(', ', $errors);
$message .= $reason ? "; Reason: {$reason}" : '';
throw new Error($message);
}
if (!$toolkit->isAuthenticated()) {

View File

@@ -34,7 +34,13 @@ class PermissionApplicator
$ownRolePermission = $user->can($fullPermission . '-own');
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
$isOwner = $user->id === $ownable->getAttribute($ownerField);
$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
@@ -68,6 +74,10 @@ class PermissionApplicator
}
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)

View File

@@ -80,6 +80,11 @@ 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.
*
@@ -233,12 +238,18 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $default;
}
if (!empty($this->avatarUrl)) {
return $this->avatarUrl;
}
try {
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
} catch (Exception $err) {
$avatar = $default;
}
$this->avatarUrl = $avatar;
return $avatar;
}

View File

@@ -22,7 +22,7 @@ return [
// The number of revisions to keep in the database.
// Once this limit is reached older revisions will be deleted.
// If set to false then a limit will not be enforced.
'revision_limit' => env('REVISION_LIMIT', 50),
'revision_limit' => env('REVISION_LIMIT', 100),
// 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
@@ -75,7 +75,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', 'ru', 'th', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
'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'],
// Application Fallback Locale
'fallback_locale' => 'en',

View File

@@ -7,6 +7,7 @@
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$dompdfPaperSizeMap = [
'a4' => 'a4',
'letter' => 'letter',

View File

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

View File

@@ -7,6 +7,7 @@
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$snappyPaperSizeMap = [
'a4' => 'A4',
'letter' => 'Letter',

View File

@@ -5,6 +5,7 @@ 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
{
@@ -43,9 +44,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) {
@@ -55,7 +56,9 @@ class RegenerateCommentContent extends Command
}
});
\DB::setDefaultConnection($connection);
DB::setDefaultConnection($connection);
$this->comment('Comment HTML content has been regenerated');
return 0;
}
}

View File

@@ -50,5 +50,7 @@ class RegeneratePermissions extends Command
DB::setDefaultConnection($connection);
$this->comment('Permissions regenerated');
return 0;
}
}

View File

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

View File

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

View File

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

View File

@@ -86,7 +86,7 @@ class Bookshelf extends Entity implements HasCoverImage
*/
public function coverImageTypeKey(): string
{
return 'cover_shelf';
return 'cover_bookshelf';
}
/**

View File

@@ -11,7 +11,6 @@ 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\Interfaces\Deletable;
use BookStack\Interfaces\Favouritable;
@@ -19,6 +18,9 @@ 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;
@@ -202,6 +204,22 @@ 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'.

View File

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

View File

@@ -6,6 +6,7 @@ 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;
@@ -13,11 +14,13 @@ class BaseRepo
{
protected TagRepo $tagRepo;
protected ImageRepo $imageRepo;
protected ReferenceUpdater $referenceUpdater;
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater)
{
$this->tagRepo = $tagRepo;
$this->imageRepo = $imageRepo;
$this->referenceUpdater = $referenceUpdater;
}
/**
@@ -38,6 +41,7 @@ class BaseRepo
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
}
$entity->refresh();
$entity->rebuildPermissions();
$entity->indexForSearch();
}
@@ -47,10 +51,12 @@ class BaseRepo
*/
public function update(Entity $entity, array $input)
{
$oldUrl = $entity->getUrl();
$entity->fill($input);
$entity->updated_by = user()->id;
if ($entity->isDirty('name')) {
if ($entity->isDirty('name') || empty($entity->slug)) {
$entity->refreshSlug();
}
@@ -63,6 +69,10 @@ class BaseRepo
$entity->rebuildPermissions();
$entity->indexForSearch();
if ($oldUrl !== $entity->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
}
}
/**
@@ -76,8 +86,9 @@ 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, 'cover_book', $entity->id, 512, 512, true);
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
$entity->cover()->associate($image);
$entity->save();
}

View File

@@ -140,7 +140,7 @@ class BookshelfRepo
public function copyDownPermissions(Bookshelf $shelf, $checkUserPermissions = true): int
{
$shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
$shelfBooks = $shelf->books()->get(['id', 'restricted']);
$shelfBooks = $shelf->books()->get(['id', 'restricted', 'owned_by']);
$updatedBookCount = 0;
/** @var Book $book */

View File

@@ -16,20 +16,31 @@ 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;
protected BaseRepo $baseRepo;
protected RevisionRepo $revisionRepo;
protected ReferenceStore $referenceStore;
protected ReferenceUpdater $referenceUpdater;
/**
* PageRepo constructor.
*/
public function __construct(BaseRepo $baseRepo)
{
public function __construct(
BaseRepo $baseRepo,
RevisionRepo $revisionRepo,
ReferenceStore $referenceStore,
ReferenceUpdater $referenceUpdater
) {
$this->baseRepo = $baseRepo;
$this->revisionRepo = $revisionRepo;
$this->referenceStore = $referenceStore;
$this->referenceUpdater = $referenceUpdater;
}
/**
@@ -39,6 +50,7 @@ class PageRepo
*/
public function getById(int $id, array $relations = ['book']): Page
{
/** @var Page $page */
$page = Page::visible()->with($relations)->find($id);
if (!$page) {
@@ -70,17 +82,7 @@ class PageRepo
*/
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
{
/** @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();
$revision = $this->revisionRepo->getBySlugs($bookSlug, $pageSlug);
return $revision->page ?? null;
}
@@ -112,7 +114,7 @@ class PageRepo
public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity
{
if ($chapterSlug !== null) {
return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
return Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
}
return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
@@ -123,9 +125,7 @@ class PageRepo
*/
public function getUserDraft(Page $page): ?PageRevision
{
$revision = $this->getUserDraftQuery($page)->first();
return $revision;
return $this->revisionRepo->getLatestDraftForCurrentUser($page);
}
/**
@@ -165,11 +165,10 @@ class PageRepo
$draft->draft = false;
$draft->revision_count = 1;
$draft->priority = $this->getNewPriority($draft);
$draft->refreshSlug();
$draft->save();
$this->savePageRevision($draft, trans('entities.pages_initial_revision'));
$draft->indexForSearch();
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
$this->referenceStore->updateForPage($draft);
$draft->refresh();
Activity::add(ActivityType::PAGE_CREATE, $draft);
@@ -189,13 +188,14 @@ 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->getUserDraftQuery($page)->delete();
$this->revisionRepo->deleteDraftsForCurrentUser($page);
// 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->savePageRevision($page, $summary);
$this->revisionRepo->storeNewForPage($page, $summary);
}
Activity::add(ActivityType::PAGE_UPDATE, $page);
@@ -239,32 +239,6 @@ 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.
*/
@@ -280,7 +254,7 @@ class PageRepo
}
// Otherwise, save the data to a revision
$draft = $this->getPageRevisionToUpdate($page);
$draft = $this->revisionRepo->getNewDraftForCurrentUser($page);
$draft->fill($input);
if (!empty($input['markdown'])) {
@@ -314,6 +288,7 @@ class PageRepo
*/
public function restoreRevision(Page $page, int $revisionId): Page
{
$oldUrl = $page->getUrl();
$page->revision_count++;
/** @var PageRevision $revision */
@@ -332,11 +307,17 @@ 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->savePageRevision($page, $summary);
$this->revisionRepo->storeNewForPage($page, $summary);
if ($oldUrl !== $page->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($page, $oldUrl);
}
Activity::add(ActivityType::PAGE_RESTORE, $page);
Activity::add(ActivityType::REVISION_RESTORE, $revision);
return $page;
}
@@ -392,48 +373,6 @@ class PageRepo
return $parentClass::visible()->where('id', '=', $entityId)->first();
}
/**
* 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.
*/
@@ -449,15 +388,4 @@ class PageRepo
return (new BookContents($page->book))->getLastPriority() + 1;
}
/**
* Get the query to find the user's draft copies of the given page.
*/
protected function getUserDraftQuery(Page $page)
{
return PageRevision::query()->where('created_by', '=', user()->id)
->where('type', 'update_draft')
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc');
}
}

View File

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

View File

@@ -235,7 +235,7 @@ class ExportFormatter
$linksOutput = [];
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
// Replace image src with base64 encoded image strings
// Update relative links to be absolute, with instance url
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
foreach ($linksOutput[0] as $index => $linkMatch) {
$oldLinkString = $linkMatch;
@@ -248,7 +248,6 @@ class ExportFormatter
}
}
// Replace any relative links with system domain
return $htmlContent;
}

View File

@@ -5,6 +5,8 @@ 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;
@@ -372,23 +374,30 @@ class PageContent
continue;
}
// Find page and skip this if page not found
// Find page to use, and default replacement to empty string for non-matches.
/** @var ?Page $matchedPage */
$matchedPage = Page::visible()->find($pageId);
if ($matchedPage === null) {
$html = str_replace($fullMatch, '', $html);
continue;
$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 we only have page id, just insert all page html and continue.
if (count($splitInclude) === 1) {
$html = str_replace($fullMatch, $matchedPage->html, $html);
continue;
}
$themeReplacement = Theme::dispatch(
ThemeEvents::PAGE_INCLUDE_PARSE,
$includeId,
$replacement,
clone $this->page,
$matchedPage ? (clone $matchedPage) : null,
);
// Create and load HTML into a document
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
$html = str_replace($fullMatch, trim($innerContent), $html);
// Perform the content replacement
$html = str_replace($fullMatch, $themeReplacement ?? $replacement, $html);
}
return $html;

View File

@@ -42,7 +42,7 @@ class PageEditActivity
$userMessage = trans('entities.pages_draft_edit_active.start_b', ['userName' => $firstDraft->createdBy->name ?? '']);
}
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount' => 60]);
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
}

View File

@@ -376,6 +376,8 @@ class TrashCan
$entity->searchTerms()->delete();
$entity->deletions()->delete();
$entity->favourites()->delete();
$entity->referencesTo()->delete();
$entity->referencesFrom()->delete();
if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
$imageService = app()->make(ImageService::class);

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,6 @@ class ForgotPasswordController extends Controller
| your application to your users. Feel free to explore this trait.
|
*/
use SendsPasswordResetEmails;
/**

View File

@@ -24,8 +24,9 @@ class LoginController extends Controller
| to conveniently provide its functionality to your applications.
|
*/
use AuthenticatesUsers { logout as traitLogout; }
use AuthenticatesUsers {
logout as traitLogout;
}
/**
* Redirection paths.
@@ -112,8 +113,10 @@ class LoginController extends Controller
// If the class is using the ThrottlesLogins trait, we can automatically throttle
// the login attempts for this application. We'll key this by the username and
// the IP address of the client making these requests into this application.
if (method_exists($this, 'hasTooManyLoginAttempts') &&
$this->hasTooManyLoginAttempts($request)) {
if (
method_exists($this, 'hasTooManyLoginAttempts') &&
$this->hasTooManyLoginAttempts($request)
) {
$this->fireLockoutEvent($request);
Activity::logFailedLogin($username);

View File

@@ -27,12 +27,11 @@ class RegisterController extends Controller
| provide this functionality without requiring any additional code.
|
*/
use RegistersUsers;
protected $socialAuthService;
protected $registrationService;
protected $loginService;
protected SocialAuthService $socialAuthService;
protected RegistrationService $registrationService;
protected LoginService $loginService;
/**
* Where to redirect users after login / registration.
@@ -69,7 +68,7 @@ class RegisterController extends Controller
protected function validator(array $data)
{
return Validator::make($data, [
'name' => ['required', 'min:2', 'max:255'],
'name' => ['required', 'min:2', 'max:100'],
'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => ['required', Password::default()],
]);

View File

@@ -20,7 +20,6 @@ class ResetPasswordController extends Controller
| explore this trait and override any methods you wish to tweak.
|
*/
use ResetsPasswords;
protected $redirectTo = '/';

View File

@@ -15,19 +15,22 @@ 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;
protected $entityContextManager;
protected BookRepo $bookRepo;
protected ShelfContext $shelfContext;
protected ReferenceFetcher $referenceFetcher;
public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo)
public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo, ReferenceFetcher $referenceFetcher)
{
$this->bookRepo = $bookRepo;
$this->entityContextManager = $entityContextManager;
$this->shelfContext = $entityContextManager;
$this->referenceFetcher = $referenceFetcher;
}
/**
@@ -44,7 +47,7 @@ class BookController extends Controller
$popular = $this->bookRepo->getPopular(4);
$new = $this->bookRepo->getRecentlyCreated(4);
$this->entityContextManager->clearShelfContext();
$this->shelfContext->clearShelfContext();
$this->setPageTitle(trans('entities.books'));
@@ -122,7 +125,7 @@ class BookController extends Controller
View::incrementFor($book);
if ($request->has('shelf')) {
$this->entityContextManager->setShelfContext(intval($request->get('shelf')));
$this->shelfContext->setShelfContext(intval($request->get('shelf')));
}
$this->setPageTitle($book->getShortName());
@@ -133,6 +136,7 @@ class BookController extends Controller
'bookChildren' => $bookChildren,
'bookParentShelves' => $bookParentShelves,
'activity' => $activities->entityActivity($book, 20, 1),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book),
]);
}
@@ -143,7 +147,7 @@ class BookController extends Controller
{
$book = $this->bookRepo->getBySlug($slug);
$this->checkOwnablePermission('book-update', $book);
$this->setPageTitle(trans('entities.books_edit_named', ['bookName'=>$book->getShortName()]));
$this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()]));
return view('books.edit', ['book' => $book, 'current' => $book]);
}

View File

@@ -28,7 +28,7 @@ class BookSortController extends Controller
$bookChildren = (new BookContents($book))->getTree(false);
$this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()]));
$this->setPageTitle(trans('entities.books_sort_named', ['bookName' => $book->getShortName()]));
return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
}

View File

@@ -10,6 +10,7 @@ use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\References\ReferenceFetcher;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -18,11 +19,13 @@ class BookshelfController extends Controller
{
protected BookshelfRepo $shelfRepo;
protected ShelfContext $shelfContext;
protected ReferenceFetcher $referenceFetcher;
public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext)
public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext, ReferenceFetcher $referenceFetcher)
{
$this->shelfRepo = $shelfRepo;
$this->shelfContext = $shelfContext;
$this->referenceFetcher = $referenceFetcher;
}
/**
@@ -124,6 +127,7 @@ class BookshelfController extends Controller
'activity' => $activities->entityActivity($shelf, 20, 1),
'order' => $order,
'sort' => $sort,
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
]);
}

View File

@@ -13,20 +13,20 @@ 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;
protected ChapterRepo $chapterRepo;
protected ReferenceFetcher $referenceFetcher;
/**
* ChapterController constructor.
*/
public function __construct(ChapterRepo $chapterRepo)
public function __construct(ChapterRepo $chapterRepo, ReferenceFetcher $referenceFetcher)
{
$this->chapterRepo = $chapterRepo;
$this->referenceFetcher = $referenceFetcher;
}
/**
@@ -77,13 +77,14 @@ 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(),
'book' => $chapter->book,
'chapter' => $chapter,
'current' => $chapter,
'sidebarTree' => $sidebarTree,
'pages' => $pages,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($chapter),
]);
}

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ 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;
@@ -74,6 +75,24 @@ class MaintenanceController extends Controller
$this->showErrorNotification($errorMessage);
}
return redirect('/settings/maintenance#image-cleanup')->withInput();
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');
}
}

View File

@@ -14,6 +14,7 @@ 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;
@@ -23,13 +24,15 @@ use Throwable;
class PageController extends Controller
{
protected PageRepo $pageRepo;
protected ReferenceFetcher $referenceFetcher;
/**
* PageController constructor.
*/
public function __construct(PageRepo $pageRepo)
public function __construct(PageRepo $pageRepo, ReferenceFetcher $referenceFetcher)
{
$this->pageRepo = $pageRepo;
$this->referenceFetcher = $referenceFetcher;
}
/**
@@ -160,6 +163,7 @@ class PageController extends Controller
'pageNav' => $pageNav,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($page),
]);
}

View File

@@ -2,18 +2,17 @@
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;
protected PageRepo $pageRepo;
/**
* PageRevisionController constructor.
*/
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
@@ -27,11 +26,19 @@ class PageRevisionController extends Controller
public function index(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName'=>$page->getShortName()]));
$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()]));
return view('pages.revisions', [
'page' => $page,
'current' => $page,
'revisions' => $revisions,
'page' => $page,
]);
}
@@ -84,7 +91,7 @@ class PageRevisionController extends Controller
// TODO - Refactor PageContent so we don't need to juggle this
$page->html = $revision->html;
$page->html = (new PageContent($page))->render();
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
return view('pages.revision', [
'page' => $page,
@@ -132,6 +139,7 @@ class PageRevisionController extends Controller
}
$revision->delete();
Activity::add(ActivityType::REVISION_DELETE, $revision);
$this->showSuccessNotification(trans('entities.revision_delete_success'));
return redirect($page->getUrl('/revisions'));

View File

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

View File

@@ -3,10 +3,10 @@
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

View File

@@ -18,8 +18,8 @@ use Illuminate\Validation\ValidationException;
class UserController extends Controller
{
protected $userRepo;
protected $imageRepo;
protected UserRepo $userRepo;
protected ImageRepo $imageRepo;
/**
* UserController constructor.
@@ -81,9 +81,9 @@ class UserController extends Controller
$passwordRequired = ($authMethod === 'standard' && !$sendInvite);
$validationRules = [
'name' => ['required'],
'name' => ['required', 'max:100'],
'email' => ['required', 'email', 'unique:users,email'],
'language' => ['string'],
'language' => ['string', 'max:15', 'alpha_dash'],
'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'],
'name' => ['min:2', 'max:100'],
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
'password' => ['required_with:password_confirm', Password::default()],
'password-confirm' => ['same:password', 'required_with:password'],
'language' => ['string'],
'language' => ['string', 'max:15', 'alpha_dash'],
'roles' => ['array'],
'roles.*' => ['integer'],
'external_auth_id' => ['string'],

View File

@@ -2,59 +2,18 @@
namespace BookStack\Http\Middleware;
use BookStack\Util\LanguageManager;
use Carbon\Carbon;
use Closure;
use Illuminate\Http\Request;
class Localization
{
/**
* Array of right-to-left locales.
*/
protected $rtlLocales = ['ar', 'fa', 'he'];
protected 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',
];
public function __construct(LanguageManager $languageManager)
{
$this->languageManager = $languageManager;
}
/**
* Handle an incoming request.
@@ -66,76 +25,23 @@ 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);
$locale = $this->getUserLocale($request, $defaultLang);
config()->set('app.lang', str_replace('_', '-', $this->getLocaleIso($locale)));
// 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)));
// Set text direction
if (in_array($locale, $this->rtlLocales)) {
if ($this->languageManager->isRTL($userLang)) {
config()->set('app.rtl', true);
}
app()->setLocale($locale);
Carbon::setLocale($locale);
$this->setSystemDateLocale($locale);
app()->setLocale($userLang);
Carbon::setLocale($userLang);
$this->languageManager->setPhpDateTimeLocale($userLang);
return $next($request);
}
/**
* Get the locale specifically for the currently logged in user if available.
*/
protected function getUserLocale(Request $request, string $default): string
{
try {
$user = user();
} catch (\Exception $exception) {
return $default;
}
if ($user->isDefault() && config('app.auto_detect_locale')) {
return $this->autoDetectLocale($request, $default);
}
return setting()->getUser($user, 'language', $default);
}
/**
* Autodetect the visitors locale by matching locales in their headers
* against the locales supported by BookStack.
*/
protected function autoDetectLocale(Request $request, string $default): string
{
$availableLocales = config('app.locales');
foreach ($request->getLanguages() as $lang) {
if (in_array($lang, $availableLocales)) {
return $lang;
}
}
return $default;
}
/**
* Get the ISO version of a BookStack language name.
*/
public function getLocaleIso(string $locale): string
{
return $this->localeMap[$locale] ?? $locale;
}
/**
* Set the system date locale for localized date formatting.
* Will try both the standard locale name and the UTF8 variant.
*/
protected function setSystemDateLocale(string $locale)
{
$systemLocale = $this->getLocaleIso($locale);
$set = setlocale(LC_TIME, $systemLocale);
if ($set === false) {
setlocale(LC_TIME, $systemLocale . '.utf8');
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,11 @@
<?php
namespace BookStack\Entities\Tools;
namespace BookStack\Search;
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;

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Entities\Tools;
namespace BookStack\Search;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Auth\User;
@@ -8,7 +8,6 @@ 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;
@@ -29,7 +28,7 @@ class SearchRunner
*
* @var string[]
*/
protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
protected array $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
/**
* Retain a cache of score adjusted terms for specific search options.
@@ -163,7 +162,7 @@ class SearchRunner
$entityQuery = $entityModelInstance->newQuery()->scopes('visible');
if ($entityModelInstance instanceof Page) {
$entityQuery->select($entityModelInstance::$listAttributes);
$entityQuery->select(array_merge($entityModelInstance::$listAttributes, ['restricted', 'owned_by']));
} else {
$entityQuery->select(['*']);
}

View File

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

View File

@@ -2,6 +2,8 @@
namespace BookStack\Theming;
use BookStack\Entities\Models\Page;
/**
* The ThemeEvents used within BookStack.
*
@@ -60,8 +62,7 @@ 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 it's 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
@@ -69,6 +70,21 @@ 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

View File

@@ -41,7 +41,7 @@ class AttachmentService
// Change to our secure-attachment disk if any of the local options
// are used to prevent escaping that location.
if ($storageType === 'local' || $storageType === 'local_secure') {
if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
$storageType = 'local_secure_attachments';
}

View File

@@ -2,6 +2,9 @@
namespace BookStack\Uploads;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\ImageUploadException;
use ErrorException;
use Exception;
@@ -24,20 +27,15 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
class ImageService
{
protected $imageTool;
protected $cache;
protected ImageManager $imageTool;
protected Cache $cache;
protected $storageUrl;
protected $image;
protected $fileSystem;
protected FilesystemManager $fileSystem;
protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
/**
* ImageService constructor.
*/
public function __construct(Image $image, ImageManager $imageTool, FilesystemManager $fileSystem, Cache $cache)
public function __construct(ImageManager $imageTool, FilesystemManager $fileSystem, Cache $cache)
{
$this->image = $image;
$this->imageTool = $imageTool;
$this->fileSystem = $fileSystem;
$this->cache = $cache;
@@ -55,9 +53,18 @@ class ImageService
* Check if local secure image storage (Fetched behind authentication)
* is currently active in the instance.
*/
protected function usingSecureImages(): bool
protected function usingSecureImages(string $imageType = 'gallery'): bool
{
return $this->getStorageDiskName('gallery') === 'local_secure_images';
return $this->getStorageDiskName($imageType) === 'local_secure_images';
}
/**
* Check if "local secure restricted" (Fetched behind auth, with permissions enforced)
* is currently active in the instance.
*/
protected function usingSecureRestrictedImages()
{
return config('filesystems.images') === 'local_secure_restricted';
}
/**
@@ -68,7 +75,7 @@ class ImageService
{
$path = Util::normalizePath(str_replace('uploads/images/', '', $path));
if ($this->getStorageDiskName($imageType) === 'local_secure_images') {
if ($this->usingSecureImages($imageType)) {
return $path;
}
@@ -87,7 +94,9 @@ class ImageService
$storageType = 'local';
}
if ($storageType === 'local_secure') {
// Rename local_secure options to get our image specific storage driver which
// is scoped to the relevant image directories.
if ($storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
$storageType = 'local_secure_images';
}
@@ -179,8 +188,8 @@ class ImageService
$imageDetails['updated_by'] = $userId;
}
$image = $this->image->newInstance();
$image->forceFill($imageDetails)->save();
$image = (new Image())->forceFill($imageDetails);
$image->save();
return $image;
}
@@ -306,7 +315,7 @@ class ImageService
{
try {
$thumb = $this->imageTool->make($imageData);
} catch (ErrorException|NotSupportedException $e) {
} catch (ErrorException | NotSupportedException $e) {
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
@@ -451,7 +460,7 @@ class ImageService
$types = ['gallery', 'drawio'];
$deletedPaths = [];
$this->image->newQuery()->whereIn('type', $types)
Image::query()->whereIn('type', $types)
->chunk(1000, function ($images) use ($checkRevisions, &$deletedPaths, $dryRun) {
foreach ($images as $image) {
$searchQuery = '%' . basename($image->path) . '%';
@@ -492,6 +501,14 @@ class ImageService
}
$storagePath = $this->adjustPathForStorageDisk($storagePath);
// Apply access control when local_secure_restricted images are active
if ($this->usingSecureRestrictedImages()) {
if (!$this->checkUserHasAccessToRelationOfImageAtPath($storagePath)) {
return null;
}
}
$storage = $this->getStorageDisk();
$imageData = null;
if ($storage->exists($storagePath)) {
@@ -511,14 +528,19 @@ class ImageService
}
/**
* Check if the given path exists in the local secure image system.
* Returns false if local_secure is not in use.
* Check if the given path exists and is accessible in the local secure image system.
* Returns false if local_secure is not in use, if the file does not exist, if the
* file is likely not a valid image, or if permission does not allow access.
*/
public function pathExistsInLocalSecure(string $imagePath): bool
public function pathAccessibleInLocalSecure(string $imagePath): bool
{
/** @var FilesystemAdapter $disk */
$disk = $this->getStorageDisk('gallery');
if ($this->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
return false;
}
// Check local_secure is active
return $this->usingSecureImages()
&& $disk instanceof FilesystemAdapter
@@ -528,6 +550,55 @@ class ImageService
&& strpos($disk->getMimetype($imagePath), 'image/') === 0;
}
/**
* Check that the current user has access to the relation
* of the image at the given path.
*/
protected function checkUserHasAccessToRelationOfImageAtPath(string $path): bool
{
if (strpos($path, '/uploads/images/') === 0) {
$path = substr($path, 15);
}
// Strip thumbnail element from path if existing
$originalPathSplit = array_filter(explode('/', $path), function (string $part) {
$resizedDir = (strpos($part, 'thumbs-') === 0 || strpos($part, 'scaled-') === 0);
$missingExtension = strpos($part, '.') === false;
return !($resizedDir && $missingExtension);
});
// Build a database-format image path and search for the image entry
$fullPath = '/uploads/images/' . ltrim(implode('/', $originalPathSplit), '/');
$image = Image::query()->where('path', '=', $fullPath)->first();
if (is_null($image)) {
return false;
}
$imageType = $image->type;
// Allow user or system (logo) images
// (No specific relation control but may still have access controlled by auth)
if ($imageType === 'user' || $imageType === 'system') {
return true;
}
if ($imageType === 'gallery' || $imageType === 'drawio') {
return Page::visible()->where('id', '=', $image->uploaded_to)->exists();
}
if ($imageType === 'cover_book') {
return Book::visible()->where('id', '=', $image->uploaded_to)->exists();
}
if ($imageType === 'cover_bookshelf') {
return Bookshelf::visible()->where('id', '=', $image->uploaded_to)->exists();
}
return false;
}
/**
* For the given path, if existing, provide a response that will stream the image contents.
*/

View File

@@ -45,6 +45,12 @@ class HtmlContentFilter
$badIframes = $xPath->query('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
static::removeNodes($badIframes);
// Remove attributes, within svg children, hiding JavaScript or data uris.
// A bunch of svg element and attribute combinations expose xss possibilities.
// For example, SVG animate tag can exploit javascript in values.
$badValuesAttrs = $xPath->query('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']');
static::removeAttributes($badValuesAttrs);
// Remove elements with a xlink:href attribute
// Used in SVG but deprecated anyway, so we'll be a bit more heavy-handed here.
$xlinkHrefAttributes = $xPath->query('//@*[contains(name(), \'xlink:href\')]');

View File

@@ -0,0 +1,135 @@
<?php
namespace BookStack\Util;
use Illuminate\Http\Request;
class LanguageManager
{
/**
* Array of right-to-left language options.
*/
protected array $rtlLanguages = ['ar', 'fa', 'he'];
/**
* Map of BookStack language names to best-estimate ISO and windows locale names.
* Locales can often be found by running `locale -a` on a linux system.
* Windows locales can be found at:
* https://docs.microsoft.com/en-us/cpp/c-runtime-library/language-strings?view=msvc-170.
*
* @var array<string, array{iso: string, windows: string}>
*/
protected array $localeMap = [
'ar' => ['iso' => 'ar', 'windows' => 'Arabic'],
'bg' => ['iso' => 'bg_BG', 'windows' => 'Bulgarian'],
'bs' => ['iso' => 'bs_BA', 'windows' => 'Bosnian (Latin)'],
'ca' => ['iso' => 'ca', 'windows' => 'Catalan'],
'da' => ['iso' => 'da_DK', 'windows' => 'Danish'],
'de' => ['iso' => 'de_DE', 'windows' => 'German'],
'de_informal' => ['iso' => 'de_DE', 'windows' => 'German'],
'en' => ['iso' => 'en_GB', 'windows' => 'English'],
'es' => ['iso' => 'es_ES', 'windows' => 'Spanish'],
'es_AR' => ['iso' => 'es_AR', 'windows' => 'Spanish'],
'et' => ['iso' => 'et_EE', 'windows' => 'Estonian'],
'eu' => ['iso' => 'eu_ES', 'windows' => 'Basque'],
'fa' => ['iso' => 'fa_IR', 'windows' => 'Persian'],
'fr' => ['iso' => 'fr_FR', 'windows' => 'French'],
'he' => ['iso' => 'he_IL', 'windows' => 'Hebrew'],
'hr' => ['iso' => 'hr_HR', 'windows' => 'Croatian'],
'hu' => ['iso' => 'hu_HU', 'windows' => 'Hungarian'],
'id' => ['iso' => 'id_ID', 'windows' => 'Indonesian'],
'it' => ['iso' => 'it_IT', 'windows' => 'Italian'],
'ja' => ['iso' => 'ja', 'windows' => 'Japanese'],
'ko' => ['iso' => 'ko_KR', 'windows' => 'Korean'],
'lt' => ['iso' => 'lt_LT', 'windows' => 'Lithuanian'],
'lv' => ['iso' => 'lv_LV', 'windows' => 'Latvian'],
'nl' => ['iso' => 'nl_NL', 'windows' => 'Dutch'],
'nb' => ['iso' => 'nb_NO', 'windows' => 'Norwegian (Bokmal)'],
'pl' => ['iso' => 'pl_PL', 'windows' => 'Polish'],
'pt' => ['iso' => 'pt_PT', 'windows' => 'Portuguese'],
'pt_BR' => ['iso' => 'pt_BR', 'windows' => 'Portuguese'],
'ro' => ['iso' => 'ro_RO', 'windows' => 'Romanian'],
'ru' => ['iso' => 'ru', 'windows' => 'Russian'],
'sk' => ['iso' => 'sk_SK', 'windows' => 'Slovak'],
'sl' => ['iso' => 'sl_SI', 'windows' => 'Slovenian'],
'sv' => ['iso' => 'sv_SE', 'windows' => 'Swedish'],
'uk' => ['iso' => 'uk_UA', 'windows' => 'Ukrainian'],
'vi' => ['iso' => 'vi_VN', 'windows' => 'Vietnamese'],
'zh_CN' => ['iso' => 'zh_CN', 'windows' => 'Chinese (Simplified)'],
'zh_TW' => ['iso' => 'zh_TW', 'windows' => 'Chinese (Traditional)'],
'tr' => ['iso' => 'tr_TR', 'windows' => 'Turkish'],
];
/**
* Get the language specifically for the currently logged-in user if available.
*/
public function getUserLanguage(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);
}
/**
* Check if the given BookStack language value is a right-to-left language.
*/
public function isRTL(string $language): bool
{
return in_array($language, $this->rtlLanguages);
}
/**
* 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 getIsoName(string $language): string
{
return $this->localeMap[$language]['iso'] ?? $language;
}
/**
* Set the system date locale for localized date formatting.
* Will try both the standard locale name and the UTF8 variant.
*/
public function setPhpDateTimeLocale(string $language): void
{
$isoLang = $this->localeMap[$language]['iso'] ?? '';
$isoLangPrefix = explode('_', $isoLang)[0];
$locales = array_filter([
$isoLang ? $isoLang . '.utf8' : false,
$isoLang ?: false,
$isoLang ? str_replace('_', '-', $isoLang) : false,
$isoLang ? $isoLangPrefix . '.UTF-8' : false,
$this->localeMap[$language]['windows'] ?? false,
$language,
]);
if (!empty($locales)) {
setlocale(LC_TIME, ...$locales);
}
}
}

View File

@@ -50,6 +50,7 @@
"nunomaduro/collision": "^5.10",
"nunomaduro/larastan": "^1.0",
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "^3.7",
"ssddanbrown/asserthtml": "^1.0"
},
"autoload": {
@@ -68,6 +69,10 @@
}
},
"scripts": {
"check-static": "phpstan --memory-limit=2g",
"format": "phpcbf",
"lint": "phpcs",
"test": "phpunit",
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"

973
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateReferencesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('references', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('from_id')->index();
$table->string('from_type', 25)->index();
$table->unsignedInteger('to_id')->index();
$table->string('to_type', 25)->index();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('references');
}
}

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
class FixShelfCoverImageTypes extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// This updates the 'type' field for images, uploaded as shelf cover images,
// to be cover_bookshelf instead of cover_book.
// This does not fix their paths, since fixing that requires a more complicated operation,
// but their path does not affect functionality at time of this fix.
$shelfImageIds = DB::table('bookshelves')
->whereNotNull('image_id')
->pluck('image_id')
->values()->all();
if (count($shelfImageIds) > 0) {
DB::table('images')
->where('type', '=', 'cover_book')
->whereIn('id', $shelfImageIds)
->update(['type' => 'cover_bookshelf']);
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
DB::table('images')
->where('type', '=', 'cover_bookshelf')
->update(['type' => 'cover_book']);
}
}

View File

@@ -10,7 +10,7 @@ use BookStack\Auth\User;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\SearchIndex;
use BookStack\Search\SearchIndex;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

View File

@@ -8,7 +8,7 @@ use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\SearchIndex;
use BookStack\Search\SearchIndex;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;

35
phpcs.xml Normal file
View File

@@ -0,0 +1,35 @@
<?xml version="1.0"?>
<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" name="PHP_CodeSniffer" xsi:noNamespaceSchemaLocation="phpcs.xsd">
<description>The coding standard for BookStack</description>
<file>app</file>
<file>bootstrap/app.php</file>
<file>database</file>
<file>public/index.php</file>
<file>routes</file>
<file>tests</file>
<arg name="basepath" value="."/>
<arg name="colors"/>
<arg name="parallel" value="75"/>
<arg value="np"/>
<rule ref="PSR12"/>
<rule ref="PSR1.Methods.CamelCapsMethodName">
<exclude-pattern>./tests/*</exclude-pattern>
</rule>
<rule ref="PSR1.Classes.ClassDeclaration.MultipleClasses">
<exclude-pattern>./tests/*</exclude-pattern>
</rule>
<rule ref="PSR1.Classes.ClassDeclaration.MissingNamespace">
<exclude-pattern>./database/*</exclude-pattern>
</rule>
<rule ref="PSR12.Files.FileHeader.IncorrectOrder">
<exclude-pattern>./app/Config/*</exclude-pattern>
</rule>
</ruleset>

16
public/dist/app.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,8 +3,8 @@
[![GitHub release](https://img.shields.io/github/release/BookStackApp/BookStack.svg)](https://github.com/BookStackApp/BookStack/releases/latest)
[![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/development/LICENSE)
[![Crowdin](https://badges.crowdin.net/bookstack/localized.svg)](https://crowdin.com/project/bookstack)
[![Build Status](https://github.com/BookStackApp/BookStack/workflows/phpunit/badge.svg)](https://github.com/BookStackApp/BookStack/actions)
[![StyleCI](https://github.styleci.io/repos/41589337/shield?style=flat)](https://github.styleci.io/repos/41589337)
[![Build Status](https://github.com/BookStackApp/BookStack/workflows/test-php/badge.svg)](https://github.com/BookStackApp/BookStack/actions)
[![Lint Status](https://github.com/BookStackApp/BookStack/workflows/lint-php/badge.svg)](https://github.com/BookStackApp/BookStack/actions)
[![Maintainability](https://api.codeclimate.com/v1/badges/5551731994dd22fa1f4f/maintainability)](https://codeclimate.com/github/BookStackApp/BookStack/maintainability)
[![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/)
@@ -64,7 +64,7 @@ Below is a high-level road map view for BookStack to provide a sense of directio
- **Platform REST API** - *(Most actions implemented, maturing)*
- *A REST API covering, at minimum, control of core content models (Books, Chapters, Pages) for automation and platform extension.*
- **Editor Alignment & Review** - *(Done)*
- *Review the page editors with goal of achieving increased interoperability & feature parity while also considering collaborative editing potential.*
- *Review the page editors with the goal of achieving increased interoperability & feature parity while also considering collaborative editing potential.*
- **Permission System Review** - *(In Progress)*
- *Improvement in how permissions are applied and a review of the efficiency of the permission & roles system.*
- **Installation & Deployment Process Revamp**
@@ -77,12 +77,12 @@ BookStack releases are each assigned a date-based version number in the format `
- `v20.12` - New feature released launched during December 2020.
- `v21.06.2` - Second patch release upon the June 2021 feature release.
Patch releases are generally fairly minor, primarily intended for fixes and therefore is fairly unlikely to cause breakages upon update.
Patch releases are generally fairly minor, primarily intended for fixes and therefore are fairly unlikely to cause breakages upon update.
Feature releases are generally larger, bringing new features in addition to fixes and enhancements. These releases have a greater chance of introducing breaking changes upon update, so it's worth checking for any notes in the [update guide](https://www.bookstackapp.com/docs/admin/updates/).
Each BookStack release will have a [milestone](https://github.com/BookStackApp/BookStack/milestones) created with issues & pull requests assigned to it to define what will be in that release. Milestones are built up then worked through until complete at which point, after some testing and documentation updates, the release will be deployed.
Feature releases, and some patch releases, will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blogs posts (once per week maximum) [at this link](https://updates.bookstackapp.com/signup/bookstack-news-and-updates).
Feature releases, and some patch releases, will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blog posts (once per week maximum) [at this link](https://updates.bookstackapp.com/signup/bookstack-news-and-updates).
## 🛠️ Development & Testing
@@ -115,12 +115,29 @@ php artisan migrate --database=mysql_testing
php artisan db:seed --class=DummyContentSeeder --database=mysql_testing
```
Once done you can run `php vendor/bin/phpunit` in the application root directory to run all tests.
Once done you can run `composer test` in the application root directory to run all tests.
### 📜 Code Standards
PHP code style is enforced automatically [using StyleCI](https://github.styleci.io/repos/41589337).
If submitting a PR, any formatting changes to be made will be automatically fixed after merging.
PHP code standards are managed by [using PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer).
Static analysis is in place using [PHPStan](https://phpstan.org/) & [Larastan](https://github.com/nunomaduro/larastan).
The below commands can be used to utilise these tools:
```bash
# Run code linting using PHP_CodeSniffer
composer lint
# As above, but show rule names in output
composer lint -- -s
# Auto-fix formatting & lint issues via PHP_CodeSniffer phpcbf
composer format
# Run static analysis via larastan/phpstan
composer check-static
```
If submitting a PR, formatting as per our project standards would help for clarity but don't worry too much about using/understanding these tools as we can always address issues at a later stage when they're picked up by our automated tools.
### 🐋 Development using Docker
@@ -170,9 +187,9 @@ NB : For some editors like Visual Studio Code, you might need to map your worksp
## 🌎 Translations
Translations for text within BookStack is managed through the [BookStack project on Crowdin](https://crowdin.com/project/bookstack). Some strings have colon-prefixed variables in such as `:userName`. Leave these values as they are as they will be replaced at run-time. Crowdin is the preferred way to provide translations, otherwise the raw translations files can be found within the `resources/lang` path.
Translations for text within BookStack is managed through the [BookStack project on Crowdin](https://crowdin.com/project/bookstack). Some strings have colon-prefixed variables such as `:userName`. Leave these values as they are as they will be replaced at run-time. Crowdin is the preferred way to provide translations, otherwise the raw translations files can be found within the `resources/lang` path.
If you'd like a new language to be added to Crowdin, for you to be able to provide translations for, please [open a new issue here](https://github.com/BookStackApp/BookStack/issues/new?template=language_request.md).
If you'd like a new language to be added to Crowdin, for you to be able to provide translations for, please [open a new issue here](https://github.com/BookStackApp/BookStack/issues/new?template=language_request.yml).
Please note, translations in BookStack are provided to the "Crowdin Global Translation Memory" which helps BookStack and other projects with finding translations. If you are not happy with contributing to this then providing translations to BookStack, even manually via GitHub, is not advised.
@@ -180,7 +197,7 @@ Please note, translations in BookStack are provided to the "Crowdin Global Trans
Feel free to create issues to request new features or to report bugs & problems. Just please follow the template given when creating the issue.
Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit in to the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project.
Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit into the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project.
Pull requests should be created from the `development` branch since they will be merged back into `development` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
@@ -234,9 +251,9 @@ Note: This is not an exhaustive list of all libraries and projects that would be
* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml) - _[MIT](https://github.com/onelogin/php-saml/blob/master/LICENSE)_
* [League/CommonMark](https://commonmark.thephpleague.com/) - _[BSD-3-Clause](https://github.com/thephpleague/commonmark/blob/2.2/LICENSE)_
* [League/Flysystem](https://flysystem.thephpleague.com) - _[MIT](https://github.com/thephpleague/flysystem/blob/3.x/LICENSE)_
* [StyleCI](https://styleci.io/) - _Hosted Service_
* [pragmarx/google2fa](https://github.com/antonioribeiro/google2fa) - _[MIT](https://github.com/antonioribeiro/google2fa/blob/8.x/LICENSE.md)_
* [Bacon/BaconQrCode](https://github.com/Bacon/BaconQrCode) - _[BSD-2-Clause](https://github.com/Bacon/BaconQrCode/blob/master/LICENSE)_
* [phpseclib](https://github.com/phpseclib/phpseclib) - _[MIT](https://github.com/phpseclib/phpseclib/blob/master/LICENSE)_
* [Clockwork](https://github.com/itsgoingd/clockwork) - _[MIT](https://github.com/itsgoingd/clockwork/blob/master/LICENSE)_
* [PHPStan](https://phpstan.org/) & [Larastan](https://github.com/nunomaduro/larastan) - _[MIT](https://github.com/phpstan/phpstan/blob/master/LICENSE) and [MIT](https://github.com/nunomaduro/larastan/blob/master/LICENSE.md)_
* [PHPStan](https://phpstan.org/) & [Larastan](https://github.com/nunomaduro/larastan) - _[MIT](https://github.com/phpstan/phpstan/blob/master/LICENSE) and [MIT](https://github.com/nunomaduro/larastan/blob/master/LICENSE.md)_
* [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) - _[BSD 3-Clause](https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt)_

View File

@@ -1,4 +1,3 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 431 B

After

Width:  |  Height:  |  Size: 389 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path d="m24 44.15c-0.4 0-0.79167-0.06667-1.175-0.2-0.38333-0.13333-0.70833-0.33333-0.975-0.6l-17.2-17.2c-0.26667-0.26667-0.46667-0.59167-0.6-0.975s-0.2-0.775-0.2-1.175 0.066667-0.79167 0.2-1.175c0.13333-0.38333 0.33333-0.70833 0.6-0.975l17.2-17.2c0.26667-0.26667 0.59167-0.46667 0.975-0.6s0.775-0.2 1.175-0.2 0.79167 0.066667 1.175 0.2c0.38333 0.13333 0.70833 0.33333 0.975 0.6l17.2 17.2c0.26667 0.26667 0.46667 0.59167 0.6 0.975s0.2 0.775 0.2 1.175-0.06667 0.79167-0.2 1.175c-0.13333 0.38333-0.33333 0.70833-0.6 0.975l-17.2 17.2c-0.26667 0.26667-0.59167 0.46667-0.975 0.6s-0.775 0.2-1.175 0.2zm4.05-18.65-5.15 5.15c-0.3 0.3-0.44167 0.65-0.425 1.05 0.01667 0.4 0.175 0.75 0.475 1.05s0.65833 0.45 1.075 0.45 0.775-0.15 1.075-0.45l7.7-7.7c0.3-0.3 0.45-0.65 0.45-1.05s-0.15-0.75-0.45-1.05l-7.75-7.75c-0.3-0.3-0.65-0.45-1.05-0.45s-0.75 0.15-1.05 0.45-0.45 0.65833-0.45 1.075 0.15 0.775 0.45 1.075l5.1 5.15h-12.4c-0.43333 0-0.79167 0.14167-1.075 0.425s-0.425 0.64167-0.425 1.075 0.14167 0.79167 0.425 1.075 0.64167 0.425 1.075 0.425z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -37,12 +37,8 @@ class DropDown {
if (this.moveMenu) {
this.body.appendChild(this.menu);
this.menu.style.position = 'fixed';
if (this.direction === 'right') {
this.menu.style.right = `${(menuOriginalRect.right - menuOriginalRect.width)}px`;
} else {
this.menu.style.left = `${menuOriginalRect.left}px`;
}
this.menu.style.width = `${menuOriginalRect.width}px`;
this.menu.style.left = `${menuOriginalRect.left}px`;
heightOffset = dropUpwards ? (window.innerHeight - menuOriginalRect.top - toggleHeight / 2) : menuOriginalRect.top;
}
@@ -94,6 +90,7 @@ class DropDown {
this.menu.style.position = '';
this.menu.style[this.direction] = '';
this.menu.style.width = '';
this.menu.style.left = '';
this.container.appendChild(this.menu);
}

View File

@@ -111,10 +111,8 @@ function defineCodeBlockCustomElement(editor) {
const container = this.shadowRoot.querySelector('.CodeMirrorContainer');
const renderCodeMirror = (Code) => {
this.cm = Code.wysiwygView(container, content, this.getLanguage());
Code.updateLayout(this.cm);
setTimeout(() => {
this.style.height = null;
}, 1);
setTimeout(() => Code.updateLayout(this.cm), 10);
setTimeout(() => this.style.height = null, 12);
};
window.importVersioned('code').then((Code) => {

View File

@@ -18,11 +18,13 @@ function showDrawingManager(mceEditor, selectedNode = null) {
// Show image manager
window.ImageManager.show(function (image) {
if (selectedNode) {
let imgElem = selectedNode.querySelector('img');
pageEditor.dom.setAttrib(imgElem, 'src', image.url);
pageEditor.dom.setAttrib(selectedNode, 'drawio-diagram', image.id);
const imgElem = selectedNode.querySelector('img');
pageEditor.undoManager.transact(function () {
pageEditor.dom.setAttrib(imgElem, 'src', image.url);
pageEditor.dom.setAttrib(selectedNode, 'drawio-diagram', image.id);
});
} else {
let imgHTML = `<div drawio-diagram="${image.id}" contenteditable="false"><img src="${image.url}"></div>`;
const imgHTML = `<div drawio-diagram="${image.id}" contenteditable="false"><img src="${image.url}"></div>`;
pageEditor.insertContent(imgHTML);
}
}, 'drawio');
@@ -53,8 +55,10 @@ async function updateContent(pngData) {
let imgElem = currentNode.querySelector('img');
try {
const img = await DrawIO.upload(pngData, options.pageId);
pageEditor.dom.setAttrib(imgElem, 'src', img.url);
pageEditor.dom.setAttrib(currentNode, 'drawio-diagram', img.id);
pageEditor.undoManager.transact(function () {
pageEditor.dom.setAttrib(imgElem, 'src', img.url);
pageEditor.dom.setAttrib(currentNode, 'drawio-diagram', img.id);
});
} catch (err) {
handleUploadError(err);
}
@@ -66,8 +70,10 @@ async function updateContent(pngData) {
DrawIO.close();
try {
const img = await DrawIO.upload(pngData, options.pageId);
pageEditor.dom.setAttrib(id, 'src', img.url);
pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', img.id);
pageEditor.undoManager.transact(function () {
pageEditor.dom.setAttrib(id, 'src', img.url);
pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', img.id);
});
} catch (err) {
pageEditor.dom.remove(id);
handleUploadError(err);

View File

@@ -28,8 +28,8 @@ return [
// Books
'book_create' => 'تم إنشاء كتاب',
'book_create_notification' => 'تم إنشاء الكتاب بنجاح',
'book_create_from_chapter' => 'converted chapter to book',
'book_create_from_chapter_notification' => 'Chapter successfully converted to a book',
'book_create_from_chapter' => 'تم تحويل الفصل إلى كتاب',
'book_create_from_chapter_notification' => 'تم تحويل الفصل إلى كتاب بنجاح',
'book_update' => 'تم تحديث الكتاب',
'book_update_notification' => 'تم تحديث الكتاب بنجاح',
'book_delete' => 'تم حذف الكتاب',
@@ -38,13 +38,13 @@ return [
'book_sort_notification' => 'تم إعادة فرز الكتاب بنجاح',
// Bookshelves
'bookshelf_create' => 'تم إنشاء رف كتب',
'bookshelf_create_notification' => 'تم إنشاء الرف بنجاح',
'bookshelf_create_from_book' => 'converted book to bookshelf',
'bookshelf_create_from_book_notification' => 'Book successfully converted to a shelf',
'bookshelf_create' => 'تم إنشاء رف',
'bookshelf_create_notification' => 'تم إنشاء رف بنجاح',
'bookshelf_create_from_book' => 'تم تحويل الكتاب إلى رف',
'bookshelf_create_from_book_notification' => 'تم تحويل الكتاب إلى رف بنجاح',
'bookshelf_update' => 'تم تحديث الرف',
'bookshelf_update_notification' => 'تم تحديث الرف بنجاح',
'bookshelf_delete' => 'تم تحديث الرف',
'bookshelf_delete' => 'تم حذف الرف',
'bookshelf_delete_notification' => 'تم حذف الرف بنجاح',
// Favourites

View File

@@ -21,7 +21,7 @@ return [
'email' => 'البريد الإلكتروني',
'password' => 'كلمة المرور',
'password_confirm' => 'تأكيد كلمة المرور',
'password_hint' => 'Must be at least 8 characters',
'password_hint' => 'يجب أن تحتوي كلمة المرور على 8 خانات على الأقل',
'forgot_password' => 'نسيت كلمة المرور؟',
'remember_me' => 'تذكرني',
'ldap_email_hint' => 'الرجاء إدخال عنوان بريد إلكتروني لاستخدامه مع الحساب.',
@@ -41,7 +41,7 @@ return [
// Login auto-initiation
'auto_init_starting' => 'Attempting Login',
'auto_init_starting_desc' => 'We\'re contacting your authentication system to start the login process. If there\'s no progress after 5 seconds you can try clicking the link below.',
'auto_init_start_link' => 'Proceed with authentication',
'auto_init_start_link' => 'المتابعة مع المصادقة',
// Password Reset
'reset_password' => 'استعادة كلمة المرور',
@@ -86,7 +86,7 @@ return [
'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
'mfa_setup_action' => 'إعداد (تنصيب)',
'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
'mfa_option_totp_title' => 'Mobile App',
'mfa_option_totp_title' => 'تطبيق الجوال',
'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
'mfa_option_backup_codes_title' => 'Backup Codes',
'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',

View File

@@ -23,6 +23,7 @@ return [
'meta_updated' => 'مُحدث :timeLength',
'meta_updated_name' => 'مُحدث :timeLength بواسطة :user',
'meta_owned_name' => 'Owned by :user',
'meta_reference_page_count' => 'Referenced on 1 page|Referenced on :count pages',
'entity_select' => 'اختيار الكيان',
'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item',
'images' => 'صور',
@@ -77,7 +78,6 @@ return [
'shelf' => 'رف',
'shelves' => 'الأرفف',
'x_shelves' => ':count رف|:count أرفف',
'shelves_long' => 'أرفف الكتب',
'shelves_empty' => 'لم ينشأ أي رف',
'shelves_create' => 'إنشاء رف جديد',
'shelves_popular' => 'أرفف رائجة',
@@ -91,20 +91,20 @@ return [
'shelves_drag_books' => 'Drag books below to add them to this shelf',
'shelves_empty_contents' => 'لا توجد كتب مخصصة لهذا الرف',
'shelves_edit_and_assign' => 'تحرير الرف لإدراج كتب',
'shelves_edit_named' => 'تحرير رف الكتب :name',
'shelves_edit' => 'تحرير رف الكتب',
'shelves_delete' => 'حذف رف الكتب',
'shelves_delete_named' => 'حذف رف الكتب :name',
'shelves_delete_explain' => "سيؤدي هذا إلى حذف رف الكتب المسمى ':name'، ولن تحذف الكتب المتضمنة فيه.",
'shelves_delete_confirmation' => 'هل أنت متأكد من أنك تريد حذف هذا الرف؟',
'shelves_permissions' => 'أذونات رف الكتب',
'shelves_permissions_updated' => 'تم تحديث أذونات رف الكتب',
'shelves_permissions_active' => 'أذونات رف الكتب نشطة',
'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
'shelves_edit_named' => 'Edit Shelf :name',
'shelves_edit' => 'Edit Shelf',
'shelves_delete' => 'Delete Shelf',
'shelves_delete_named' => 'Delete Shelf :name',
'shelves_delete_explain' => "This will delete the shelf with the name ':name'. Contained books will not be deleted.",
'shelves_delete_confirmation' => 'Are you sure you want to delete this shelf?',
'shelves_permissions' => 'Shelf Permissions',
'shelves_permissions_updated' => 'Shelf Permissions Updated',
'shelves_permissions_active' => 'Shelf Permissions Active',
'shelves_permissions_cascade_warning' => 'Permissions on shelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
'shelves_copy_permissions_to_books' => 'نسخ أذونات الوصول إلى الكتب',
'shelves_copy_permissions' => 'نسخ الأذونات',
'shelves_copy_permissions_explain' => 'سيؤدي هذا إلى تطبيق إعدادات الأذونات الحالية لهذا الرف على جميع الكتب المتضمنة فيه. قبل التفعيل، تأكد من حفظ أي تغييرات في أذونات هذا الرف.',
'shelves_copy_permission_success' => 'تم نسخ أذونات رف الكتب إلى :count books',
'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.',
'shelves_copy_permission_success' => 'Shelf permissions copied to :count books',
// Books
'book' => 'كتاب',
@@ -248,6 +248,7 @@ return [
'pages_edit_content_link' => 'تعديل المحتوى',
'pages_permissions_active' => 'أذونات الصفحة مفعلة',
'pages_initial_revision' => 'نشر مبدئي',
'pages_references_update_revision' => 'System auto-update of internal links',
'pages_initial_name' => 'صفحة جديدة',
'pages_editing_draft_notification' => 'جارٍ تعديل مسودة لم يتم حفظها من :timeDiff.',
'pages_draft_edited_notification' => 'تم تحديث هذه الصفحة منذ ذلك الوقت. من الأفضل التخلص من هذه المسودة.',
@@ -369,4 +370,9 @@ return [
'convert_to_book_desc' => 'You can convert this chapter to a new book with the same contents. Any permissions set on this chapter will be copied to the new book but any inherited permissions, from the parent book, will not be copied which could lead to a change of access control.',
'convert_chapter' => 'Convert Chapter',
'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?',
// References
'references' => 'References',
'references_none' => 'There are no tracked references to this item.',
'references_to_desc' => 'Shown below are all the known pages in the system that link to this item.',
];

View File

@@ -58,7 +58,7 @@ return [
// Entities
'entity_not_found' => 'الكيان غير موجود',
'bookshelf_not_found' => 'رف الكتب غير موجود',
'bookshelf_not_found' => 'Shelf not found',
'book_not_found' => 'لم يتم العثور على الكتاب',
'page_not_found' => 'لم يتم العثور على الصفحة',
'chapter_not_found' => 'لم يتم العثور على الفصل',

View File

@@ -89,6 +89,10 @@ return [
'maint_send_test_email_mail_text' => 'تهانينا! كما تلقيت إشعار هذا البريد الإلكتروني، يبدو أن إعدادات البريد الإلكتروني الخاص بك قد تم تكوينها بشكل صحيح.',
'maint_recycle_bin_desc' => 'تُرسل الأرفف والكتب والفصول والصفحات المحذوفة إلى سلة المحذوفات حتى يمكن استعادتها أو حذفها نهائيًا. قد يتم إزالة العناصر الأقدم في سلة المحذوفات تلقائيًا بعد فترة اعتمادًا على تكوين النظام.',
'maint_recycle_bin_open' => 'افتح سلة المحذوفات',
'maint_regen_references' => 'Regenerate References',
'maint_regen_references_desc' => 'This action will rebuild the cross-item reference index within the database. This is usually handled automatically but this action can be useful to index old content or content added via unofficial methods.',
'maint_regen_references_success' => 'Reference index has been regenerated!',
'maint_timeout_command_note' => 'Note: This action can take time to run, which can lead to timeout issues in some web environments. As an alternative, this action be performed using a terminal command.',
// Recycle Bin
'recycle_bin' => 'سلة المحذوفات',
@@ -157,6 +161,7 @@ return [
'roles_system_warning' => 'اعلم أن الوصول إلى أي من الأذونات الثلاثة المذكورة أعلاه يمكن أن يسمح للمستخدم بتغيير امتيازاته الخاصة أو امتيازات الآخرين في النظام. قم بتعيين الأدوار مع هذه الأذونات فقط للمستخدمين الموثوق بهم.',
'role_asset_desc' => 'تتحكم هذه الأذونات في الوصول الافتراضي إلى الأصول داخل النظام. ستتجاوز الأذونات الخاصة بالكتب والفصول والصفحات هذه الأذونات.',
'role_asset_admins' => 'يُمنح المسؤولين حق الوصول تلقائيًا إلى جميع المحتويات ولكن هذه الخيارات قد تعرض خيارات واجهة المستخدم أو تخفيها.',
'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.',
'role_all' => 'الكل',
'role_own' => 'ما يخص',
'role_controlled_by_asset' => 'يتحكم فيها الأصول التي يتم رفعها إلى',
@@ -295,6 +300,7 @@ return [
'pl' => 'Polski',
'pt' => 'Português',
'pt_BR' => 'Português do Brasil',
'ro' => 'Română',
'ru' => 'Русский',
'sk' => 'Slovensky',
'sl' => 'Slovenščina',

View File

@@ -38,14 +38,14 @@ return [
'book_sort_notification' => 'Книгата е преподредена успешно',
// Bookshelves
'bookshelf_create' => 'създаден рафт',
'bookshelf_create_notification' => 'Рафтът е създаден успешно',
'bookshelf_create_from_book' => 'converted book to bookshelf',
'bookshelf_create' => 'created shelf',
'bookshelf_create_notification' => 'Shelf successfully created',
'bookshelf_create_from_book' => 'converted book to shelf',
'bookshelf_create_from_book_notification' => 'Book successfully converted to a shelf',
'bookshelf_update' => 'обновен рафт',
'bookshelf_update_notification' => 'Рафтът е обновен успешно',
'bookshelf_delete' => 'изтрит рафт',
'bookshelf_delete_notification' => 'Рафтът е изтрит успешно',
'bookshelf_update' => 'updated shelf',
'bookshelf_update_notification' => 'Shelf successfully updated',
'bookshelf_delete' => 'deleted shelf',
'bookshelf_delete_notification' => 'Shelf successfully deleted',
// Favourites
'favourite_add_notification' => '":name" е добавен към любими успешно',

View File

@@ -23,6 +23,7 @@ return [
'meta_updated' => 'Актуализирано :timeLength',
'meta_updated_name' => 'Актуализирано преди :timeLength от :user',
'meta_owned_name' => 'Притежавано от :user',
'meta_reference_page_count' => 'Referenced on 1 page|Referenced on :count pages',
'entity_select' => 'Избор на обект',
'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item',
'images' => 'Изображения',
@@ -77,7 +78,6 @@ return [
'shelf' => 'Рафт',
'shelves' => 'Рафтове',
'x_shelves' => ':count Рафт|:count Рафтове',
'shelves_long' => 'Рафтове с книги',
'shelves_empty' => 'Няма създадени рафтове',
'shelves_create' => 'Създай нов рафт',
'shelves_popular' => 'Популярни рафтове',
@@ -91,20 +91,20 @@ return [
'shelves_drag_books' => 'Drag books below to add them to this shelf',
'shelves_empty_contents' => 'Този рафт няма добавени книги',
'shelves_edit_and_assign' => 'Редактирай рафта за да добавиш книги',
'shelves_edit_named' => 'Редактирай рафт с книги :name',
'shelves_edit' => 'Редактирай рафт с книги',
'shelves_delete' => 'Изтрий рафт с книги',
'shelves_delete_named' => 'Изтрий рафт с книги :name',
'shelves_delete_explain' => "Ще бъде изтрит рафта с книги със следното име ':name'. Съдържащите се книги няма да бъдат изтрити.",
'shelves_delete_confirmation' => 'Сигурни ли сте, че искате да изтриете този рафт с книги?',
'shelves_permissions' => 'Настройки за достъп до рафта с книги',
'shelves_permissions_updated' => 'Настройките за достъп до рафта с книги е обновен',
'shelves_permissions_active' => 'Настройките за достъп до рафта с книги е активен',
'shelves_permissions_cascade_warning' => 'Привилегиите на рафтовете не се разпространяват автоматично към съдържаните в тях книги. Това е така, защото една книга може да съществува на няколко различни рафта. Въпреки това, привилегиите могат да бъдат копирани до книгите вътре чрез опцията отдолу.',
'shelves_edit_named' => 'Edit Shelf :name',
'shelves_edit' => 'Edit Shelf',
'shelves_delete' => 'Delete Shelf',
'shelves_delete_named' => 'Delete Shelf :name',
'shelves_delete_explain' => "This will delete the shelf with the name ':name'. Contained books will not be deleted.",
'shelves_delete_confirmation' => 'Are you sure you want to delete this shelf?',
'shelves_permissions' => 'Shelf Permissions',
'shelves_permissions_updated' => 'Shelf Permissions Updated',
'shelves_permissions_active' => 'Shelf Permissions Active',
'shelves_permissions_cascade_warning' => 'Permissions on shelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
'shelves_copy_permissions_to_books' => 'Копирай настойките за достъп към книгите',
'shelves_copy_permissions' => 'Копирай настройките за достъп',
'shelves_copy_permissions_explain' => 'Това ще приложи настоящите настройки за достъп на този рафт с книги за всички книги, съдържащи се в него. Преди да активирате, уверете се, че всички промени в настройките за достъп на този рафт са запазени.',
'shelves_copy_permission_success' => 'Настройките за достъп на рафта с книги бяха копирани върху :count books',
'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.',
'shelves_copy_permission_success' => 'Shelf permissions copied to :count books',
// Books
'book' => 'Книга',
@@ -248,6 +248,7 @@ return [
'pages_edit_content_link' => 'Редактиране на съдържанието',
'pages_permissions_active' => 'Настройките за достъп до страницата са активни',
'pages_initial_revision' => 'Първо публикуване',
'pages_references_update_revision' => 'System auto-update of internal links',
'pages_initial_name' => 'Нова страница',
'pages_editing_draft_notification' => 'В момента редактирате чернова, която беше последно обновена :timeDiff.',
'pages_draft_edited_notification' => 'Тази страница беше актуализирана от тогава. Препоръчително е да изтриете настоящата чернова.',
@@ -369,4 +370,9 @@ return [
'convert_to_book_desc' => 'You can convert this chapter to a new book with the same contents. Any permissions set on this chapter will be copied to the new book but any inherited permissions, from the parent book, will not be copied which could lead to a change of access control.',
'convert_chapter' => 'Convert Chapter',
'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?',
// References
'references' => 'References',
'references_none' => 'There are no tracked references to this item.',
'references_to_desc' => 'Shown below are all the known pages in the system that link to this item.',
];

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