Compare commits

...

98 Commits

Author SHA1 Message Date
Dan Brown
98315f3899 Updated version and assets for release v23.02 2023-02-26 11:03:49 +00:00
Dan Brown
8c82aaabd6 Merge branch 'development' into release 2023-02-26 11:02:56 +00:00
Dan Brown
c7e33d1981 Fixed caching issue when running tests 2023-02-26 10:50:14 +00:00
Dan Brown
ba21b54195 Updated translations with latest Crowdin changes (#4025) 2023-02-26 10:36:15 +00:00
Dan Brown
f35c42b0b8 Updated php deps and translaters in prep for v23.02 2023-02-25 17:35:21 +00:00
Dan Brown
b88b1bef2c Added updated_at index to pages table
This has a large impact on some areas where latest updated pages are
shown, such as the homepage for example.
2023-02-23 23:06:12 +00:00
Dan Brown
8abb41abbd Added caching to the loading of system roles
Admin system role was being loaded for each permission check performed.
This caches the fetching for the request lifetime.
2023-02-23 23:01:03 +00:00
Dan Brown
a031edec16 Fixed old deprecated encoding convert on HTML doc load 2023-02-23 22:59:26 +00:00
Dan Brown
2724b2867b Merge pull request #4062 from BookStackApp/settings_perf
Changed the way settings are loaded
2023-02-23 22:22:32 +00:00
Dan Brown
8bebea4cca Changed the way settings are loaded
This new method batch-loads them from the database, and removes the
cache-layer with the intention that a couple of batch fetches from the
DB is more efficient than hitting the cache each time.
2023-02-23 22:14:47 +00:00
Dan Brown
6545afacd6 Changed autosave handling for better editor performance
This changes how the editors interact with the parent page-editor
compontent, which handles auto-saving.
Instead of blasting the full editor content upon any change to that
parent compontent, the editors just alert of a change, without the
content. The parent compontent then requests the editor content from the
editor component when it needs that data for an autosave.

For #3981
2023-02-23 12:30:27 +00:00
Dan Brown
31495758a9 Made page-save HTML formatting much more efficient
Replaced the existing xpath-heavy system with a more manual traversal
approach. Fixes following slow areas of old system:
- Old system would repeat ID-setting action for elements (Headers could
  be processed up to three times).
- Old system had a few very open xpath queries for headers.
- Old system would update links on every ID change, which triggers it's
  own xpath query for links, leading to exponential scaling issues.

New system only does one xpath query for links when changes are needed.
Added test to cover.

For #3932
2023-02-22 14:32:40 +00:00
Dan Brown
c80396136f Increased attachment link limit from 192 to 2k
Added test to cover.
Did attempt a 64k limit, but values over 2k significantly increase
chance of other issues since this URL may be used in redirect headers.
Would rather catch issues in-app.

For #4044
2023-02-20 13:05:23 +00:00
Dan Brown
8da3e64039 Updated language files to remove literal "1" values
This is to encourge the ":count" values to be used instead of 1s in the
translated variants so that non-pluralised languages are hardcoded with
"1"s in their content, even when not used in a singular context.

For #4040
2023-02-20 12:05:52 +00:00
Dan Brown
c1167f8821 Merge pull request #4051 from BookStackApp/roles_api
User Roles API Endpoint
2023-02-19 16:11:30 +00:00
Dan Brown
4176b598ce Fixed unselectable checkbox role form options 2023-02-19 16:03:50 +00:00
Dan Brown
950c02e996 Added role API responses & requests
Also applied other slight tweaks and comment updates based upon manual
endpoint testing.
2023-02-19 15:58:29 +00:00
Dan Brown
9502f349a2 Updated test to have reliable check ordering 2023-02-18 19:01:38 +00:00
Dan Brown
3c3c2ae9b5 Set order to role permissions API response 2023-02-18 18:50:01 +00:00
Dan Brown
723f108bd9 Aded roles API controller methods
Altered & updated permissions repo, and existing connected
RoleController to suit.
Also extracts in-app success notifications to auto activity system.
Tweaked tests where required.
2023-02-18 18:36:34 +00:00
Dan Brown
55456a57d6 Added tests for not-yet-built role API endpoints 2023-02-18 13:51:18 +00:00
Dan Brown
fd45d280b4 Updated tinymce from 6.1.0 to 6.3.1 2023-02-17 21:16:42 +00:00
Dan Brown
524adce654 Merge pull request #4049 from BookStackApp/shelf_book_sort_updates
Shelf book sort improvements
2023-02-17 16:20:59 +00:00
Dan Brown
f799c9b260 Applied shelf book sort changes from testing
Added better labelling of sort lists for screen readers.
Fadded out sort-item action buttons until hovering for a cleaner look.
2023-02-17 16:18:24 +00:00
Dan Brown
9c26ccf43d Added shelf book item sort action functionality
Adds JS logic, and dropdown action list, for quick-sorting the book
shelf list in addition to handling the book item action buttons.
2023-02-17 15:53:24 +00:00
Dan Brown
71a09bcf6e Started accessible controls for shelf book sort
Added buttons and fit to design.
Added new icon variations to support.
Extracted book item to own view and setup for future auto sorts.
2023-02-17 15:05:28 +00:00
Dan Brown
af31a6fc1b Made sendmail command configurable
For #4001
Added simple test to cover config option.
2023-02-17 14:25:38 +00:00
Dan Brown
08b39500b3 Fixed gallery images not visible until draft publish
For #4028
2023-02-16 17:57:34 +00:00
Dan Brown
f9fcc9f3c7 Updated php deps 2023-02-16 17:27:09 +00:00
Dan Brown
0812184995 Added torutec as sponsor, updated license and version 2023-02-14 16:16:08 +00:00
Dan Brown
646f8f60c0 Merge pull request #4032 from BookStackApp/favicon
Generate favicon.ico file
2023-02-09 21:37:38 +00:00
Dan Brown
f333db8e4f Added control-upon-access of the default favicon.ico file 2023-02-09 21:16:27 +00:00
Dan Brown
da42fc7457 Added default favicon creation upon access. 2023-02-09 20:57:35 +00:00
Dan Brown
48f1934387 Updated favicon gen to use png-based ICO
From testing, worked on Firefox, Chrome, Gnome Web
2023-02-09 17:47:33 +00:00
Dan Brown
2845e0003e Got favicons better supported, can't get transparency right
Digging deeper, I don't think PHPGD supports 32bit bmp output which
complicates matters.
2023-02-09 15:14:41 +00:00
Dan Brown
1a189640f1 Integrated favicon handler with correct files & actions
Format does not look 100% correct though, won't show in Firefox/gimp.
2023-02-09 13:24:43 +00:00
Dan Brown
420f89af99 Built custom favicon.ico file creator
Followed wikipedia-defined ICO file format info, and used with
Intervention's good bmp support, to create a working proof-of-concept.
2023-02-08 23:06:42 +00:00
Dan Brown
da1a66abd3 Extracted test file handling to its own class
Closes #3995
2023-02-08 14:39:13 +00:00
Dan Brown
5d18e7df79 Removed deprecated syntax in old migration file 2023-02-08 13:20:00 +00:00
Dan Brown
ba25a3e1b7 Merge pull request #4021 from BookStackApp/laravel9
Upgrade framework to Laravel 9
2023-02-07 12:11:04 +00:00
Dan Brown
bc18dc7da6 Removed parallel testing, updated predis
Parallel testing paratest library caused issues due to a single version
not being compatibile across our php range. Removed for now as not
really worth the faff to get compatible.
2023-02-07 11:50:59 +00:00
Dan Brown
5e8ec56196 Fixed issues found from tests 2023-02-06 20:41:33 +00:00
Dan Brown
9ca088a4e2 Fixed static analysis issues 2023-02-06 20:00:44 +00:00
Dan Brown
008e7a4d25 Followed Laravel 9 update steps and file changes 2023-02-06 16:58:29 +00:00
Dan Brown
ce9b536b78 Updated version and assets for release v23.01.1 2023-02-02 12:29:26 +00:00
Dan Brown
d9c50e5bc1 Merge branch 'development' into release 2023-02-02 12:29:07 +00:00
Dan Brown
6e6f113336 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2023-02-02 12:17:06 +00:00
Dan Brown
f7441e2abc Updated translations with latest Crowdin changes (#4008) 2023-02-02 12:16:56 +00:00
Dan Brown
28c168145f Added missing app icon image
Fixes #4006
2023-02-02 11:49:06 +00:00
Dan Brown
c2115cab59 Updated php depenencies 2023-02-02 11:44:25 +00:00
Dan Brown
bf075f7dd8 Updated version and assets for release v23.01 2023-01-31 11:59:51 +00:00
Dan Brown
a4fd673285 Merge branch 'development' into release 2023-01-31 11:59:28 +00:00
Dan Brown
813d140213 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2023-01-31 11:39:21 +00:00
Dan Brown
3dc5942a85 Updated translation attribution before v23.01 release 2023-01-31 11:38:56 +00:00
Dan Brown
03e2a9b200 Updated translations with latest Crowdin changes (#3925) 2023-01-31 11:29:36 +00:00
Dan Brown
8367a94e90 Merge pull request #4002 from BookStackApp/color_upgrades
Better application color scheme control
2023-01-28 17:59:54 +00:00
Dan Brown
631546a68a Adjusted/improved some color setting wording 2023-01-28 17:57:43 +00:00
Dan Brown
7751022c66 Updated migration to carry across more colors, updated export
Updated export to use link color for link.
Export will now copy primary color to link color options for stable
upgrades.
2023-01-28 17:49:48 +00:00
Dan Brown
f42ff59b43 Added migration of color settings to dark mode 2023-01-28 17:31:43 +00:00
Dan Brown
104621841b Update JS to show live changes and set light color values 2023-01-28 17:11:15 +00:00
Dan Brown
c337439370 Rolled out use of seperate link color style 2023-01-28 16:06:11 +00:00
Dan Brown
65ebdb7234 Added usage and defaults for dark colors 2023-01-28 15:20:08 +00:00
Dan Brown
e708ce93ba Updated generic tab styles and js to force accessible usage
Added use of more accessible tags to create tabbed-interfaces then
updated css and JS to require use of those attributes rather than custom
techniques.

Updated relevant parts of app.
Some custom parts using their own tabs though, something to improve in
future.
2023-01-28 12:50:51 +00:00
Dan Brown
1f69965c1e Updated settings view to have dark-mode color options
Also added link color option, not yet used.
Cleaned up tabbed interface control design as part of this.
2023-01-28 11:50:46 +00:00
Dan Brown
d7723b33f3 Merge pull request #3999 from BookStackApp/sort_ui_improvements
Improve Book Sorting User Experience
2023-01-27 18:02:14 +00:00
Dan Brown
87e371ffde Added prevention of nested chapters on sort 2023-01-27 17:39:51 +00:00
Dan Brown
b649738718 Made book-sort changes based on screen reader testing
- Removed having sort items in tabbing order since they have no action.
- Updated "show other books" list to add upon single selection since it
  was not clear how these were added (double press) without then seeing
the add button, and even then the add button would be after the scroll
list.
2023-01-27 17:06:39 +00:00
Dan Brown
022cbb9c00 Finished off design and fixing of sort buttons 2023-01-27 16:25:06 +00:00
Dan Brown
40e112fc5b Extracted text & added dropdown for book sort move actions
Primarily styling and testing left to do.
2023-01-27 13:26:58 +00:00
Dan Brown
7cacbaadf0 Added functionality/logic for button-based sorting 2023-01-27 13:08:35 +00:00
Dan Brown
a3e7e754b9 Improves sortable ux
- Fixes multi-select functionality.
- Updated other books to be sticky.
- Added some general intro/desc text.
- Updated sort boxes to be collapsible.
- Cleaned up other books styling.
2023-01-27 11:16:17 +00:00
Dan Brown
03ad288aaa Updated user avatar reset to clear relation id in database
Added test to cover.
For #3977
2023-01-26 17:15:09 +00:00
Dan Brown
811be3a36a Added option to change the OIDC claim regarded as the ID
Defined via a OIDC_EXTERNAL_ID_CLAIM env option.
For #3914
2023-01-26 16:43:15 +00:00
Dan Brown
3202f96181 Tweak tag list to add new row on input instead of change
Prevented interferance with the user's action if they interacted with
something below the tags, since a new row would be added on blur and
hence shift down positions.

For #3931
2023-01-26 16:10:47 +00:00
Dan Brown
f6a6b11ec5 Added and addressed multi-role/own-role-perm/inheretance scenario
Found during manual testing.
Have checked against relation queries manually too.
2023-01-26 12:53:25 +00:00
Dan Brown
48df8725d8 Added better drawing load failure handling
Failure of loading drawings will now close the drawing view and show an
error message, hinting at file or permission issues, instead of leaving
the user facing a continuosly loading interface.

Adds test to cover.

This also updates errors from our HTTP service to be wrapped in a custom
error type for better identification and so the error is an actual
javascript error. Should be object compatible.

Related to #3955.
2023-01-26 12:18:33 +00:00
Dan Brown
25bdd71477 Add scheme and sql-variant code language options
For #3954 and #3942
2023-01-26 11:26:20 +00:00
Dan Brown
deda331745 Fixed global search preview click on safari
Safari needs an element to be focusable to be able to use :focus-within.
For #3926
2023-01-25 21:46:26 +00:00
Dan Brown
f6d3944b20 Merge pull request #3994 from BookStackApp/app_icon_setting
Added ability to control app icon (favicon) via settings
2023-01-25 16:50:48 +00:00
Dan Brown
a50b0ea1e5 Covered app icon setting with testing 2023-01-25 16:41:41 +00:00
Dan Brown
3c658e39ab Extracted app icon text, fixed issues
Tweaked sizes and meta tags based unpon ipad testing.
Fixed reduced sizes not being cleaned up.
2023-01-25 16:11:34 +00:00
Dan Brown
d8354255e7 Added practicali to sponsor list 2023-01-25 12:06:11 +00:00
Dan Brown
55b6a7842e Added ability to control app icon (favicon) via settings 2023-01-25 11:03:19 +00:00
Dan Brown
0f113ec41f Merge pull request #3986 from BookStackApp/permission_testing
Permission Testing & Alignment
2023-01-24 21:37:28 +00:00
Dan Brown
1fa5a31960 Fixed role entity permissions ignoring inheritance
Added additional scnenario tests to cover
2023-01-24 21:26:41 +00:00
Dan Brown
8be36455ab Addressed fallback override cases found during testing
Had misalignment between query and usercan, The nuance between fallback
and entity-role permissions was not taken into account by the query
system. Now added with new test cases to cover.
2023-01-24 20:42:20 +00:00
Dan Brown
d1bd6d0e39 Fixed incorrect field in down migration 2023-01-24 19:21:23 +00:00
Dan Brown
1660e72cc5 Migrated remaining relation permission usages
Now all tests are passing.
Some level of manual checks to do.
2023-01-24 19:04:32 +00:00
Dan Brown
2d1f1abce4 Implemented alternate approach to current joint_permissions
Is a tweak upon the existing approach, mainly to store and query role
permission access in a way that allows muli-level states that may
override eachother. These states are represented in the new PermissionStatus
class.

This also simplifies how own permissions are stored and queried, to be
part of a single column.
2023-01-24 14:55:34 +00:00
Dan Brown
7d74575eb8 Found a sql having-style approach to permissions
As a way to check aggregate queries for required changes to need to
analyse across combined permission values.
2023-01-24 13:44:38 +00:00
Dan Brown
91e613fe60 Shared entity permission logic across both query methods
The runtime userCan() and the JointPermissionBuilder now share much of
the same logic for handling entity permission resolution.
2023-01-23 15:09:03 +00:00
Dan Brown
f3f2a0c1d5 Updated userCan logic to meet expectations in tests
Updated with similar logic to that used in the user_permissions branch,
but all extracted to a seperate class for doing all fetch and collapse
work.
2023-01-23 12:40:11 +00:00
Dan Brown
1c2ae7bff6 Added gmp extension to test workflow
If was not already enabled by default, should enable faster testing
handling as it helps the phpseclib usage for OIDC tokens in test rocket
through.
2023-01-21 21:34:39 +00:00
Dan Brown
78ebcb6f38 Addressed a range of deprecation warnings
Closes #3969
2023-01-21 20:50:04 +00:00
Dan Brown
28dda39260 Updated PHP and JS depenencies 2023-01-21 19:09:19 +00:00
Dan Brown
e2a72d16aa Made adjustments to fit copied work into dev branch
Ported non-compatible elements, Now all tests passing apart from some
specific permission scenario tests which are probably correctly failing.
Updates some tests to better avoid messing environment state.
2023-01-21 13:03:47 +00:00
Dan Brown
c724bfe4d3 Copied over work from user_permissions branch
Only that relevant to the additional testing work.
2023-01-21 11:08:34 +00:00
Dan Brown
6070d804f8 Fixed incorrect pluralisation for de_informal
Updated language system to only use initial part of locale for
translation pluralisation to better match the hard-coded logic of the
built-in MessageSelector. Extends and overrides Laravel's default for
this system.

Added test to cover.
Related to #3976.
2023-01-16 16:56:41 +00:00
911 changed files with 10171 additions and 6883 deletions

View File

@@ -80,6 +80,9 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
# Command to use when email is sent via sendmail
MAIL_SENDMAIL_COMMAND="/usr/sbin/sendmail -bs"
# Cache & Session driver to use
# Can be 'file', 'database', 'memcached' or 'redis'
CACHE_DRIVER=file
@@ -268,6 +271,7 @@ OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false
OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false
OIDC_EXTERNAL_ID_CLAIM=sub
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option

View File

@@ -176,7 +176,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: ; Dutch; Turkish
REMOVED_USER :: ; French; Dutch; Turkish
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -302,3 +302,12 @@ Angelos Chouvardas (achouvardas) :: Greek
rndrss :: Portuguese, Brazilian
rirac294 :: Russian
David Furman (thefourCraft) :: Hebrew
Pafzedog :: French
Yllelder :: Spanish
Adrian Ocneanu (aocneanu) :: Romanian
Eduardo Castanho (EduardoCastanho) :: Portuguese
VIET NAM VPS (vietnamvps) :: Vietnamese
m4tthi4s :: French
toras9000 :: Japanese
pathab :: German
MichelSchoon85 :: Dutch

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['7.4', '8.0', '8.1', '8.2']
php: ['8.0', '8.1', '8.2']
steps:
- uses: actions/checkout@v1

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['7.4', '8.0', '8.1', '8.2']
php: ['8.0', '8.1', '8.2']
steps:
- uses: actions/checkout@v1
@@ -16,7 +16,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap
extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
- name: Get Composer Cache Directory
id: composer-cache

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ yarn-error.log
/public/js/*.map
/public/bower
/public/build/
/public/favicon.ico
/storage/images
_ide_helper.php
/storage/debugbar

View File

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

View File

@@ -2,10 +2,12 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Str;
@@ -40,6 +42,12 @@ class Activity extends Model
return $this->belongsTo(User::class);
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
}
/**
* Returns text from the language files, Looks up by using the activity key.
*/

View File

@@ -2,7 +2,9 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Favourite extends Model
@@ -16,4 +18,10 @@ class Favourite extends Model
{
return $this->morphTo();
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'favouritable_id')
->whereColumn('favourites.favouritable_type', '=', 'joint_permissions.entity_type');
}
}

View File

@@ -2,8 +2,10 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
@@ -27,6 +29,12 @@ class Tag extends Model
return $this->morphTo('entity');
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('tags.entity_type', '=', 'joint_permissions.entity_type');
}
/**
* Get a full URL to start a tag name search for this tag name.
*/

View File

@@ -2,8 +2,10 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
@@ -28,6 +30,12 @@ class View extends Model
return $this->morphTo();
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'viewable_id')
->whereColumn('views.viewable_type', '=', 'joint_permissions.entity_type');
}
/**
* Increment the current user's view count for the given viewable model.
*/

View File

@@ -198,7 +198,8 @@ class OidcService
*/
protected function getUserDetails(OidcIdToken $token): array
{
$id = $token->getClaim('sub');
$idClaim = $this->config()['external_id_claim'];
$id = $token->getClaim($idClaim);
return [
'external_id' => $id,

View File

@@ -0,0 +1,141 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
class EntityPermissionEvaluator
{
protected string $action;
public function __construct(string $action)
{
$this->action = $action;
}
public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool
{
if ($this->isUserSystemAdmin($userRoleIds)) {
return true;
}
$typeIdChain = $this->gatherEntityChainTypeIds(SimpleEntityData::fromEntity($entity));
$relevantPermissions = $this->getPermissionsMapByTypeId($typeIdChain, [...$userRoleIds, 0]);
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
$status = $this->evaluatePermitsByType($permitsByType);
return is_null($status) ? null : $status === PermissionStatus::IMPLICIT_ALLOW || $status === PermissionStatus::EXPLICIT_ALLOW;
}
/**
* @param array<string, array<string, int>> $permitsByType
*/
protected function evaluatePermitsByType(array $permitsByType): ?int
{
// Return grant or reject from role-level if exists
if (count($permitsByType['role']) > 0) {
return max($permitsByType['role']) ? PermissionStatus::EXPLICIT_ALLOW : PermissionStatus::EXPLICIT_DENY;
}
// Return fallback permission if exists
if (count($permitsByType['fallback']) > 0) {
return $permitsByType['fallback'][0] ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
}
return null;
}
/**
* @param string[] $typeIdChain
* @param array<string, EntityPermission[]> $permissionMapByTypeId
* @return array<string, array<string, int>>
*/
protected function collapseAndCategorisePermissions(array $typeIdChain, array $permissionMapByTypeId): array
{
$permitsByType = ['fallback' => [], 'role' => []];
foreach ($typeIdChain as $typeId) {
$permissions = $permissionMapByTypeId[$typeId] ?? [];
foreach ($permissions as $permission) {
$roleId = $permission->role_id;
$type = $roleId === 0 ? 'fallback' : 'role';
if (!isset($permitsByType[$type][$roleId])) {
$permitsByType[$type][$roleId] = $permission->{$this->action};
}
}
if (isset($permitsByType['fallback'][0])) {
break;
}
}
return $permitsByType;
}
/**
* @param string[] $typeIdChain
* @return array<string, EntityPermission[]>
*/
protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): array
{
$query = EntityPermission::query()->where(function (Builder $query) use ($typeIdChain) {
foreach ($typeIdChain as $typeId) {
$query->orWhere(function (Builder $query) use ($typeId) {
[$type, $id] = explode(':', $typeId);
$query->where('entity_type', '=', $type)
->where('entity_id', '=', $id);
});
}
});
if (!empty($filterRoleIds)) {
$query->where(function (Builder $query) use ($filterRoleIds) {
$query->whereIn('role_id', [...$filterRoleIds, 0]);
});
}
$relevantPermissions = $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
$map = [];
foreach ($relevantPermissions as $permission) {
$key = $permission->entity_type . ':' . $permission->entity_id;
if (!isset($map[$key])) {
$map[$key] = [];
}
$map[$key][] = $permission;
}
return $map;
}
/**
* @return string[]
*/
protected function gatherEntityChainTypeIds(SimpleEntityData $entity): array
{
// The array order here is very important due to the fact we walk up the chain
// elsewhere in the class. Earlier items in the chain have higher priority.
$chain = [$entity->type . ':' . $entity->id];
if ($entity->type === 'page' && $entity->chapter_id) {
$chain[] = 'chapter:' . $entity->chapter_id;
}
if ($entity->type === 'page' || $entity->type === 'chapter') {
$chain[] = 'book:' . $entity->book_id;
}
return $chain;
}
protected function isUserSystemAdmin($userRoleIds): bool
{
$adminRoleId = Role::getSystemRole('admin')->id;
return in_array($adminRoleId, $userRoleIds);
}
}

View File

@@ -19,11 +19,6 @@ use Illuminate\Support\Facades\DB;
*/
class JointPermissionBuilder
{
/**
* @var array<string, array<int, SimpleEntityData>>
*/
protected array $entityCache;
/**
* Re-generate all entity permission from scratch.
*/
@@ -98,40 +93,6 @@ class JointPermissionBuilder
});
}
/**
* Prepare the local entity cache and ensure it's empty.
*
* @param SimpleEntityData[] $entities
*/
protected function readyEntityCache(array $entities)
{
$this->entityCache = [];
foreach ($entities as $entity) {
if (!isset($this->entityCache[$entity->type])) {
$this->entityCache[$entity->type] = [];
}
$this->entityCache[$entity->type][$entity->id] = $entity;
}
}
/**
* Get a book via ID, Checks local cache.
*/
protected function getBook(int $bookId): SimpleEntityData
{
return $this->entityCache['book'][$bookId];
}
/**
* Get a chapter via ID, Checks local cache.
*/
protected function getChapter(int $chapterId): SimpleEntityData
{
return $this->entityCache['chapter'][$chapterId];
}
/**
* Get a query for fetching a book with its children.
*/
@@ -214,13 +175,7 @@ class JointPermissionBuilder
$simpleEntities = [];
foreach ($entities as $entity) {
$attrs = $entity->getAttributes();
$simple = new SimpleEntityData();
$simple->id = $attrs['id'];
$simple->type = $entity->getMorphClass();
$simple->owned_by = $attrs['owned_by'] ?? 0;
$simple->book_id = $attrs['book_id'] ?? null;
$simple->chapter_id = $attrs['chapter_id'] ?? null;
$simple = SimpleEntityData::fromEntity($entity);
$simpleEntities[] = $simple;
}
@@ -236,18 +191,10 @@ class JointPermissionBuilder
protected function createManyJointPermissions(array $originalEntities, array $roles)
{
$entities = $this->entitiesToSimpleEntities($originalEntities);
$this->readyEntityCache($entities);
$jointPermissions = [];
// Fetch related entity permissions
$permissions = $this->getEntityPermissionsForEntities($entities);
// Create a mapping of explicit entity permissions
$permissionMap = [];
foreach ($permissions as $permission) {
$key = $permission->entity_type . ':' . $permission->entity_id . ':' . $permission->role_id;
$permissionMap[$key] = $permission->view;
}
$permissions = new MassEntityPermissionEvaluator($entities, 'view');
// Create a mapping of role permissions
$rolePermissionMap = [];
@@ -260,13 +207,14 @@ class JointPermissionBuilder
// Create Joint Permission Data
foreach ($entities as $entity) {
foreach ($roles as $role) {
$jointPermissions[] = $this->createJointPermissionData(
$jp = $this->createJointPermissionData(
$entity,
$role->getRawAttribute('id'),
$permissionMap,
$permissions,
$rolePermissionMap,
$role->system_name === 'admin'
);
$jointPermissions[] = $jp;
}
}
@@ -300,109 +248,45 @@ class JointPermissionBuilder
return $idsByType;
}
/**
* Get the entity permissions for all the given entities.
*
* @param SimpleEntityData[] $entities
*
* @return EntityPermission[]
*/
protected function getEntityPermissionsForEntities(array $entities): array
{
$idsByType = $this->entitiesToTypeIdMap($entities);
$permissionFetch = EntityPermission::query()
->where(function (Builder $query) use ($idsByType) {
foreach ($idsByType as $type => $ids) {
$query->orWhere(function (Builder $query) use ($type, $ids) {
$query->where('entity_type', '=', $type)->whereIn('entity_id', $ids);
});
}
});
return $permissionFetch->get()->all();
}
/**
* Create entity permission data for an entity and role
* for a particular action.
*/
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, array $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, MassEntityPermissionEvaluator $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
{
// Ensure system admin role retains permissions
if ($isAdminRole) {
return $this->createJointPermissionDataArray($entity, $roleId, PermissionStatus::EXPLICIT_ALLOW, true);
}
// Return evaluated entity permission status if it has an affect.
$entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId);
if ($entityPermissionStatus !== null) {
return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, false);
}
// Otherwise default to the role-level permissions
$permissionPrefix = $entity->type . '-view';
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
if ($isAdminRole) {
return $this->createJointPermissionDataArray($entity, $roleId, true, true);
}
if ($this->entityPermissionsActiveForRole($permissionMap, $entity, $roleId)) {
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId);
return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess);
}
if ($entity->type === 'book' || $entity->type === 'bookshelf') {
return $this->createJointPermissionDataArray($entity, $roleId, $roleHasPermission, $roleHasPermissionOwn);
}
// For chapters and pages, Check if explicit permissions are set on the Book.
$book = $this->getBook($entity->book_id);
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId);
$hasPermissiveAccessToParents = !$this->entityPermissionsActiveForRole($permissionMap, $book, $roleId);
// For pages with a chapter, Check if explicit permissions are set on the Chapter
if ($entity->type === 'page' && $entity->chapter_id !== 0) {
$chapter = $this->getChapter($entity->chapter_id);
$chapterRestricted = $this->entityPermissionsActiveForRole($permissionMap, $chapter, $roleId);
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapterRestricted;
if ($chapterRestricted) {
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
}
}
return $this->createJointPermissionDataArray(
$entity,
$roleId,
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
);
}
/**
* Check if entity permissions are defined within the given map, for the given entity and role.
* Checks for the default `role_id=0` backup option as a fallback.
*/
protected function entityPermissionsActiveForRole(array $permissionMap, SimpleEntityData $entity, int $roleId): bool
{
$keyPrefix = $entity->type . ':' . $entity->id . ':';
return isset($permissionMap[$keyPrefix . $roleId]) || isset($permissionMap[$keyPrefix . '0']);
}
/**
* Check for an active restriction in an entity map.
*/
protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
{
$roleKey = $entity->type . ':' . $entity->id . ':' . $roleId;
$defaultKey = $entity->type . ':' . $entity->id . ':0';
return $entityMap[$roleKey] ?? $entityMap[$defaultKey] ?? false;
$status = $roleHasPermission ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
return $this->createJointPermissionDataArray($entity, $roleId, $status, $roleHasPermissionOwn);
}
/**
* Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion.
*/
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, bool $permissionAll, bool $permissionOwn): array
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, int $permissionStatus, bool $hasPermissionOwn): array
{
$ownPermissionActive = ($hasPermissionOwn && $permissionStatus !== PermissionStatus::EXPLICIT_DENY && $entity->owned_by);
return [
'entity_id' => $entity->id,
'entity_type' => $entity->type,
'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn,
'owned_by' => $entity->owned_by,
'role_id' => $roleId,
'entity_id' => $entity->id,
'entity_type' => $entity->type,
'role_id' => $roleId,
'status' => $permissionStatus,
'owner_id' => $ownPermissionActive ? $entity->owned_by : null,
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace BookStack\Auth\Permissions;
class MassEntityPermissionEvaluator extends EntityPermissionEvaluator
{
/**
* @var SimpleEntityData[]
*/
protected array $entitiesInvolved;
protected array $permissionMapCache;
public function __construct(array $entitiesInvolved, string $action)
{
$this->entitiesInvolved = $entitiesInvolved;
parent::__construct($action);
}
public function evaluateEntityForRole(SimpleEntityData $entity, int $roleId): ?int
{
$typeIdChain = $this->gatherEntityChainTypeIds($entity);
$relevantPermissions = $this->getPermissionMapByTypeIdForChainAndRole($typeIdChain, $roleId);
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
return $this->evaluatePermitsByType($permitsByType);
}
/**
* @param string[] $typeIdChain
* @return array<string, EntityPermission[]>
*/
protected function getPermissionMapByTypeIdForChainAndRole(array $typeIdChain, int $roleId): array
{
$allPermissions = $this->getPermissionMapByTypeIdAndRoleForAllInvolved();
$relevantPermissions = [];
// Filter down permissions to just those for current typeId
// and current roleID or fallback permissions.
foreach ($typeIdChain as $typeId) {
$relevantPermissions[$typeId] = [
...($allPermissions[$typeId][$roleId] ?? []),
...($allPermissions[$typeId][0] ?? [])
];
}
return $relevantPermissions;
}
/**
* @return array<string, array<int, EntityPermission[]>>
*/
protected function getPermissionMapByTypeIdAndRoleForAllInvolved(): array
{
if (isset($this->permissionMapCache)) {
return $this->permissionMapCache;
}
$entityTypeIdChain = [];
foreach ($this->entitiesInvolved as $entity) {
$entityTypeIdChain[] = $entity->type . ':' . $entity->id;
}
$permissionMap = $this->getPermissionsMapByTypeId($entityTypeIdChain, []);
// Manipulate permission map to also be keyed by roleId.
foreach ($permissionMap as $typeId => $permissions) {
$permissionMap[$typeId] = [];
foreach ($permissions as $permission) {
$roleId = $permission->getRawAttribute('role_id');
if (!isset($permissionMap[$typeId][$roleId])) {
$permissionMap[$typeId][$roleId] = [];
}
$permissionMap[$typeId][$roleId][] = $permission;
}
}
$this->permissionMapCache = $permissionMap;
return $this->permissionMapCache;
}
}

View File

@@ -4,7 +4,6 @@ namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Model;
@@ -61,46 +60,7 @@ class PermissionApplicator
{
$this->ensureValidEntityAction($action);
$adminRoleId = Role::getSystemRole('admin')->id;
if (in_array($adminRoleId, $userRoleIds)) {
return true;
}
// The chain order here is very important due to the fact we walk up the chain
// in the loop below. Earlier items in the chain have higher priority.
$chain = [$entity];
if ($entity instanceof Page && $entity->chapter_id) {
$chain[] = $entity->chapter;
}
if ($entity instanceof Page || $entity instanceof Chapter) {
$chain[] = $entity->book;
}
foreach ($chain as $currentEntity) {
$allowedByRoleId = $currentEntity->permissions()
->whereIn('role_id', [0, ...$userRoleIds])
->pluck($action, 'role_id');
// Continue up the chain if no applicable entity permission overrides.
if ($allowedByRoleId->isEmpty()) {
continue;
}
// If we have user-role-specific permissions set, allow if any of those
// role permissions allow access.
$hasDefault = $allowedByRoleId->has(0);
if (!$hasDefault || $allowedByRoleId->count() > 1) {
return $allowedByRoleId->search(function (bool $allowed, int $roleId) {
return $roleId !== 0 && $allowed;
}) !== false;
}
// Otherwise, return the default "Other roles" fallback value.
return $allowedByRoleId->get(0);
}
return null;
return (new EntityPermissionEvaluator($action))->evaluateEntityForUser($entity, $userRoleIds);
}
/**
@@ -134,10 +94,12 @@ class PermissionApplicator
{
return $query->where(function (Builder $parentQuery) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) {
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoleIds())
->where(function (Builder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
$permissionQuery->select(['entity_id', 'entity_type'])
->selectRaw('max(owner_id) as owner_id')
->selectRaw('max(status) as status')
->whereIn('role_id', $this->getCurrentUserRoleIds())
->groupBy(['entity_type', 'entity_id'])
->havingRaw('(status IN (1, 3) or (owner_id = ? and status != 2))', [$this->currentUser()->id]);
});
});
}
@@ -161,35 +123,23 @@ class PermissionApplicator
* Filter items that have entities set as a polymorphic relation.
* For simplicity, this will not return results attached to draft pages.
* Draft pages should never really have related items though.
*
* @param Builder|QueryBuilder $query
*/
public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn)
public function restrictEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$pageMorphClass = (new Page())->getMorphClass();
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails) {
/** @var Builder $permissionQuery */
$permissionQuery->select(['role_id'])->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
})->where(function ($query) use ($tableDetails, $pageMorphClass) {
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
return $this->restrictEntityQuery($query)
->where(function ($query) use ($tableDetails, $pageMorphClass) {
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
->where('pages.draft', '=', false);
});
});
return $q;
});
}
/**
@@ -201,49 +151,20 @@ class PermissionApplicator
public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
{
$fullPageIdColumn = $tableName . '.' . $pageIdColumn;
$morphClass = (new Page())->getMorphClass();
$existsQuery = function ($permissionQuery) use ($fullPageIdColumn, $morphClass) {
/** @var Builder $permissionQuery */
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $fullPageIdColumn)
->where('joint_permissions.entity_type', '=', $morphClass)
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
return $this->restrictEntityQuery($query)
->where(function ($query) use ($fullPageIdColumn) {
/** @var Builder $query */
$query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where('pages.draft', '=', false);
})->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where('pages.draft', '=', true)
->where('pages.created_by', '=', $this->currentUser()->id);
});
};
$q = $query->where(function ($query) use ($existsQuery, $fullPageIdColumn) {
$query->whereExists($existsQuery)
->orWhere($fullPageIdColumn, '=', 0);
});
// Prevent visibility of non-owned draft pages
$q->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where(function (QueryBuilder $query) {
$query->where('pages.draft', '=', false)
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
});
});
return $q;
}
/**
* Add the query for checking the given user id has permission
* within the join_permissions table.
*
* @param QueryBuilder|Builder $query
*/
protected function addJointHasPermissionCheck($query, int $userIdToCheck)
{
$query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
$query->where('joint_permissions.has_permission_own', '=', true)
->where('joint_permissions.owned_by', '=', $userIdToCheck);
});
});
}
/**

View File

@@ -0,0 +1,11 @@
<?php
namespace BookStack\Auth\Permissions;
class PermissionStatus
{
const IMPLICIT_DENY = 0;
const IMPLICIT_ALLOW = 1;
const EXPLICIT_DENY = 2;
const EXPLICIT_ALLOW = 3;
}

View File

@@ -12,11 +12,8 @@ use Illuminate\Database\Eloquent\Collection;
class PermissionsRepo
{
protected JointPermissionBuilder $permissionBuilder;
protected $systemRoles = ['admin', 'public'];
protected array $systemRoles = ['admin', 'public'];
/**
* PermissionsRepo constructor.
*/
public function __construct(JointPermissionBuilder $permissionBuilder)
{
$this->permissionBuilder = $permissionBuilder;
@@ -41,7 +38,7 @@ class PermissionsRepo
/**
* Get a role via its ID.
*/
public function getRoleById($id): Role
public function getRoleById(int $id): Role
{
return Role::query()->findOrFail($id);
}
@@ -52,10 +49,10 @@ class PermissionsRepo
public function saveNewRole(array $roleData): Role
{
$role = new Role($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
$role->save();
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$permissions = $roleData['permissions'] ?? [];
$this->assignRolePermissions($role, $permissions);
$this->permissionBuilder->rebuildForRole($role);
@@ -66,42 +63,45 @@ class PermissionsRepo
/**
* Updates an existing role.
* Ensure Admin role always have core permissions.
* Ensures Admin system role always have core permissions.
*/
public function updateRole($roleId, array $roleData)
public function updateRole($roleId, array $roleData): Role
{
$role = $this->getRoleById($roleId);
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
if (isset($roleData['permissions'])) {
$this->assignRolePermissions($role, $roleData['permissions']);
}
$role->fill($roleData);
$role->save();
$this->permissionBuilder->rebuildForRole($role);
Activity::add(ActivityType::ROLE_UPDATE, $role);
return $role;
}
/**
* Assign a list of permission names to the given role.
*/
protected function assignRolePermissions(Role $role, array $permissionNameArray = []): void
{
$permissions = [];
$permissionNameArray = array_values($permissionNameArray);
// Ensure the admin system role retains vital system permissions
if ($role->system_name === 'admin') {
$permissions = array_merge($permissions, [
$permissionNameArray = array_unique(array_merge($permissionNameArray, [
'users-manage',
'user-roles-manage',
'restrictions-manage-all',
'restrictions-manage-own',
'settings-manage',
]);
]));
}
$this->assignRolePermissions($role, $permissions);
$role->fill($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->save();
$this->permissionBuilder->rebuildForRole($role);
Activity::add(ActivityType::ROLE_UPDATE, $role);
}
/**
* Assign a list of permission names to a role.
*/
protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
{
$permissions = [];
$permissionNameArray = array_values($permissionNameArray);
if ($permissionNameArray) {
if (!empty($permissionNameArray)) {
$permissions = RolePermission::query()
->whereIn('name', $permissionNameArray)
->pluck('id')
@@ -114,13 +114,13 @@ class PermissionsRepo
/**
* Delete a role from the system.
* Check it's not an admin role or set as default before deleting.
* If an migration Role ID is specified the users assign to the current role
* If a migration Role ID is specified the users assign to the current role
* will be added to the role of the specified id.
*
* @throws PermissionsException
* @throws Exception
*/
public function deleteRole($roleId, $migrateRoleId)
public function deleteRole(int $roleId, int $migrateRoleId = 0): void
{
$role = $this->getRoleById($roleId);
@@ -131,7 +131,7 @@ class PermissionsRepo
throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
}
if ($migrateRoleId) {
if ($migrateRoleId !== 0) {
$newRole = Role::query()->find($migrateRoleId);
if ($newRole) {
$users = $role->users()->pluck('id')->toArray();

View File

@@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/**
* @property int $id
* @property string $name
* @property string $display_name
*/
class RolePermission extends Model
{

View File

@@ -2,6 +2,8 @@
namespace BookStack\Auth\Permissions;
use BookStack\Entities\Models\Entity;
class SimpleEntityData
{
public int $id;
@@ -9,4 +11,18 @@ class SimpleEntityData
public int $owned_by;
public ?int $book_id;
public ?int $chapter_id;
public static function fromEntity(Entity $entity): self
{
$attrs = $entity->getAttributes();
$simple = new self();
$simple->id = $attrs['id'];
$simple->type = $entity->getMorphClass();
$simple->owned_by = $attrs['owned_by'] ?? 0;
$simple->book_id = $attrs['book_id'] ?? null;
$simple->chapter_id = $attrs['chapter_id'] ?? null;
return $simple;
}
}

View File

@@ -27,10 +27,14 @@ class Role extends Model implements Loggable
{
use HasFactory;
protected $fillable = ['display_name', 'description', 'external_auth_id'];
protected $fillable = ['display_name', 'description', 'external_auth_id', 'mfa_enforced'];
protected $hidden = ['pivot'];
protected $casts = [
'mfa_enforced' => 'boolean',
];
/**
* The roles that belong to the role.
*/
@@ -107,7 +111,13 @@ class Role extends Model implements Loggable
*/
public static function getSystemRole(string $systemName): ?self
{
return static::query()->where('system_name', '=', $systemName)->first();
static $cache = [];
if (!isset($cache[$systemName])) {
$cache[$systemName] = static::query()->where('system_name', '=', $systemName)->first();
}
return $cache[$systemName];
}
/**

View File

@@ -72,7 +72,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected $hidden = [
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
'created_at', 'updated_at', 'image_id', 'roles', 'avatar', 'user_id',
'created_at', 'updated_at', 'image_id', 'roles', 'avatar', 'user_id', 'pivot',
];
/**
@@ -200,6 +200,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
public function attachRole(Role $role)
{
$this->roles()->attach($role->id);
$this->unsetRelation('roles');
}
/**

View File

@@ -8,6 +8,8 @@
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
use Illuminate\Support\Facades\Facade;
return [
// The environment to run BookStack in.
@@ -98,7 +100,13 @@ return [
// Encryption cipher
'cipher' => 'AES-256-CBC',
// Application Services Provides
// Maintenance Mode Driver
'maintenance' => [
'driver' => 'file',
// 'store' => 'redis',
],
// Application Service Providers
'providers' => [
// Laravel Framework Service Providers...
@@ -141,58 +149,9 @@ return [
BookStack\Providers\ViewTweaksServiceProvider::class,
],
/*
|--------------------------------------------------------------------------
| Class Aliases
|--------------------------------------------------------------------------
|
| This array of class aliases will be registered when this application
| is started. However, feel free to register as many as you wish as
| the aliases are "lazy" loaded so they don't hinder performance.
|
*/
// Class aliases, Registered on application start
'aliases' => [
// Laravel
'App' => Illuminate\Support\Facades\App::class,
'Arr' => Illuminate\Support\Arr::class,
'Artisan' => Illuminate\Support\Facades\Artisan::class,
'Auth' => Illuminate\Support\Facades\Auth::class,
'Blade' => Illuminate\Support\Facades\Blade::class,
'Bus' => Illuminate\Support\Facades\Bus::class,
'Cache' => Illuminate\Support\Facades\Cache::class,
'Config' => Illuminate\Support\Facades\Config::class,
'Cookie' => Illuminate\Support\Facades\Cookie::class,
'Crypt' => Illuminate\Support\Facades\Crypt::class,
'Date' => Illuminate\Support\Facades\Date::class,
'DB' => Illuminate\Support\Facades\DB::class,
'Eloquent' => Illuminate\Database\Eloquent\Model::class,
'Event' => Illuminate\Support\Facades\Event::class,
'File' => Illuminate\Support\Facades\File::class,
'Gate' => Illuminate\Support\Facades\Gate::class,
'Hash' => Illuminate\Support\Facades\Hash::class,
'Http' => Illuminate\Support\Facades\Http::class,
'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class,
'Notification' => Illuminate\Support\Facades\Notification::class,
'Password' => Illuminate\Support\Facades\Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class,
'RateLimiter' => Illuminate\Support\Facades\RateLimiter::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class,
// 'Redis' => Illuminate\Support\Facades\Redis::class,
'Request' => Illuminate\Support\Facades\Request::class,
'Response' => Illuminate\Support\Facades\Response::class,
'Route' => Illuminate\Support\Facades\Route::class,
'Schema' => Illuminate\Support\Facades\Schema::class,
'Session' => Illuminate\Support\Facades\Session::class,
'Storage' => Illuminate\Support\Facades\Storage::class,
'Str' => Illuminate\Support\Str::class,
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
// Class Aliases
// This array of class aliases to be registered on application start.
'aliases' => Facade::defaultAliases()->merge([
// Laravel Packages
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
@@ -202,7 +161,7 @@ return [
// Custom BookStack
'Activity' => BookStack\Facades\Activity::class,
'Theme' => BookStack\Facades\Theme::class,
],
])->toArray(),
// Proxy configuration
'proxies' => env('APP_PROXIES', ''),

View File

@@ -14,7 +14,7 @@ return [
// This option controls the default broadcaster that will be used by the
// framework when an event needs to be broadcast. This can be set to
// any of the connections defined in the "connections" array below.
'default' => env('BROADCAST_DRIVER', 'pusher'),
'default' => 'null',
// Broadcast Connections
// Here you may define all of the broadcast connections that will be used
@@ -22,21 +22,7 @@ return [
// each available type of connection are provided inside this array.
'connections' => [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'useTLS' => true,
],
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
// Default options removed since we don't use broadcasting.
'log' => [
'driver' => 'log',

View File

@@ -87,6 +87,6 @@ return [
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache'),
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache_'),
];

View File

@@ -33,17 +33,20 @@ return [
'driver' => 'local',
'root' => public_path(),
'visibility' => 'public',
'throw' => true,
],
'local_secure_attachments' => [
'driver' => 'local',
'root' => storage_path('uploads/files/'),
'throw' => true,
],
'local_secure_images' => [
'driver' => 'local',
'root' => storage_path('uploads/images/'),
'visibility' => 'public',
'throw' => true,
],
's3' => [
@@ -54,6 +57,7 @@ return [
'bucket' => env('STORAGE_S3_BUCKET', 'your-bucket'),
'endpoint' => env('STORAGE_S3_ENDPOINT', null),
'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
'throw' => true,
],
],

View File

@@ -21,6 +21,15 @@ return [
// one of the channels defined in the "channels" configuration array.
'default' => env('LOG_CHANNEL', 'single'),
// Deprecations Log Channel
// This option controls the log channel that should be used to log warnings
// regarding deprecated PHP and library features. This allows you to get
// your application ready for upcoming major versions of dependencies.
'deprecations' => [
'channel' => 'null',
'trace' => false,
],
// Log Channels
// Here you may configure the log channels for your application. Out of
// the box, Laravel uses the Monolog PHP logging library. This gives

View File

@@ -14,13 +14,7 @@ return [
// From Laravel 7+ this is MAIL_MAILER in laravel.
// Kept as MAIL_DRIVER in BookStack to prevent breaking change.
// Options: smtp, sendmail, log, array
'driver' => env('MAIL_DRIVER', 'smtp'),
// SMTP host address
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
// SMTP host port
'port' => env('MAIL_PORT', 587),
'default' => env('MAIL_DRIVER', 'smtp'),
// Global "From" address & name
'from' => [
@@ -28,17 +22,42 @@ return [
'name' => env('MAIL_FROM_NAME', 'BookStack'),
],
// Email encryption protocol
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
// Mailer Configurations
// Available mailing methods and their settings.
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
'port' => env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN'),
],
// SMTP server username
'username' => env('MAIL_USERNAME'),
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'),
],
// SMTP server password
'password' => env('MAIL_PASSWORD'),
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
// Sendmail application path
'sendmail' => '/usr/sbin/sendmail -bs',
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
],
],
// Email markdown configuration
'markdown' => [
@@ -47,11 +66,4 @@ return [
resource_path('views/vendor/mail'),
],
],
// Log Channel
// If you are using the "log" driver, you may specify the logging channel
// if you prefer to keep mail messages separate from other log entries
// for simpler reading. Otherwise, the default channel will be used.
'log_channel' => env('MAIL_LOG_CHANNEL'),
];

View File

@@ -8,9 +8,12 @@ return [
// Dump user details after a login request for debugging purposes
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
// Attribute, within a OpenId token, to find the user's display name
// Claim, within an OpenId token, to find the user's display name
'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
// Claim, within an OpenID token, to use to connect a BookStack user to the OIDC user.
'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'),
// OAuth2/OpenId client id, as configured in your Authorization server.
'client_id' => env('OIDC_CLIENT_ID', null),

View File

@@ -16,11 +16,20 @@ return [
'app-editor' => 'wysiwyg',
'app-color' => '#206ea7',
'app-color-light' => 'rgba(32,110,167,0.15)',
'link-color' => '#206ea7',
'bookshelf-color' => '#a94747',
'book-color' => '#077b70',
'chapter-color' => '#af4d0d',
'page-color' => '#206ea7',
'page-draft-color' => '#7e50b1',
'app-color-dark' => '#195785',
'app-color-light-dark' => 'rgba(32,110,167,0.15)',
'link-color-dark' => '#429fe3',
'bookshelf-color-dark' => '#ff5454',
'book-color-dark' => '#389f60',
'chapter-color-dark' => '#ee7a2d',
'page-color-dark' => '#429fe3',
'page-draft-color-dark' => '#a66ce8',
'app-custom-head' => false,
'registration-enabled' => false,

View File

@@ -2,18 +2,18 @@
namespace BookStack\Entities\Tools\Markdown;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\Block\Element\Paragraph;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\Block\Renderer\ListItemRenderer;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
use League\CommonMark\Extension\CommonMark\Renderer\Block\ListItemRenderer;
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
use League\CommonMark\HtmlElement;
use League\CommonMark\Node\Block\Paragraph;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
class CustomListItemRenderer implements BlockRendererInterface
class CustomListItemRenderer implements NodeRendererInterface
{
protected $baseRenderer;
protected ListItemRenderer $baseRenderer;
public function __construct()
{
@@ -23,11 +23,11 @@ class CustomListItemRenderer implements BlockRendererInterface
/**
* @return HtmlElement|string|null
*/
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{
$listItem = $this->baseRenderer->render($block, $htmlRenderer, $inTightList);
$listItem = $this->baseRenderer->render($node, $childRenderer);
if ($this->startsTaskListItem($block)) {
if ($node instanceof ListItem && $this->startsTaskListItem($node) && $listItem instanceof HtmlElement) {
$listItem->setAttribute('class', 'task-list-item');
}

View File

@@ -2,16 +2,16 @@
namespace BookStack\Entities\Tools\Markdown;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Extension\Strikethrough\Strikethrough;
use League\CommonMark\Extension\Strikethrough\StrikethroughDelimiterProcessor;
class CustomStrikeThroughExtension implements ExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addDelimiterProcessor(new StrikethroughDelimiterProcessor());
$environment->addInlineRenderer(Strikethrough::class, new CustomStrikethroughRenderer());
$environment->addRenderer(Strikethrough::class, new CustomStrikethroughRenderer());
}
}

View File

@@ -2,25 +2,23 @@
namespace BookStack\Entities\Tools\Markdown;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Extension\Strikethrough\Strikethrough;
use League\CommonMark\HtmlElement;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Inline\Renderer\InlineRendererInterface;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
/**
* This is a somewhat clone of the League\CommonMark\Extension\Strikethrough\StrikethroughRender
* class but modified slightly to use <s> HTML tags instead of <del> in order to
* match front-end markdown-it rendering.
*/
class CustomStrikethroughRenderer implements InlineRendererInterface
class CustomStrikethroughRenderer implements NodeRendererInterface
{
public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer)
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{
if (!($inline instanceof Strikethrough)) {
throw new \InvalidArgumentException('Incompatible inline type: ' . get_class($inline));
}
Strikethrough::assertInstanceOf($node);
return new HtmlElement('s', $inline->getData('attributes', []), $htmlRenderer->renderInlines($inline->children()));
return new HtmlElement('s', $node->data->get('attributes'), $childRenderer->renderNodes($node->children()));
}
}

View File

@@ -4,11 +4,12 @@ namespace BookStack\Entities\Tools\Markdown;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;
class MarkdownToHtml
{
@@ -21,15 +22,16 @@ class MarkdownToHtml
public function convert(): string
{
$environment = Environment::createCommonMarkEnvironment();
$environment = new Environment();
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new TableExtension());
$environment->addExtension(new TaskListExtension());
$environment->addExtension(new CustomStrikeThroughExtension());
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
$converter = new CommonMarkConverter([], $environment);
$converter = new MarkdownConverter($environment);
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
$environment->addRenderer(ListItem::class, new CustomListItemRenderer(), 10);
return $converter->convertToHtml($this->markdown);
return $converter->convert($this->markdown)->getContent();
}
}

View File

@@ -19,20 +19,15 @@ use Illuminate\Support\Str;
class PageContent
{
protected Page $page;
/**
* PageContent constructor.
*/
public function __construct(Page $page)
{
$this->page = $page;
public function __construct(
protected Page $page
) {
}
/**
* Update the content of the page with new provided HTML.
*/
public function setNewHTML(string $html)
public function setNewHTML(string $html): void
{
$html = $this->extractBase64ImagesFromHtml($html);
$this->page->html = $this->formatHtml($html);
@@ -43,7 +38,7 @@ class PageContent
/**
* Update the content of the page with new provided Markdown content.
*/
public function setNewMarkdown(string $markdown)
public function setNewMarkdown(string $markdown): void
{
$markdown = $this->extractBase64ImagesFromMarkdown($markdown);
$this->page->markdown = $markdown;
@@ -57,7 +52,7 @@ class PageContent
*/
protected function extractBase64ImagesFromHtml(string $htmlText): string
{
if (empty($htmlText) || strpos($htmlText, 'data:image') === false) {
if (empty($htmlText) || !str_contains($htmlText, 'data:image')) {
return $htmlText;
}
@@ -91,7 +86,7 @@ class PageContent
* Attempting to capture the whole data uri using regex can cause PHP
* PCRE limits to be hit with larger, multi-MB, files.
*/
protected function extractBase64ImagesFromMarkdown(string $markdown)
protected function extractBase64ImagesFromMarkdown(string $markdown): string
{
$matches = [];
$contentLength = strlen($markdown);
@@ -183,32 +178,13 @@ class PageContent
$childNodes = $body->childNodes;
$xPath = new DOMXPath($doc);
// Set ids on top-level nodes
// Map to hold used ID references
$idMap = [];
foreach ($childNodes as $index => $childNode) {
[$oldId, $newId] = $this->setUniqueId($childNode, $idMap);
if ($newId && $newId !== $oldId) {
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
}
}
// Map to hold changing ID references
$changeMap = [];
// Set ids on nested header nodes
$nestedHeaders = $xPath->query('//body//*//h1|//body//*//h2|//body//*//h3|//body//*//h4|//body//*//h5|//body//*//h6');
foreach ($nestedHeaders as $nestedHeader) {
[$oldId, $newId] = $this->setUniqueId($nestedHeader, $idMap);
if ($newId && $newId !== $oldId) {
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
}
}
// Ensure no duplicate ids within child items
$idElems = $xPath->query('//body//*//*[@id]');
foreach ($idElems as $domElem) {
[$oldId, $newId] = $this->setUniqueId($domElem, $idMap);
if ($newId && $newId !== $oldId) {
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
}
}
$this->updateIdsRecursively($body, 0, $idMap, $changeMap);
$this->updateLinks($xPath, $changeMap);
// Generate inner html as a string
$html = '';
@@ -223,20 +199,53 @@ class PageContent
}
/**
* Update the all links to the $old location to instead point to $new.
* For the given DOMNode, traverse its children recursively and update IDs
* where required (Top-level, headers & elements with IDs).
* Will update the provided $changeMap array with changes made, where keys are the old
* ids and the corresponding values are the new ids.
*/
protected function updateLinks(DOMXPath $xpath, string $old, string $new)
protected function updateIdsRecursively(DOMNode $element, int $depth, array &$idMap, array &$changeMap): void
{
$old = str_replace('"', '', $old);
$matchingLinks = $xpath->query('//body//*//*[@href="' . $old . '"]');
foreach ($matchingLinks as $domElem) {
$domElem->setAttribute('href', $new);
/* @var DOMNode $child */
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement && ($depth === 0 || in_array($child->nodeName, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) || $child->getAttribute('id'))) {
[$oldId, $newId] = $this->setUniqueId($child, $idMap);
if ($newId && $newId !== $oldId && !isset($idMap[$oldId])) {
$changeMap[$oldId] = $newId;
}
}
if ($child->hasChildNodes()) {
$this->updateIdsRecursively($child, $depth + 1, $idMap, $changeMap);
}
}
}
/**
* Update the all links in the given xpath to apply requires changes within the
* given $changeMap array.
*/
protected function updateLinks(DOMXPath $xpath, array $changeMap): void
{
if (empty($changeMap)) {
return;
}
$links = $xpath->query('//body//*//*[@href]');
/** @var DOMElement $domElem */
foreach ($links as $domElem) {
$href = ltrim($domElem->getAttribute('href'), '#');
$newHref = $changeMap[$href] ?? null;
if ($newHref) {
$domElem->setAttribute('href', '#' . $newHref);
}
}
}
/**
* Set a unique id on the given DOMElement.
* A map for existing ID's should be passed in to check for current existence.
* A map for existing ID's should be passed in to check for current existence,
* and this will be updated with any new IDs set upon elements.
* Returns a pair of strings in the format [old_id, new_id].
*/
protected function setUniqueId(DOMNode $element, array &$idMap): array
@@ -247,7 +256,7 @@ class PageContent
// Stop if there's an existing valid id that has not already been used.
$existingId = $element->getAttribute('id');
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
if (str_starts_with($existingId, 'bkmrk') && !isset($idMap[$existingId])) {
$idMap[$existingId] = true;
return [$existingId, $existingId];
@@ -258,7 +267,7 @@ class PageContent
// the same content is passed through.
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
$newId = urlencode($contentId);
$loopIndex = 0;
$loopIndex = 1;
while (isset($idMap[$newId])) {
$newId = urlencode($contentId . '-' . $loopIndex);
@@ -440,8 +449,8 @@ class PageContent
{
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$html = '<body>' . $html . '</body>';
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
$doc->loadHTML($html);
return $doc;
}

View File

@@ -2,25 +2,18 @@
namespace BookStack\Exceptions;
use Whoops\Handler\Handler;
use Illuminate\Contracts\Foundation\ExceptionRenderer;
class WhoopsBookStackPrettyHandler extends Handler
class BookStackExceptionHandlerPage implements ExceptionRenderer
{
/**
* @return int|null A handler may return nothing, or a Handler::HANDLE_* constant
*/
public function handle()
public function render($throwable)
{
$exception = $this->getException();
echo view('errors.debug', [
'error' => $exception->getMessage(),
'errorClass' => get_class($exception),
'trace' => $exception->getTraceAsString(),
return view('errors.debug', [
'error' => $throwable->getMessage(),
'errorClass' => get_class($throwable),
'trace' => $throwable->getTraceAsString(),
'environment' => $this->getEnvironment(),
])->render();
return Handler::QUIT;
}
protected function safeReturn(callable $callback, $default = null)

View File

@@ -17,7 +17,7 @@ class Handler extends ExceptionHandler
/**
* A list of the exception types that are not reported.
*
* @var array
* @var array<int, class-string<\Throwable>>
*/
protected $dontReport = [
NotFoundException::class,
@@ -25,9 +25,9 @@ class Handler extends ExceptionHandler
];
/**
* A list of the inputs that are never flashed for validation exceptions.
* A list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
@@ -98,6 +98,7 @@ class Handler extends ExceptionHandler
];
if ($e instanceof ValidationException) {
$responseData['error']['message'] = 'The given data was invalid.';
$responseData['error']['validation'] = $e->errors();
$code = $e->status;
}

View File

@@ -32,10 +32,15 @@ abstract class ApiController extends Controller
*/
public function getValidationRules(): array
{
if (method_exists($this, 'rules')) {
return $this->rules();
}
return $this->rules();
}
/**
* Get the validation rules for the actions in this controller.
* Defaults to a $rules property but can be a rules() method.
*/
protected function rules(): array
{
return $this->rules;
}
}

View File

@@ -13,11 +13,9 @@ use Illuminate\Validation\ValidationException;
class AttachmentApiController extends ApiController
{
protected $attachmentService;
public function __construct(AttachmentService $attachmentService)
{
$this->attachmentService = $attachmentService;
public function __construct(
protected AttachmentService $attachmentService
) {
}
/**
@@ -174,13 +172,13 @@ class AttachmentApiController extends ApiController
'name' => ['required', 'min:1', 'max:255', 'string'],
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
'link' => ['required_without:file', 'min:1', 'max:255', 'safe_url'],
'link' => ['required_without:file', 'min:1', 'max:2000', 'safe_url'],
],
'update' => [
'name' => ['min:1', 'max:255', 'string'],
'uploaded_to' => ['integer', 'exists:pages,id'],
'file' => $this->attachmentService->getFileValidationRules(),
'link' => ['min:1', 'max:255', 'safe_url'],
'link' => ['min:1', 'max:2000', 'safe_url'],
],
];
}

View File

@@ -0,0 +1,136 @@
<?php
namespace BookStack\Http\Controllers\Api;
use BookStack\Auth\Permissions\PermissionsRepo;
use BookStack\Auth\Role;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class RoleApiController extends ApiController
{
protected PermissionsRepo $permissionsRepo;
protected array $fieldsToExpose = [
'display_name', 'description', 'mfa_enforced', 'external_auth_id', 'created_at', 'updated_at',
];
protected $rules = [
'create' => [
'display_name' => ['required', 'string', 'min:3', 'max:180'],
'description' => ['string', 'max:180'],
'mfa_enforced' => ['boolean'],
'external_auth_id' => ['string'],
'permissions' => ['array'],
'permissions.*' => ['string'],
],
'update' => [
'display_name' => ['string', 'min:3', 'max:180'],
'description' => ['string', 'max:180'],
'mfa_enforced' => ['boolean'],
'external_auth_id' => ['string'],
'permissions' => ['array'],
'permissions.*' => ['string'],
]
];
public function __construct(PermissionsRepo $permissionsRepo)
{
$this->permissionsRepo = $permissionsRepo;
// Checks for all endpoints in this controller
$this->middleware(function ($request, $next) {
$this->checkPermission('user-roles-manage');
return $next($request);
});
}
/**
* Get a listing of roles in the system.
* Requires permission to manage roles.
*/
public function list()
{
$roles = Role::query()->select(['*'])
->withCount(['users', 'permissions']);
return $this->apiListingResponse($roles, [
...$this->fieldsToExpose,
'permissions_count',
'users_count',
]);
}
/**
* Create a new role in the system.
* Permissions should be provided as an array of permission name strings.
* Requires permission to manage roles.
*/
public function create(Request $request)
{
$data = $this->validate($request, $this->rules()['create']);
$role = null;
DB::transaction(function () use ($data, &$role) {
$role = $this->permissionsRepo->saveNewRole($data);
});
$this->singleFormatter($role);
return response()->json($role);
}
/**
* View the details of a single role.
* Provides the permissions and a high-level list of the users assigned.
* Requires permission to manage roles.
*/
public function read(string $id)
{
$user = $this->permissionsRepo->getRoleById($id);
$this->singleFormatter($user);
return response()->json($user);
}
/**
* Update an existing role in the system.
* Permissions should be provided as an array of permission name strings.
* An empty "permissions" array would clear granted permissions.
* In many cases, where permissions are changed, you'll want to fetch the existing
* permissions and then modify before providing in your update request.
* Requires permission to manage roles.
*/
public function update(Request $request, string $id)
{
$data = $this->validate($request, $this->rules()['update']);
$role = $this->permissionsRepo->updateRole($id, $data);
$this->singleFormatter($role);
return response()->json($role);
}
/**
* Delete a role from the system.
* Requires permission to manage roles.
*/
public function delete(string $id)
{
$this->permissionsRepo->deleteRole(intval($id));
return response('', 204);
}
/**
* Format the given role model for single-result display.
*/
protected function singleFormatter(Role $role)
{
$role->load('users:id,name,slug');
$role->unsetRelation('permissions');
$role->setAttribute('permissions', $role->permissions()->orderBy('name', 'asc')->pluck('name'));
$role->makeVisible(['users', 'permissions']);
}
}

View File

@@ -13,9 +13,9 @@ use Illuminate\Validation\Rules\Unique;
class UserApiController extends ApiController
{
protected $userRepo;
protected UserRepo $userRepo;
protected $fieldsToExpose = [
protected array $fieldsToExpose = [
'email', 'created_at', 'updated_at', 'last_activity_at', 'external_auth_id',
];

View File

@@ -15,16 +15,10 @@ use Illuminate\Validation\ValidationException;
class AttachmentController extends Controller
{
protected AttachmentService $attachmentService;
protected PageRepo $pageRepo;
/**
* AttachmentController constructor.
*/
public function __construct(AttachmentService $attachmentService, PageRepo $pageRepo)
{
$this->attachmentService = $attachmentService;
$this->pageRepo = $pageRepo;
public function __construct(
protected AttachmentService $attachmentService,
protected PageRepo $pageRepo
) {
}
/**
@@ -112,7 +106,7 @@ class AttachmentController extends Controller
try {
$this->validate($request, [
'attachment_edit_name' => ['required', 'string', 'min:1', 'max:255'],
'attachment_edit_url' => ['string', 'min:1', 'max:255', 'safe_url'],
'attachment_edit_url' => ['string', 'min:1', 'max:2000', 'safe_url'],
]);
} catch (ValidationException $exception) {
return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [
@@ -148,7 +142,7 @@ class AttachmentController extends Controller
$this->validate($request, [
'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'],
'attachment_link_url' => ['required', 'string', 'min:1', 'max:255', 'safe_url'],
'attachment_link_url' => ['required', 'string', 'min:1', 'max:2000', 'safe_url'],
]);
} catch (ValidationException $exception) {
return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [

View File

@@ -64,7 +64,7 @@ class BookshelfController extends Controller
public function create()
{
$this->checkPermission('bookshelf-create-all');
$books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug']);
$books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
$this->setPageTitle(trans('entities.shelves_create'));
return view('shelves.create', ['books' => $books]);
@@ -140,7 +140,7 @@ class BookshelfController extends Controller
$this->checkOwnablePermission('bookshelf-update', $shelf);
$shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
$books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug']);
$books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
$this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));

View File

@@ -10,6 +10,7 @@ use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Tools\PageContent;
use BookStack\Uploads\FaviconHandler;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
@@ -127,4 +128,15 @@ class HomeController extends Controller
{
return response()->view('errors.404', [], 404);
}
/**
* Serve the application favicon.
* Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served
* directly by the webserver in the future.
*/
public function favicon(FaviconHandler $favicons)
{
$exists = $favicons->restoreOriginalIfNotExists();
return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath());
}
}

View File

@@ -66,14 +66,19 @@ class DrawioImageController extends Controller
*/
public function getAsBase64($id)
{
$image = $this->imageRepo->getById($id);
if (is_null($image) || $image->type !== 'drawio' || !userCan('page-view', $image->getPage())) {
return $this->jsonError('Image data could not be found');
try {
$image = $this->imageRepo->getById($id);
} catch (Exception $exception) {
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
}
if ($image->type !== 'drawio' || !userCan('page-view', $image->getPage())) {
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
}
$imageData = $this->imageRepo->getImageData($image);
if (is_null($imageData)) {
return $this->jsonError('Image data could not be found');
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
}
return response()->json([

View File

@@ -74,13 +74,17 @@ class RoleController extends Controller
public function store(Request $request)
{
$this->checkPermission('user-roles-manage');
$this->validate($request, [
$data = $this->validate($request, [
'display_name' => ['required', 'min:3', 'max:180'],
'description' => ['max:180'],
'external_auth_id' => ['string'],
'permissions' => ['array'],
'mfa_enforced' => ['string'],
]);
$this->permissionsRepo->saveNewRole($request->all());
$this->showSuccessNotification(trans('settings.role_create_success'));
$data['permissions'] = array_keys($data['permissions'] ?? []);
$data['mfa_enforced'] = ($data['mfa_enforced'] ?? 'false') === 'true';
$this->permissionsRepo->saveNewRole($data);
return redirect('/settings/roles');
}
@@ -100,19 +104,21 @@ class RoleController extends Controller
/**
* Updates a user role.
*
* @throws ValidationException
*/
public function update(Request $request, string $id)
{
$this->checkPermission('user-roles-manage');
$this->validate($request, [
$data = $this->validate($request, [
'display_name' => ['required', 'min:3', 'max:180'],
'description' => ['max:180'],
'external_auth_id' => ['string'],
'permissions' => ['array'],
'mfa_enforced' => ['string'],
]);
$this->permissionsRepo->updateRole($id, $request->all());
$this->showSuccessNotification(trans('settings.role_update_success'));
$data['permissions'] = array_keys($data['permissions'] ?? []);
$data['mfa_enforced'] = ($data['mfa_enforced'] ?? 'false') === 'true';
$this->permissionsRepo->updateRole($id, $data);
return redirect('/settings/roles');
}
@@ -145,15 +151,13 @@ class RoleController extends Controller
$this->checkPermission('user-roles-manage');
try {
$this->permissionsRepo->deleteRole($id, $request->get('migrate_role_id'));
$this->permissionsRepo->deleteRole($id, $request->get('migrate_role_id', 0));
} catch (PermissionsException $e) {
$this->showErrorNotification($e->getMessage());
return redirect()->back();
}
$this->showSuccessNotification(trans('settings.role_delete_success'));
return redirect('/settings/roles');
}
}

View File

@@ -4,20 +4,14 @@ namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Auth\User;
use BookStack\Settings\AppSettingsStore;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\Request;
class SettingController extends Controller
{
protected ImageRepo $imageRepo;
protected array $settingCategories = ['features', 'customization', 'registration'];
public function __construct(ImageRepo $imageRepo)
{
$this->imageRepo = $imageRepo;
}
/**
* Handle requests to the settings index path.
*/
@@ -48,37 +42,17 @@ class SettingController extends Controller
/**
* Update the specified settings in storage.
*/
public function update(Request $request, string $category)
public function update(Request $request, AppSettingsStore $store, string $category)
{
$this->ensureCategoryExists($category);
$this->preventAccessInDemoMode();
$this->checkPermission('settings-manage');
$this->validate($request, [
'app_logo' => array_merge(['nullable'], $this->getImageValidationRules()),
'app_logo' => ['nullable', ...$this->getImageValidationRules()],
'app_icon' => ['nullable', ...$this->getImageValidationRules()],
]);
// Cycles through posted settings and update them
foreach ($request->all() as $name => $value) {
$key = str_replace('setting-', '', trim($name));
if (strpos($name, 'setting-') !== 0) {
continue;
}
setting()->put($key, $value);
}
// Update logo image if set
if ($category === 'customization' && $request->hasFile('app_logo')) {
$logoFile = $request->file('app_logo');
$this->imageRepo->destroyByType('system');
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
setting()->put('app-logo', $image->url);
}
// Clear logo image if requested
if ($category === 'customization' && $request->get('app_logo_reset', null)) {
$this->imageRepo->destroyByType('system');
setting()->remove('app-logo');
}
$store->storeFromUpdateRequest($request, $category);
$this->logActivity(ActivityType::SETTINGS_UPDATE, $category);
$this->showSuccessNotification(trans('settings.settings_save_success'));

View File

@@ -164,6 +164,8 @@ class UserController extends Controller
// Delete the profile image if reset option is in request
if ($request->has('profile_image_reset')) {
$this->imageRepo->destroyImage($user->avatar);
$user->image_id = 0;
$user->save();
}
$redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}";

View File

@@ -9,10 +9,8 @@ class Request extends LaravelRequest
/**
* Override the default request methods to get the scheme and host
* to directly use the custom APP_URL, if set.
*
* @return string
*/
public function getSchemeAndHttpHost()
public function getSchemeAndHttpHost(): string
{
$appUrl = config('app.url', null);
@@ -27,10 +25,8 @@ class Request extends LaravelRequest
* Override the default request methods to get the base URL
* to directly use the custom APP_URL, if set.
* The base URL never ends with a / but should start with one if not empty.
*
* @return string
*/
public function getBaseUrl()
public function getBaseUrl(): string
{
$appUrl = config('app.url', null);

View File

@@ -8,16 +8,16 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\WhoopsBookStackPrettyHandler;
use BookStack\Exceptions\BookStackExceptionHandlerPage;
use BookStack\Settings\SettingService;
use BookStack\Util\CspService;
use GuzzleHttp\Client;
use Illuminate\Contracts\Foundation\ExceptionRenderer;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Psr\Http\Client\ClientInterface as HttpClientInterface;
use Whoops\Handler\HandlerInterface;
class AppServiceProvider extends ServiceProvider
{
@@ -26,7 +26,7 @@ class AppServiceProvider extends ServiceProvider
* @var string[]
*/
public $bindings = [
HandlerInterface::class => WhoopsBookStackPrettyHandler::class,
ExceptionRenderer::class => BookStackExceptionHandlerPage::class,
];
/**

View File

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

View File

@@ -77,7 +77,7 @@ class RouteServiceProvider extends ServiceProvider
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}
}

View File

@@ -3,10 +3,41 @@
namespace BookStack\Providers;
use BookStack\Translation\FileLoader;
use BookStack\Translation\MessageSelector;
use Illuminate\Translation\TranslationServiceProvider as BaseProvider;
use Illuminate\Translation\Translator;
class TranslationServiceProvider extends BaseProvider
{
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$this->registerLoader();
// This is a tweak upon Laravel's based translation service registration to allow
// usage of a custom MessageSelector class
$this->app->singleton('translator', function ($app) {
$loader = $app['translation.loader'];
// When registering the translator component, we'll need to set the default
// locale as well as the fallback locale. So, we'll grab the application
// configuration so we can easily get both of these values from there.
$locale = $app['config']['app.locale'];
$trans = new Translator($loader, $locale);
$trans->setFallback($app['config']['app.fallback_locale']);
$trans->setSelector(new MessageSelector());
return $trans;
});
}
/**
* Register the translation line loader.
* Overrides the default register action from Laravel so a custom loader can be used.

View File

@@ -21,8 +21,8 @@ class ValidationRuleServiceProvider extends ServiceProvider
Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) {
$cleanLinkName = strtolower(trim($value));
$isJs = strpos($cleanLinkName, 'javascript:') === 0;
$isData = strpos($cleanLinkName, 'data:') === 0;
$isJs = str_starts_with($cleanLinkName, 'javascript:');
$isData = str_starts_with($cleanLinkName, 'data:');
return !$isJs && !$isData;
});

View File

@@ -54,10 +54,10 @@ class CrossLinkParser
{
$links = [];
$html = '<body>' . $html . '</body>';
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$doc->loadHTML($html);
$xPath = new DOMXPath($doc);
$anchors = $xPath->query('//a[@href]');

View File

@@ -2,7 +2,9 @@
namespace BookStack\References;
use BookStack\Auth\Permissions\JointPermission;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
@@ -24,4 +26,10 @@ class Reference extends Model
{
return $this->morphTo('to');
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'from_id')
->whereColumn('references.from_type', '=', 'joint_permissions.entity_type');
}
}

View File

@@ -5,6 +5,7 @@ namespace BookStack\References;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\Relation;
@@ -23,8 +24,7 @@ class ReferenceFetcher
*/
public function getPageReferencesToEntity(Entity $entity): Collection
{
$baseQuery = $entity->referencesTo()
->where('from_type', '=', (new Page())->getMorphClass())
$baseQuery = $this->queryPageReferencesToEntity($entity)
->with([
'from' => fn (Relation $query) => $query->select(Page::$listAttributes),
'from.book' => fn (Relation $query) => $query->scopes('visible'),
@@ -47,11 +47,8 @@ class ReferenceFetcher
*/
public function getPageReferenceCountToEntity(Entity $entity): int
{
$baseQuery = $entity->referencesTo()
->where('from_type', '=', (new Page())->getMorphClass());
$count = $this->permissions->restrictEntityRelationQuery(
$baseQuery,
$this->queryPageReferencesToEntity($entity),
'references',
'from_id',
'from_type'
@@ -59,4 +56,12 @@ class ReferenceFetcher
return $count;
}
protected function queryPageReferencesToEntity(Entity $entity): Builder
{
return Reference::query()
->where('to_type', '=', $entity->getMorphClass())
->where('to_id', '=', $entity->id)
->where('from_type', '=', (new Page())->getMorphClass());
}
}

View File

@@ -15,25 +15,18 @@ class SearchIndex
{
/**
* A list of delimiter characters used to break-up parsed content into terms for indexing.
*
* @var string
*/
public static $delimiters = " \n\t.,!?:;()[]{}<>`'\"";
public static string $delimiters = " \n\t.,!?:;()[]{}<>`'\"";
/**
* @var EntityProvider
*/
protected $entityProvider;
public function __construct(EntityProvider $entityProvider)
{
$this->entityProvider = $entityProvider;
public function __construct(
protected EntityProvider $entityProvider
) {
}
/**
* Index the given entity.
*/
public function indexEntity(Entity $entity)
public function indexEntity(Entity $entity): void
{
$this->deleteEntityTerms($entity);
$terms = $this->entityToTermDataArray($entity);
@@ -45,7 +38,7 @@ class SearchIndex
*
* @param Entity[] $entities
*/
public function indexEntities(array $entities)
public function indexEntities(array $entities): void
{
$terms = [];
foreach ($entities as $entity) {
@@ -69,7 +62,7 @@ class SearchIndex
*
* @param callable(Entity, int, int):void|null $progressCallback
*/
public function indexAllEntities(?callable $progressCallback = null)
public function indexAllEntities(?callable $progressCallback = null): void
{
SearchTerm::query()->truncate();
@@ -101,7 +94,7 @@ class SearchIndex
/**
* Delete related Entity search terms.
*/
public function deleteEntityTerms(Entity $entity)
public function deleteEntityTerms(Entity $entity): void
{
$entity->searchTerms()->delete();
}
@@ -112,12 +105,12 @@ class SearchIndex
*
* @returns array<string, int>
*/
protected function generateTermScoreMapFromText(string $text, int $scoreAdjustment = 1): array
protected function generateTermScoreMapFromText(string $text, float $scoreAdjustment = 1): array
{
$termMap = $this->textToTermCountMap($text);
foreach ($termMap as $term => $count) {
$termMap[$term] = $count * $scoreAdjustment;
$termMap[$term] = floor($count * $scoreAdjustment);
}
return $termMap;
@@ -145,12 +138,12 @@ class SearchIndex
'h6' => 1.5,
];
$html = '<body>' . $html . '</body>';
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
$html = str_ireplace(['<br>', '<br />', '<br/>'], "\n", $html);
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$doc->loadHTML($html);
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
/** @var DOMNode $child */

View File

@@ -0,0 +1,95 @@
<?php
namespace BookStack\Settings;
use BookStack\Uploads\FaviconHandler;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\Request;
class AppSettingsStore
{
public function __construct(
protected ImageRepo $imageRepo,
protected FaviconHandler $faviconHandler,
) {
}
public function storeFromUpdateRequest(Request $request, string $category)
{
$this->storeSimpleSettings($request);
if ($category === 'customization') {
$this->updateAppLogo($request);
$this->updateAppIcon($request);
}
}
protected function updateAppIcon(Request $request): void
{
$sizes = [180, 128, 64, 32];
// Update icon image if set
if ($request->hasFile('app_icon')) {
$iconFile = $request->file('app_icon');
$this->destroyExistingSettingImage('app-icon');
$image = $this->imageRepo->saveNew($iconFile, 'system', 0, 256, 256);
setting()->put('app-icon', $image->url);
foreach ($sizes as $size) {
$this->destroyExistingSettingImage('app-icon-' . $size);
$icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size);
setting()->put('app-icon-' . $size, $icon->url);
}
$this->faviconHandler->saveForUploadedImage($iconFile);
}
// Clear icon image if requested
if ($request->get('app_icon_reset')) {
$this->destroyExistingSettingImage('app-icon');
setting()->remove('app-icon');
foreach ($sizes as $size) {
$this->destroyExistingSettingImage('app-icon-' . $size);
setting()->remove('app-icon-' . $size);
}
$this->faviconHandler->restoreOriginal();
}
}
protected function updateAppLogo(Request $request): void
{
// Update logo image if set
if ($request->hasFile('app_logo')) {
$logoFile = $request->file('app_logo');
$this->destroyExistingSettingImage('app-logo');
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
setting()->put('app-logo', $image->url);
}
// Clear logo image if requested
if ($request->get('app_logo_reset')) {
$this->destroyExistingSettingImage('app-logo');
setting()->remove('app-logo');
}
}
protected function storeSimpleSettings(Request $request): void
{
foreach ($request->all() as $name => $value) {
if (strpos($name, 'setting-') !== 0) {
continue;
}
$key = str_replace('setting-', '', trim($name));
setting()->put($key, $value);
}
}
protected function destroyExistingSettingImage(string $settingKey)
{
$existingVal = setting()->get($settingKey);
if ($existingVal) {
$this->imageRepo->destroyByUrlAndType($existingVal, 'system');
}
}
}

View File

@@ -3,49 +3,29 @@
namespace BookStack\Settings;
use BookStack\Auth\User;
use Illuminate\Contracts\Cache\Repository as Cache;
/**
* Class SettingService
* The settings are a simple key-value database store.
* For non-authenticated users, user settings are stored via the session instead.
* A local array-based cache is used to for setting accesses across a request.
*/
class SettingService
{
protected $setting;
protected $cache;
protected $localCache = [];
protected $cachePrefix = 'setting-';
/**
* SettingService constructor.
*/
public function __construct(Setting $setting, Cache $cache)
{
$this->setting = $setting;
$this->cache = $cache;
}
protected array $localCache = [];
/**
* Gets a setting from the database,
* If not found, Returns default, Which is false by default.
*/
public function get(string $key, $default = null)
public function get(string $key, $default = null): mixed
{
if (is_null($default)) {
$default = config('setting-defaults.' . $key, false);
}
if (isset($this->localCache[$key])) {
return $this->localCache[$key];
}
$value = $this->getValueFromStore($key) ?? $default;
$formatted = $this->formatValue($value, $default);
$this->localCache[$key] = $formatted;
return $formatted;
return $this->formatValue($value, $default);
}
/**
@@ -83,52 +63,78 @@ class SettingService
}
/**
* Gets a setting value from the cache or database.
* Looks at the system defaults if not cached or in database.
* Returns null if nothing is found.
* Gets a setting value from the local cache.
* Will load the local cache if not previously loaded.
*/
protected function getValueFromStore(string $key)
protected function getValueFromStore(string $key): mixed
{
// Check the cache
$cacheKey = $this->cachePrefix . $key;
$cacheVal = $this->cache->get($cacheKey, null);
if ($cacheVal !== null) {
return $cacheVal;
$cacheCategory = $this->localCacheCategory($key);
if (!isset($this->localCache[$cacheCategory])) {
$this->loadToLocalCache($cacheCategory);
}
// Check the database
$settingObject = $this->getSettingObjectByKey($key);
if ($settingObject !== null) {
$value = $settingObject->value;
if ($settingObject->type === 'array') {
$value = json_decode($value, true) ?? [];
}
$this->cache->forever($cacheKey, $value);
return $value;
}
return null;
return $this->localCache[$cacheCategory][$key] ?? null;
}
/**
* Clear an item from the cache completely.
* Put the given value into the local cached under the given key.
*/
protected function clearFromCache(string $key)
protected function putValueIntoLocalCache(string $key, mixed $value): void
{
$cacheKey = $this->cachePrefix . $key;
$this->cache->forget($cacheKey);
if (isset($this->localCache[$key])) {
unset($this->localCache[$key]);
$cacheCategory = $this->localCacheCategory($key);
if (!isset($this->localCache[$cacheCategory])) {
$this->loadToLocalCache($cacheCategory);
}
$this->localCache[$cacheCategory][$key] = $value;
}
/**
* Get the category for the given setting key.
* Will return 'app' for a general app setting otherwise 'user:<user_id>' for a user setting.
*/
protected function localCacheCategory(string $key): string
{
if (str_starts_with($key, 'user:')) {
return implode(':', array_slice(explode(':', $key), 0, 2));
}
return 'app';
}
/**
* For the given category, load the relevant settings from the database into the local cache.
*/
protected function loadToLocalCache(string $cacheCategory): void
{
$query = Setting::query();
if ($cacheCategory === 'app') {
$query->where('setting_key', 'not like', 'user:%');
} else {
$query->where('setting_key', 'like', $cacheCategory . ':%');
}
$settings = $query->toBase()->get();
if (!isset($this->localCache[$cacheCategory])) {
$this->localCache[$cacheCategory] = [];
}
foreach ($settings as $setting) {
$value = $setting->value;
if ($setting->type === 'array') {
$value = json_decode($value, true) ?? [];
}
$this->localCache[$cacheCategory][$setting->setting_key] = $value;
}
}
/**
* Format a settings value.
*/
protected function formatValue($value, $default)
protected function formatValue(mixed $value, mixed $default): mixed
{
// Change string booleans to actual booleans
if ($value === 'true') {
@@ -159,21 +165,22 @@ class SettingService
* Add a setting to the database.
* Values can be an array or a string.
*/
public function put(string $key, $value): bool
public function put(string $key, mixed $value): bool
{
$setting = $this->setting->newQuery()->firstOrNew([
$setting = Setting::query()->firstOrNew([
'setting_key' => $key,
]);
$setting->type = 'string';
$setting->value = $value;
if (is_array($value)) {
$setting->type = 'array';
$value = $this->formatArrayValue($value);
$setting->value = $this->formatArrayValue($value);
}
$setting->value = $value;
$setting->save();
$this->clearFromCache($key);
$this->putValueIntoLocalCache($key, $value);
return true;
}
@@ -213,7 +220,7 @@ class SettingService
* Can only take string value types since this may use
* the session which is less flexible to data types.
*/
public function putForCurrentUser(string $key, string $value)
public function putForCurrentUser(string $key, string $value): bool
{
return $this->putUser(user(), $key, $value);
}
@@ -235,15 +242,19 @@ class SettingService
if ($setting) {
$setting->delete();
}
$this->clearFromCache($key);
$cacheCategory = $this->localCacheCategory($key);
if (isset($this->localCache[$cacheCategory])) {
unset($this->localCache[$cacheCategory][$key]);
}
}
/**
* Delete settings for a given user id.
*/
public function deleteUserSettings(string $userId)
public function deleteUserSettings(string $userId): void
{
return $this->setting->newQuery()
Setting::query()
->where('setting_key', 'like', $this->userKey($userId) . '%')
->delete();
}
@@ -253,7 +264,16 @@ class SettingService
*/
protected function getSettingObjectByKey(string $key): ?Setting
{
return $this->setting->newQuery()
->where('setting_key', '=', $key)->first();
return Setting::query()
->where('setting_key', '=', $key)
->first();
}
/**
* Empty the local setting value cache used by this service.
*/
public function flushCache(): void
{
$this->localCache = [];
}
}

View File

@@ -65,8 +65,8 @@ class ThemeEvents
* 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
* @returns \League\CommonMark\ConfigurableEnvironmentInterface|null
* @param \League\CommonMark\Environment\Environment $environment
* @returns \League\CommonMark\Environment\Environment|null
*/
const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure';

View File

@@ -0,0 +1,19 @@
<?php
namespace BookStack\Translation;
use Illuminate\Translation\MessageSelector as BaseClass;
/**
* This is a customization of the default Laravel MessageSelector class to tweak pluralization,
* so that is uses just the first part of the locale string to provide support with
* non-standard locales such as "de_informal".
*/
class MessageSelector extends BaseClass
{
public function getPluralIndex($locale, $number)
{
$locale = explode('_', $locale)[0];
return parent::getPluralIndex($locale, $number);
}
}

View File

@@ -2,6 +2,7 @@
namespace BookStack\Uploads;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
@@ -9,7 +10,9 @@ use BookStack\Entities\Models\Page;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
@@ -27,6 +30,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Attachment extends Model
{
use HasCreatorAndUpdater;
use HasFactory;
protected $fillable = ['name', 'order'];
protected $hidden = ['path', 'page'];
@@ -36,12 +40,10 @@ class Attachment extends Model
/**
* Get the downloadable file name for this upload.
*
* @return mixed|string
*/
public function getFileName()
public function getFileName(): string
{
if (strpos($this->name, '.') !== false) {
if (str_contains($this->name, '.')) {
return $this->name;
}
@@ -56,12 +58,18 @@ class Attachment extends Model
return $this->belongsTo(Page::class, 'uploaded_to');
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'uploaded_to')
->where('joint_permissions.entity_type', '=', 'page');
}
/**
* Get the url of this file.
*/
public function getUrl($openInline = false): string
{
if ($this->external && strpos($this->path, 'http') !== 0) {
if ($this->external && !str_starts_with($this->path, 'http')) {
return $this->path;
}

View File

@@ -9,7 +9,7 @@ use Illuminate\Contracts\Filesystem\Filesystem as Storage;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use League\Flysystem\Util;
use League\Flysystem\WhitespacePathNormalizer;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService
@@ -54,7 +54,7 @@ class AttachmentService
*/
protected function adjustPathForStorageDisk(string $path): string
{
$path = Util::normalizePath(str_replace('uploads/files/', '', $path));
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
if ($this->getStorageDiskName() === 'local_secure_attachments') {
return $path;

View File

@@ -0,0 +1,110 @@
<?php
namespace BookStack\Uploads;
use Illuminate\Http\UploadedFile;
use Intervention\Image\ImageManager;
class FaviconHandler
{
protected string $path;
public function __construct(
protected ImageManager $imageTool
) {
$this->path = public_path('favicon.ico');
}
/**
* Save the given UploadedFile instance as the application favicon.
*/
public function saveForUploadedImage(UploadedFile $file): void
{
if (!is_writeable($this->path)) {
return;
}
$imageData = file_get_contents($file->getRealPath());
$image = $this->imageTool->make($imageData);
$image->resize(32, 32);
$bmpData = $image->encode('png');
$icoData = $this->pngToIco($bmpData, 32, 32);
file_put_contents($this->path, $icoData);
}
/**
* Restore the original favicon image.
* Returned boolean indicates if the copy occurred.
*/
public function restoreOriginal(): bool
{
$permissionItem = file_exists($this->path) ? $this->path : dirname($this->path);
if (!is_writeable($permissionItem)) {
return false;
}
return copy($this->getOriginalPath(), $this->path);
}
/**
* Restore the original favicon image if no favicon image is already in use.
* Returns a boolean to indicate if the file exists.
*/
public function restoreOriginalIfNotExists(): bool
{
if (file_exists($this->path)) {
return true;
}
return $this->restoreOriginal();
}
/**
* Get the path to the favicon file.
*/
public function getPath(): string
{
return $this->path;
}
/**
* Get the path of the original favicon copy.
*/
public function getOriginalPath(): string
{
return public_path('icon.ico');
}
/**
* Convert PNG image data to ICO file format.
* Built following the file format info from Wikipedia:
* https://en.wikipedia.org/wiki/ICO_(file_format)
*/
protected function pngToIco(string $bmpData, int $width, int $height): string
{
// ICO header
$header = pack('v', 0x00); // Reserved. Must always be 0
$header .= pack('v', 0x01); // Specifies ico image
$header .= pack('v', 0x01); // Specifies number of images
// ICO Image Directory
$entry = hex2bin(dechex($width)); // Image width
$entry .= hex2bin(dechex($height)); // Image height
$entry .= "\0"; // Color palette, typically 0
$entry .= "\0"; // Reserved
// Color planes, Appears to remain 1 for bmp image data
$entry .= pack('v', 0x01);
// Bits per pixel, can range from 1 to 32. From testing conversion
// via intervention from png typically provides this as 24.
$entry .= pack('v', 0x00);
// Size of the image data in bytes
$entry .= pack('V', strlen($bmpData));
// Offset of the bmp data from file start
$entry .= pack('V', strlen($header) + strlen($entry) + 4);
// Join & return the combined parts of the ICO image data
return $header . $entry . $bmpData;
}
}

View File

@@ -2,10 +2,12 @@
namespace BookStack\Uploads;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Entities\Models\Page;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
@@ -25,6 +27,12 @@ class Image extends Model
protected $fillable = ['name'];
protected $hidden = [];
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'uploaded_to')
->where('joint_permissions.entity_type', '=', 'page');
}
/**
* Get a thumbnail for this image.
*

View File

@@ -123,7 +123,10 @@ class ImageRepo
public function saveNew(UploadedFile $uploadFile, string $type, int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, bool $keepRatio = true): Image
{
$image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
$this->loadThumbs($image);
if ($type !== 'system') {
$this->loadThumbs($image);
}
return $image;
}
@@ -180,13 +183,17 @@ class ImageRepo
}
/**
* Destroy all images of a certain type.
* Destroy images that have a specific URL and type combination.
*
* @throws Exception
*/
public function destroyByType(string $imageType): void
public function destroyByUrlAndType(string $url, string $imageType): void
{
$images = Image::query()->where('type', '=', $imageType)->get();
$images = Image::query()
->where('url', '=', $url)
->where('type', '=', $imageType)
->get();
foreach ($images as $image) {
$this->destroyImage($image);
}

View File

@@ -20,7 +20,7 @@ use Illuminate\Support\Str;
use Intervention\Image\Exception\NotSupportedException;
use Intervention\Image\Image as InterventionImage;
use Intervention\Image\ImageManager;
use League\Flysystem\Util;
use League\Flysystem\WhitespacePathNormalizer;
use Psr\SimpleCache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -29,10 +29,9 @@ class ImageService
{
protected ImageManager $imageTool;
protected Cache $cache;
protected $storageUrl;
protected FilesystemManager $fileSystem;
protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
public function __construct(ImageManager $imageTool, FilesystemManager $fileSystem, Cache $cache)
{
@@ -73,7 +72,7 @@ class ImageService
*/
protected function adjustPathForStorageDisk(string $path, string $imageType = ''): string
{
$path = Util::normalizePath(str_replace('uploads/images/', '', $path));
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path));
if ($this->usingSecureImages($imageType)) {
return $path;
@@ -548,7 +547,7 @@ class ImageService
// Check the image file exists
&& $disk->exists($imagePath)
// Check the file is likely an image file
&& strpos($disk->getMimetype($imagePath), 'image/') === 0;
&& strpos($disk->mimeType($imagePath), 'image/') === 0;
}
/**
@@ -661,25 +660,21 @@ class ImageService
*/
private function getPublicUrl(string $filePath): string
{
if (is_null($this->storageUrl)) {
$storageUrl = config('filesystems.url');
$storageUrl = config('filesystems.url');
// Get the standard public s3 url if s3 is set as storage type
// Uses the nice, short URL if bucket name has no periods in otherwise the longer
// region-based url will be used to prevent http issues.
if ($storageUrl == false && config('filesystems.images') === 's3') {
$storageDetails = config('filesystems.disks.s3');
if (strpos($storageDetails['bucket'], '.') === false) {
$storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
} else {
$storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
}
// Get the standard public s3 url if s3 is set as storage type
// Uses the nice, short URL if bucket name has no periods in otherwise the longer
// region-based url will be used to prevent http issues.
if (!$storageUrl && config('filesystems.images') === 's3') {
$storageDetails = config('filesystems.disks.s3');
if (strpos($storageDetails['bucket'], '.') === false) {
$storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
} else {
$storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
}
$this->storageUrl = $storageUrl;
}
$basePath = ($this->storageUrl == false) ? url('/') : $this->storageUrl;
$basePath = $storageUrl ?: url('/');
return rtrim($basePath, '/') . $filePath;
}

View File

@@ -126,7 +126,7 @@ class CspService
protected function getAllowedIframeHosts(): array
{
$hosts = config('app.iframe_hosts', '');
$hosts = config('app.iframe_hosts') ?? '';
return array_filter(explode(' ', $hosts));
}

View File

@@ -19,10 +19,10 @@ class HtmlContentFilter
return $html;
}
$html = '<body>' . $html . '</body>';
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$doc->loadHTML($html);
$xPath = new DOMXPath($doc);
// Remove standard script tags

View File

@@ -130,7 +130,7 @@ class LanguageManager
]);
if (!empty($locales)) {
setlocale(LC_TIME, ...$locales);
setlocale(LC_TIME, $locales[0], ...array_slice($locales, 1));
}
}
}

View File

@@ -8,7 +8,7 @@
"license": "MIT",
"type": "project",
"require": {
"php": "^7.4|^8.0",
"php": "^8.0.2",
"ext-curl": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
@@ -19,36 +19,34 @@
"bacon/bacon-qr-code": "^2.0",
"barryvdh/laravel-dompdf": "^2.0",
"barryvdh/laravel-snappy": "^1.0",
"doctrine/dbal": "^3.1",
"filp/whoops": "^2.14",
"doctrine/dbal": "^3.5",
"guzzlehttp/guzzle": "^7.4",
"intervention/image": "^2.7",
"laravel/framework": "^8.68",
"laravel/framework": "^9.0",
"laravel/socialite": "^5.2",
"laravel/tinker": "^2.6",
"league/commonmark": "^1.6",
"league/flysystem-aws-s3-v3": "^1.0.29",
"league/commonmark": "^2.3",
"league/flysystem-aws-s3-v3": "^3.0",
"league/html-to-markdown": "^5.0.0",
"league/oauth2-client": "^2.6",
"onelogin/php-saml": "^4.0",
"phpseclib/phpseclib": "~3.0",
"phpseclib/phpseclib": "^3.0",
"pragmarx/google2fa": "^8.0",
"predis/predis": "^1.1",
"predis/predis": "^2.1",
"socialiteproviders/discord": "^4.1",
"socialiteproviders/gitlab": "^4.1",
"socialiteproviders/microsoft-azure": "^5.0.1",
"socialiteproviders/okta": "^4.1",
"socialiteproviders/microsoft-azure": "^5.1",
"socialiteproviders/okta": "^4.2",
"socialiteproviders/slack": "^4.1",
"socialiteproviders/twitch": "^5.3",
"ssddanbrown/htmldiff": "^1.0.2"
},
"require-dev": {
"brianium/paratest": "^6.6",
"fakerphp/faker": "^1.16",
"fakerphp/faker": "^1.21",
"itsgoingd/clockwork": "^5.1",
"mockery/mockery": "^1.4",
"nunomaduro/collision": "^5.10",
"nunomaduro/larastan": "^1.0",
"mockery/mockery": "^1.5",
"nunomaduro/collision": "^6.4",
"nunomaduro/larastan": "^2.4",
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "^3.7",
"ssddanbrown/asserthtml": "^1.0"
@@ -73,7 +71,6 @@
"format": "phpcbf",
"lint": "phpcs",
"test": "phpunit",
"t": "@php artisan test --parallel",
"t-reset": "@php artisan test --recreate-databases",
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
@@ -102,7 +99,7 @@
"preferred-install": "dist",
"sort-packages": true,
"platform": {
"php": "7.4.0"
"php": "8.0.2"
}
},
"extra": {
@@ -110,6 +107,6 @@
"dont-discover": []
}
},
"minimum-stability": "dev",
"minimum-stability": "stable",
"prefer-stable": true
}

4135
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,6 @@ pull_request_title: Updated translations with latest Crowdin changes
pull_request_labels:
- ":earth_africa: Translations"
files:
- source: /resources/lang/en/*.php
translation: /resources/lang/%two_letters_code%/%original_file_name%
- source: /lang/en/*.php
translation: /lang/%two_letters_code%/%original_file_name%
type: php

View File

@@ -21,7 +21,7 @@ class TagFactory extends Factory
public function definition()
{
return [
'name' => $this->faker->city,
'name' => $this->faker->city(),
'value' => $this->faker->sentence(3),
];
}

View File

@@ -18,7 +18,7 @@ class WebhookFactory extends Factory
{
return [
'name' => 'My webhook for ' . $this->faker->country(),
'endpoint' => $this->faker->url,
'endpoint' => $this->faker->url(),
'active' => true,
'timeout' => 3,
];

View File

@@ -22,11 +22,11 @@ class UserFactory extends Factory
*/
public function definition()
{
$name = $this->faker->name;
$name = $this->faker->name();
return [
'name' => $name,
'email' => $this->faker->email,
'email' => $this->faker->email(),
'slug' => Str::slug($name . '-' . Str::random(5)),
'password' => Str::random(10),
'remember_token' => Str::random(10),

View File

@@ -22,9 +22,9 @@ class BookFactory extends Factory
public function definition()
{
return [
'name' => $this->faker->sentence,
'name' => $this->faker->sentence(),
'slug' => Str::random(10),
'description' => $this->faker->paragraph,
'description' => $this->faker->paragraph(),
];
}
}

View File

@@ -22,9 +22,9 @@ class ChapterFactory extends Factory
public function definition()
{
return [
'name' => $this->faker->sentence,
'name' => $this->faker->sentence(),
'slug' => Str::random(10),
'description' => $this->faker->paragraph,
'description' => $this->faker->paragraph(),
];
}
}

View File

@@ -24,7 +24,7 @@ class PageFactory extends Factory
$html = '<p>' . implode('</p>', $this->faker->paragraphs(5)) . '</p>';
return [
'name' => $this->faker->sentence,
'name' => $this->faker->sentence(),
'slug' => Str::random(10),
'html' => $html,
'text' => strip_tags($html),

View File

@@ -0,0 +1,39 @@
<?php
namespace Database\Factories\Uploads;
use BookStack\Auth\User;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Uploads\Attachment>
*/
class AttachmentFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = \BookStack\Uploads\Attachment::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
'name' => $this->faker->words(2, true),
'path' => $this->faker->url(),
'extension' => '',
'external' => true,
'uploaded_to' => Page::factory(),
'created_by' => User::factory(),
'updated_by' => User::factory(),
'order' => 0,
];
}
}

View File

@@ -21,9 +21,9 @@ class ImageFactory extends Factory
public function definition()
{
return [
'name' => $this->faker->slug . '.jpg',
'url' => $this->faker->url,
'path' => $this->faker->url,
'name' => $this->faker->slug() . '.jpg',
'url' => $this->faker->url(),
'path' => $this->faker->url(),
'type' => 'gallery',
'uploaded_to' => 0,
];

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateUsersTable extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -40,4 +40,4 @@ class CreateUsersTable extends Migration
{
Schema::drop('users');
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreatePasswordResetsTable extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -28,4 +28,4 @@ class CreatePasswordResetsTable extends Migration
{
Schema::drop('password_resets');
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateBooksTable extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -30,4 +30,4 @@ class CreateBooksTable extends Migration
{
Schema::drop('books');
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreatePagesTable extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -34,4 +34,4 @@ class CreatePagesTable extends Migration
{
Schema::drop('pages');
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateImagesTable extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -29,4 +29,4 @@ class CreateImagesTable extends Migration
{
Schema::drop('images');
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateChaptersTable extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -32,4 +32,4 @@ class CreateChaptersTable extends Migration
{
Schema::drop('chapters');
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class AddUsersToEntities extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -54,4 +54,4 @@ class AddUsersToEntities extends Migration
$table->dropColumn('updated_by');
});
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreatePageRevisionsTable extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -32,4 +32,4 @@ class CreatePageRevisionsTable extends Migration
{
Schema::drop('page_revisions');
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateActivitiesTable extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -33,4 +33,4 @@ class CreateActivitiesTable extends Migration
{
Schema::drop('activities');
}
}
};

View File

@@ -1,8 +1,5 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
/**
* Much of this code has been taken from entrust,
* a role & permission management solution for Laravel.
@@ -12,7 +9,11 @@ use Illuminate\Database\Schema\Blueprint;
* @license MIT
* @url https://github.com/Zizaco/entrust
*/
class AddRolesAndPermissions extends Migration
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
return new class extends Migration
{
/**
* Run the migrations.
@@ -147,4 +148,4 @@ class AddRolesAndPermissions extends Migration
Schema::drop('role_user');
Schema::drop('roles');
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateSettingsTable extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -28,4 +28,4 @@ class CreateSettingsTable extends Migration
{
Schema::drop('settings');
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class AddSearchIndexes extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -51,4 +51,4 @@ class AddSearchIndexes extends Migration
});
}
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateSocialAccountsTable extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -31,4 +31,4 @@ class CreateSocialAccountsTable extends Migration
{
Schema::drop('social_accounts');
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class AddEmailConfirmationTable extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -36,4 +36,4 @@ class AddEmailConfirmationTable extends Migration
});
Schema::drop('email_confirmations');
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateViewsTable extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -31,4 +31,4 @@ class CreateViewsTable extends Migration
{
Schema::drop('views');
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class AddEntityIndexes extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -86,4 +86,4 @@ class AddEntityIndexes extends Migration
$table->dropIndex('views_viewable_id_index');
});
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class FulltextWeighting extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -51,4 +51,4 @@ class FulltextWeighting extends Migration
});
}
}
}
};

View File

@@ -4,7 +4,7 @@ use BookStack\Uploads\Image;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class AddImageUploadTypes extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -37,4 +37,4 @@ class AddImageUploadTypes extends Migration
$table->dropColumn('path');
});
}
}
};

View File

@@ -3,7 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class AddUserAvatars extends Migration
return new class extends Migration
{
/**
* Run the migrations.
@@ -28,4 +28,4 @@ class AddUserAvatars extends Migration
$table->dropColumn('image_id');
});
}
}
};

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