Compare commits

..

8 Commits

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

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

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

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

View File

@@ -37,10 +37,8 @@ MAIL_FROM=bookstack@example.com
# SMTP mail options
# These settings can be checked using the "Send a Test Email"
# feature found in the "Settings > Maintenance" area of the system.
# For more detailed documentation on mail options, refer to:
# https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration
MAIL_HOST=localhost
MAIL_PORT=587
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null

View File

@@ -3,10 +3,6 @@
# Each option is shown with it's default value.
# Do not copy this whole file to use as your '.env' file.
# The details here only serve as a quick reference.
# Please refer to the BookStack documentation for full details:
# https://www.bookstackapp.com/docs/
# Application environment
# Can be 'production', 'development', 'testing' or 'demo'
APP_ENV=production
@@ -69,20 +65,20 @@ DB_PASSWORD=database_user_password
# certificate itself (Common Name or Subject Alternative Name).
MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
# Mail configuration
# Refer to https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration
# Mail system to use
# Can be 'smtp' or 'sendmail'
MAIL_DRIVER=smtp
# Mail sending options
MAIL_FROM=mail@bookstackapp.com
MAIL_FROM_NAME=BookStack
# SMTP mail options
MAIL_HOST=localhost
MAIL_PORT=587
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_VERIFY_SSL=true
MAIL_SENDMAIL_COMMAND="/usr/sbin/sendmail -bs"
# Cache & Session driver to use
# Can be 'file', 'database', 'memcached' or 'redis'
@@ -147,10 +143,6 @@ STORAGE_URL=false
# Can be 'standard', 'ldap', 'saml2' or 'oidc'
AUTH_METHOD=standard
# Automatically initiate login via external auth system if it's the only auth method.
# Works with saml2 or oidc auth methods.
AUTH_AUTO_INITIATE=false
# Social authentication configuration
# All disabled by default.
# Refer to https://www.bookstackapp.com/docs/admin/third-party-auth/
@@ -267,12 +259,7 @@ OIDC_ISSUER_DISCOVER=false
OIDC_PUBLIC_KEY=null
OIDC_AUTH_ENDPOINT=null
OIDC_TOKEN_ENDPOINT=null
OIDC_ADDITIONAL_SCOPES=null
OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false
OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false
OIDC_EXTERNAL_ID_CLAIM=sub
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option
@@ -304,7 +291,7 @@ APP_DEFAULT_DARK_MODE=false
# Page revision limit
# Number of page revisions to keep in the system before deleting old revisions.
# If set to 'false' a limit will not be enforced.
REVISION_LIMIT=100
REVISION_LIMIT=50
# Recycle Bin Lifetime
# The number of days that content will remain in the recycle bin before
@@ -323,13 +310,6 @@ FILE_UPLOAD_SIZE_LIMIT=50
# Can be 'a4' or 'letter'.
EXPORT_PAGE_SIZE=a4
# Set path to wkhtmltopdf binary for PDF generation.
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
# When false, BookStack will attempt to find a wkhtmltopdf in the application
# root folder then fall back to the default dompdf renderer if no binary exists.
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
WKHTMLTOPDF=false
# Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false
@@ -373,11 +353,3 @@ API_REQUESTS_PER_MIN=180
# user identifier (Username or email).
LOG_FAILED_LOGIN_MESSAGE=false
LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
# Alter the precision of IP addresses stored by BookStack.
# Should be a number between 0 and 4, where 4 retains the full IP address
# and 0 completely hides the IP address. As an example, a value of 2 for the
# IP address '146.191.42.4' would result in '146.191.x.x' being logged.
# For the IPv6 address '2001:db8:85a3:8d3:1319:8a2e:370:7348' this would result as:
# '2001:db8:85a3:8d3:x:x:x:x'
IP_ADDRESS_PRECISION=4

1
.github/FUNDING.yml vendored
View File

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

View File

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

View File

@@ -55,8 +55,6 @@ Name :: Languages
@Baptistou :: French
@arcoai :: Spanish
@Jokuna :: Korean
@smartshogu :: German; German Informal
@samadha56 :: Persian
cipi1965 :: Italian
Mykola Ronik (Mantikor) :: Ukrainian
furkanoyk :: Turkish
@@ -138,7 +136,7 @@ Xiphoseer :: German
MerlinSVK (merlinsvk) :: Slovak
Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
MatthieuParis :: French
Douradinho :: Portuguese, Brazilian; Portuguese
Douradinho :: Portuguese, Brazilian
Gaku Yaguchi (tama11) :: Japanese
johnroyer :: Chinese Traditional
jackaaa :: Chinese Traditional
@@ -176,7 +174,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: ; French; Dutch; Turkish
REMOVED_USER :: Turkish
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -245,99 +243,3 @@ Shukrullo (vodiylik) :: Uzbek
William W. (Nevnt) :: Chinese Traditional
eamaro :: Portuguese
Ypsilon-dev :: Arabic
Hieu Vuong Trung (vuongtrunghieu) :: Vietnamese
David Clubb (davidoclubb) :: Welsh
welles freire (wellesximenes) :: Portuguese, Brazilian
Magnus Jensen (MagnusHJensen) :: Danish
Hesley Magno (hesleymagno) :: Portuguese, Brazilian
Éric Gaspar (erga) :: French
Fr3shlama :: German
DSR :: Spanish, Argentina
Andrii Bodnar (andrii-bodnar) :: Ukrainian
Younes el Anjri (younesea28) :: Dutch
Guclu Ozturk (gucluoz) :: Turkish
Atmis :: French
redjack666 :: Chinese Traditional
Ashita007 :: Russian
lihaorr :: Chinese Simplified
Marcus Silber (marcus.silber82) :: German
PellNet :: Croatian
Winetradr :: German
Sebastian Klaus (sebklaus) :: German
Filip Antala (AntalaFilip) :: Slovak
mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional
Nanang Setia Budi (sefidananang) :: Indonesian
Андрей Павлов (andrei.pavlov) :: Russian
Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian
Ji-Hyeon Gim (PotatoGim) :: Korean
Mihai Ochian (soulstorm19) :: Romanian
HeartCore :: German Informal; German
simon.pct :: French
okaeiz :: Persian
Naoto Ishikawa (na3shkw) :: Japanese
sdhadi :: Persian
DerLinkman (derlinkman) :: German; German Informal
TurnArabic :: Arabic
Martin Sebek (sebekmartin) :: Czech
Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
digilady :: Greek
Linus (LinusOP) :: Swedish
Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian
RandomUser0815 :: German Informal; German
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
구인회 (laskdjlaskdj12) :: Korean
LiZerui (CNLiZerui) :: Chinese Traditional
Fabrice Boyer (FabriceBoyer) :: French
mikael (bitcanon) :: Swedish
Matthias Mai (schnapsidee) :: German; German Informal
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
Jan Mitrof (jan.kachlik) :: Czech
edwardsmirnov :: Russian
Mr_OSS117 :: French
shotu :: French
Cesar_Lopez_Aguillon :: Spanish
bdewoop :: German
dina davoudi (dina.davoudi) :: Persian
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
Jøran Haugli (haugli92) :: Norwegian Bokmal
Vasileios Kouvelis (VasilisKouvelis) :: Greek
Dremski :: Bulgarian
Frédéric SENE (nothingfr) :: French
bendem :: French
kostasdizas :: Greek
Ricardo Schroeder (brownstone666) :: Portuguese, Brazilian
Eitan MG (EitanMG) :: Hebrew
Robin Flikkema (RobinFlikkema) :: Dutch
Michal Gurcik (mgurcik) :: Slovak
Pooyan Arab (pooyanarab) :: Persian
Ochi Darma Putra (troke12) :: Indonesian
H.-H. Peng (Hsins) :: Chinese Traditional
Mosi Wang (mosiwang) :: Chinese Traditional
骆言 (LawssssCat) :: Chinese Simplified
Stickers Gaming Shøw (StickerSGSHOW) :: French
Le Van Chinh (Chino) (lvanchinh86) :: Vietnamese
Rubens nagios (rubenix) :: Catalan
Patrick Dantas (pa-tiq) :: Portuguese, Brazilian
Michal (michalgurcik) :: Slovak
Nepomacs :: German
Rubens (rubenix) :: Catalan
m4z :: German; German Informal
TheRazvy :: Romanian
Yossi Zilber (lortens) :: Hebrew; Uzbek
desdinova :: French
Ingus Rūķis (ingus.rukis) :: Latvian
Eugene Pershin (SilentEugene) :: Russian
周盛道 (zhoushengdao) :: Chinese Simplified
hamidreza amini (hamidrezaamini2022) :: Persian

View File

@@ -1,16 +0,0 @@
name: lint-js
on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v1
- name: Install NPM deps
run: npm ci
- name: Run formatting check
run: npm run lint

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
name: test-php
name: phpunit
on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['8.0', '8.1', '8.2']
php: ['7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1
@@ -16,19 +16,18 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v1
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
restore-keys: ${{ runner.os }}-composer-
- name: Start Database
run: |
@@ -49,5 +48,5 @@ jobs:
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
- name: Run PHP tests
- name: phpunit
run: php${{ matrix.php }} ./vendor/bin/phpunit

View File

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

13
.gitignore vendored
View File

@@ -1,19 +1,16 @@
/vendor
/node_modules
/.vscode
/composer
Homestead.yaml
.env
.idea
npm-debug.log
yarn-error.log
/public/dist/*.map
/public/dist
/public/plugins
/public/css/*.map
/public/js/*.map
/public/css
/public/js
/public/bower
/public/build/
/public/favicon.ico
/storage/images
_ide_helper.php
/storage/debugbar
@@ -23,10 +20,8 @@ yarn.lock
nbproject
.buildpath
.project
.nvmrc
.settings/
webpack-stats.json
.phpunit.result.cache
.DS_Store
phpstan.neon
esbuild-meta.json
phpstan.neon

View File

@@ -1,6 +1,7 @@
The MIT License (MIT)
Copyright (c) 2015-2023, Dan Brown and the BookStack Project contributors.
Copyright (c) 2015-present, Dan Brown and the BookStack Project contributors
https://github.com/BookStackApp/BookStack/graphs/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

@@ -1,98 +0,0 @@
<?php
namespace BookStack\Access\Controllers;
use BookStack\Access\LoginService;
use BookStack\Activity\ActivityType;
use BookStack\Http\Controller;
use BookStack\Users\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
class ResetPasswordController extends Controller
{
protected LoginService $loginService;
public function __construct(LoginService $loginService)
{
$this->middleware('guest');
$this->middleware('guard:standard');
$this->loginService = $loginService;
}
/**
* Display the password reset view for the given token.
* If no token is present, display the link request form.
*/
public function showResetForm(Request $request)
{
$token = $request->route()->parameter('token');
return view('auth.passwords.reset')->with(
['token' => $token, 'email' => $request->email]
);
}
/**
* Reset the given user's password.
*/
public function reset(Request $request)
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', PasswordRule::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
$user->password = Hash::make($password);
$user->setRememberToken(Str::random(60));
$user->save();
$this->loginService->login($user, auth()->getDefaultDriver());
});
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $response === Password::PASSWORD_RESET
? $this->sendResetResponse()
: $this->sendResetFailedResponse($request, $response);
}
/**
* Get the response for a successful password reset.
*/
protected function sendResetResponse(): RedirectResponse
{
$this->showSuccessNotification(trans('auth.reset_password_success'));
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user());
return redirect('/');
}
/**
* Get the response for a failed password reset.
*/
protected function sendResetFailedResponse(Request $request, string $response): RedirectResponse
{
// We show invalid users as invalid tokens as to not leak what
// users may exist in the system.
if ($response === Password::INVALID_USER) {
$response = Password::INVALID_TOKEN;
}
return redirect()->back()
->withInput($request->only('email'))
->withErrors(['email' => trans($response)]);
}
}

View File

@@ -1,92 +0,0 @@
<?php
namespace BookStack\Access\Controllers;
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
trait ThrottlesLogins
{
/**
* Determine if the user has too many failed login attempts.
*/
protected function hasTooManyLoginAttempts(Request $request): bool
{
return $this->limiter()->tooManyAttempts(
$this->throttleKey($request),
$this->maxAttempts()
);
}
/**
* Increment the login attempts for the user.
*/
protected function incrementLoginAttempts(Request $request): void
{
$this->limiter()->hit(
$this->throttleKey($request),
$this->decayMinutes() * 60
);
}
/**
* Redirect the user after determining they are locked out.
* @throws ValidationException
*/
protected function sendLockoutResponse(Request $request): \Symfony\Component\HttpFoundation\Response
{
$seconds = $this->limiter()->availableIn(
$this->throttleKey($request)
);
throw ValidationException::withMessages([
$this->username() => [trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
])],
])->status(Response::HTTP_TOO_MANY_REQUESTS);
}
/**
* Clear the login locks for the given user credentials.
*/
protected function clearLoginAttempts(Request $request): void
{
$this->limiter()->clear($this->throttleKey($request));
}
/**
* Get the throttle key for the given request.
*/
protected function throttleKey(Request $request): string
{
return Str::transliterate(Str::lower($request->input($this->username())) . '|' . $request->ip());
}
/**
* Get the rate limiter instance.
*/
protected function limiter(): RateLimiter
{
return app(RateLimiter::class);
}
/**
* Get the maximum number of attempts to allow.
*/
public function maxAttempts(): int
{
return 5;
}
/**
* Get the number of minutes to throttle for.
*/
public function decayMinutes(): int
{
return 1;
}
}

View File

@@ -1,110 +0,0 @@
<?php
namespace BookStack\Access;
/**
* Class Ldap
* An object-orientated thin abstraction wrapper for common PHP LDAP functions.
* Allows the standard LDAP functions to be mocked for testing.
*/
class Ldap
{
/**
* Connect to an LDAP server.
*
* @return resource|\LDAP\Connection|false
*/
public function connect(string $hostName)
{
return ldap_connect($hostName);
}
/**
* Set the value of an LDAP option for the given connection.
*
* @param resource|\LDAP\Connection|null $ldapConnection
*/
public function setOption($ldapConnection, int $option, mixed $value): bool
{
return ldap_set_option($ldapConnection, $option, $value);
}
/**
* Start TLS on the given LDAP connection.
*/
public function startTls($ldapConnection): bool
{
return ldap_start_tls($ldapConnection);
}
/**
* Set the version number for the given LDAP connection.
*
* @param resource|\LDAP\Connection $ldapConnection
*/
public function setVersion($ldapConnection, int $version): bool
{
return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
}
/**
* Search LDAP tree using the provided filter.
*
* @param resource|\LDAP\Connection $ldapConnection
*
* @return resource|\LDAP\Result
*/
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = null)
{
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
}
/**
* Get entries from an LDAP search result.
*
* @param resource|\LDAP\Connection $ldapConnection
* @param resource|\LDAP\Result $ldapSearchResult
*/
public function getEntries($ldapConnection, $ldapSearchResult): array|false
{
return ldap_get_entries($ldapConnection, $ldapSearchResult);
}
/**
* Search and get entries immediately.
*
* @param resource|\LDAP\Connection $ldapConnection
*/
public function searchAndGetEntries($ldapConnection, string $baseDn, string $filter, array $attributes = null): array|false
{
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
return $this->getEntries($ldapConnection, $search);
}
/**
* Bind to LDAP directory.
*
* @param resource|\LDAP\Connection $ldapConnection
*/
public function bind($ldapConnection, string $bindRdn = null, string $bindPassword = null): bool
{
return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
}
/**
* Explode an LDAP dn string into an array of components.
*/
public function explodeDn(string $dn, int $withAttrib): array|false
{
return ldap_explode_dn($dn, $withAttrib);
}
/**
* Escape a string for use in an LDAP filter.
*/
public function escape(string $value, string $ignore = '', int $flags = 0): string
{
return ldap_escape($value, $ignore, $flags);
}
}

View File

@@ -1,13 +1,11 @@
<?php
namespace BookStack\Activity\Models;
namespace BookStack\Actions;
use BookStack\App\Model;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Permissions\Models\JointPermission;
use BookStack\Users\Models\User;
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;
@@ -19,8 +17,6 @@ use Illuminate\Support\Str;
* @property string $entity_type
* @property int $entity_id
* @property int $user_id
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Activity extends Model
{
@@ -44,12 +40,6 @@ 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

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
<?php
namespace BookStack\Activity\Models;
namespace BookStack\Actions;
use BookStack\App\Model;
use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphTo;

View File

@@ -1,20 +1,32 @@
<?php
namespace BookStack\Activity;
namespace BookStack\Actions;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Activity as ActivityService;
use League\CommonMark\CommonMarkConverter;
/**
* Class CommentRepo.
*/
class CommentRepo
{
/**
* @var Comment
*/
protected $comment;
public function __construct(Comment $comment)
{
$this->comment = $comment;
}
/**
* Get a comment by ID.
*/
public function getById(int $id): Comment
{
return Comment::query()->findOrFail($id);
return $this->comment->newQuery()->findOrFail($id);
}
/**
@@ -23,7 +35,7 @@ class CommentRepo
public function create(Entity $entity, string $text, ?int $parent_id): Comment
{
$userId = user()->id;
$comment = new Comment();
$comment = $this->comment->newInstance();
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
@@ -70,7 +82,7 @@ class CommentRepo
'allow_unsafe_links' => false,
]);
return $converter->convert($commentText);
return $converter->convertToHtml($commentText);
}
/**
@@ -78,8 +90,9 @@ class CommentRepo
*/
protected function getNextLocalId(Entity $entity): int
{
$currentMaxId = $entity->comments()->max('local_id');
/** @var Comment $comment */
$comment = $entity->comments(false)->orderBy('local_id', 'desc')->first();
return $currentMaxId + 1;
return ($comment->local_id ?? 0) + 1;
}
}

View File

@@ -1,13 +1,11 @@
<?php
namespace BookStack\Activity;
namespace BookStack\Actions;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Tools\WebhookFormatter;
use BookStack\Auth\User;
use BookStack\Facades\Theme;
use BookStack\Interfaces\Loggable;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

19
app/Actions/Favourite.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace BookStack\Actions;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Favourite extends Model
{
protected $fillable = ['user_id'];
/**
* Get the related model that can be favourited.
*/
public function favouritable(): MorphTo
{
return $this->morphTo();
}
}

View File

@@ -1,11 +1,9 @@
<?php
namespace BookStack\Activity\Models;
namespace BookStack\Actions;
use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use BookStack\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
@@ -29,12 +27,6 @@ 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

@@ -1,33 +1,28 @@
<?php
namespace BookStack\Activity;
namespace BookStack\Actions;
use BookStack\Activity\Models\Tag;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Util\SimpleListOptions;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class TagRepo
{
public function __construct(
protected PermissionApplicator $permissions
) {
protected $tag;
protected $permissionService;
public function __construct(PermissionService $ps)
{
$this->permissionService = $ps;
}
/**
* Start a query against all tags in the system.
*/
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
{
$searchTerm = $listOptions->getSearch();
$sort = $listOptions->getSort();
if ($sort === 'name' && $nameFilter) {
$sort = 'value';
}
$query = Tag::query()
->select([
'name',
@@ -38,7 +33,7 @@ class TagRepo
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
])
->orderBy($sort, $listOptions->getOrder());
->orderBy($nameFilter ? 'value' : 'name');
if ($nameFilter) {
$query->where('name', '=', $nameFilter);
@@ -56,28 +51,28 @@ class TagRepo
});
}
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
return $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
}
/**
* Get tag name suggestions from scanning existing tag names.
* If no search term is given the 50 most popular tag names are provided.
*/
public function getNameSuggestions(string $searchTerm): Collection
public function getNameSuggestions(?string $searchTerm): Collection
{
$query = Tag::query()
->select('*', DB::raw('count(*) as count'))
->groupBy('name');
if ($searchTerm) {
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'asc');
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
} else {
$query = $query->orderBy('count', 'desc')->take(50);
}
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->pluck('name');
return $query->get(['name'])->pluck('name');
}
/**
@@ -85,11 +80,10 @@ class TagRepo
* If no search is given the 50 most popular values are provided.
* Passing a tagName will only find values for a tags with a particular name.
*/
public function getValueSuggestions(string $searchTerm, string $tagName): Collection
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
{
$query = Tag::query()
->select('*', DB::raw('count(*) as count'))
->where('value', '!=', '')
->groupBy('value');
if ($searchTerm) {
@@ -102,9 +96,9 @@ class TagRepo
$query = $query->where('name', '=', $tagName);
}
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->pluck('value');
return $query->get(['value'])->pluck('value');
}
/**

View File

@@ -1,10 +1,9 @@
<?php
namespace BookStack\Activity\Models;
namespace BookStack\Actions;
use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use Illuminate\Database\Eloquent\Relations\HasMany;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
@@ -29,12 +28,6 @@ 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.
*/
@@ -54,4 +47,12 @@ class View extends Model
return $view->views;
}
/**
* Clear all views from the system.
*/
public static function clearAll()
{
static::query()->truncate();
}
}

View File

@@ -1,8 +1,8 @@
<?php
namespace BookStack\Activity\Models;
namespace BookStack\Actions;
use BookStack\Activity\ActivityType;
use BookStack\Interfaces\Loggable;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -22,10 +22,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
*/
class Webhook extends Model implements Loggable
{
use HasFactory;
protected $fillable = ['name', 'endpoint', 'timeout'];
use HasFactory;
protected $casts = [
'last_called_at' => 'datetime',
'last_errored_at' => 'datetime',

View File

@@ -1,14 +1,12 @@
<?php
namespace BookStack\Activity\Tools;
namespace BookStack\Actions;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Webhook;
use BookStack\App\Model;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use Illuminate\Support\Carbon;
class WebhookFormatter

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Activity\Models;
namespace BookStack\Actions;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Model;
*/
class WebhookTrackedEvent extends Model
{
use HasFactory;
protected $fillable = ['event'];
use HasFactory;
}

View File

@@ -1,70 +0,0 @@
<?php
namespace BookStack\Activity\Controllers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Http\Controller;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
class AuditLogController extends Controller
{
public function index(Request $request)
{
$this->checkPermission('settings-manage');
$this->checkPermission('users-manage');
$sort = $request->get('sort', 'activity_date');
$order = $request->get('order', 'desc');
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
'created_at' => trans('settings.audit_table_date'),
'type' => trans('settings.audit_table_event'),
]);
$filters = [
'event' => $request->get('event', ''),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'user' => $request->get('user', ''),
'ip' => $request->get('ip', ''),
];
$query = Activity::query()
->with([
'entity' => fn ($query) => $query->withTrashed(),
'user',
])
->orderBy($listOptions->getSort(), $listOptions->getOrder());
if ($filters['event']) {
$query->where('type', '=', $filters['event']);
}
if ($filters['user']) {
$query->where('user_id', '=', $filters['user']);
}
if ($filters['date_from']) {
$query->where('created_at', '>=', $filters['date_from']);
}
if ($filters['date_to']) {
$query->where('created_at', '<=', $filters['date_to']);
}
if ($filters['ip']) {
$query->where('ip', 'like', $filters['ip'] . '%');
}
$activities = $query->paginate(100);
$activities->appends($request->all());
$types = ActivityType::all();
$this->setPageTitle(trans('settings.audit'));
return view('settings.audit', [
'activities' => $activities,
'filters' => $filters,
'listOptions' => $listOptions,
'activityTypes' => $types,
]);
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace BookStack\Activity\Models;
use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Favourite extends Model
{
protected $fillable = ['user_id'];
/**
* Get the related model that can be favourited.
*/
public function favouritable(): MorphTo
{
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

@@ -1,30 +0,0 @@
<?php
namespace BookStack\Activity\Queries;
use BookStack\Activity\Models\Webhook;
use BookStack\Util\SimpleListOptions;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Get all the webhooks in the system in a paginated format.
*/
class WebhooksAllPaginatedAndSorted
{
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
{
$query = Webhook::query()->select(['*'])
->withCount(['trackedEvents'])
->orderBy($listOptions->getSort(), $listOptions->getOrder());
if ($listOptions->getSearch()) {
$term = '%' . $listOptions->getSearch() . '%';
$query->where(function ($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('endpoint', 'like', $term);
});
}
return $query->paginate($count);
}
}

View File

@@ -1,102 +0,0 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Page;
class CommentTree
{
/**
* The built nested tree structure array.
* @var array{comment: Comment, depth: int, children: array}[]
*/
protected array $tree;
protected array $comments;
public function __construct(
protected Page $page
) {
$this->comments = $this->loadComments();
$this->tree = $this->createTree($this->comments);
}
public function enabled(): bool
{
return !setting('app-disable-comments');
}
public function empty(): bool
{
return count($this->tree) === 0;
}
public function count(): int
{
return count($this->comments);
}
public function get(): array
{
return $this->tree;
}
/**
* @param Comment[] $comments
*/
protected function createTree(array $comments): array
{
$byId = [];
foreach ($comments as $comment) {
$byId[$comment->local_id] = $comment;
}
$childMap = [];
foreach ($comments as $comment) {
$parent = $comment->parent_id;
if (is_null($parent) || !isset($byId[$parent])) {
$parent = 0;
}
if (!isset($childMap[$parent])) {
$childMap[$parent] = [];
}
$childMap[$parent][] = $comment->local_id;
}
$tree = [];
foreach ($childMap[0] ?? [] as $childId) {
$tree[] = $this->createTreeForId($childId, 0, $byId, $childMap);
}
return $tree;
}
protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array
{
$childIds = $childMap[$id] ?? [];
$children = [];
foreach ($childIds as $childId) {
$children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap);
}
return [
'comment' => $byId[$id],
'depth' => $depth,
'children' => $children,
];
}
protected function loadComments(): array
{
if (!$this->enabled()) {
return [];
}
return $this->page->comments()
->with('createdBy')
->get()
->all();
}
}

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
namespace BookStack\Api;
use BookStack\Http\ApiController;
use BookStack\Http\Controllers\Api\ApiController;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Collection;
@@ -16,8 +16,8 @@ use ReflectionMethod;
class ApiDocsGenerator
{
protected array $reflectionClasses = [];
protected array $controllerClasses = [];
protected $reflectionClasses = [];
protected $controllerClasses = [];
/**
* Load the docs form the cache if existing
@@ -139,10 +139,9 @@ class ApiDocsGenerator
protected function parseDescriptionFromMethodComment(string $comment): string
{
$matches = [];
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
preg_match_all('/^\s*?\*\s((?![@\s]).*?)$/m', $comment, $matches);
$text = implode(' ', $matches[1] ?? []);
return str_replace(' ', "\n", $text);
return implode(' ', $matches[1] ?? []);
}
/**

View File

@@ -1,107 +0,0 @@
<?php
namespace BookStack\Api;
use BookStack\Entities\Models\Entity;
class ApiEntityListFormatter
{
/**
* The list to be formatted.
* @var Entity[]
*/
protected $list = [];
/**
* The fields to show in the formatted data.
* Can be a plain string array item for a direct model field (If existing on model).
* If the key is a string, with a callable value, the return value of the callable
* will be used for the resultant value. A null return value will omit the property.
* @var array<string|int, string|callable>
*/
protected $fields = [
'id', 'name', 'slug', 'book_id', 'chapter_id',
'draft', 'template', 'created_at', 'updated_at',
];
public function __construct(array $list)
{
$this->list = $list;
// Default dynamic fields
$this->withField('url', fn(Entity $entity) => $entity->getUrl());
}
/**
* Add a field to be used in the formatter, with the property using the given
* name and value being the return type of the given callback.
*/
public function withField(string $property, callable $callback): self
{
$this->fields[$property] = $callback;
return $this;
}
/**
* Show the 'type' property in the response reflecting the entity type.
* EG: page, chapter, bookshelf, book
* To be included in results with non-pre-determined types.
*/
public function withType(): self
{
$this->withField('type', fn(Entity $entity) => $entity->getType());
return $this;
}
/**
* Include tags in the formatted data.
*/
public function withTags(): self
{
$this->withField('tags', fn(Entity $entity) => $entity->tags);
return $this;
}
/**
* Format the data and return an array of formatted content.
* @return array[]
*/
public function format(): array
{
$results = [];
foreach ($this->list as $item) {
$results[] = $this->formatSingle($item);
}
return $results;
}
/**
* Format a single entity item to a plain array.
*/
protected function formatSingle(Entity $entity): array
{
$result = [];
$values = (clone $entity)->toArray();
foreach ($this->fields as $field => $callback) {
if (is_string($callback)) {
$field = $callback;
if (!isset($values[$field])) {
continue;
}
$value = $values[$field];
} else {
$value = $callback($entity);
if (is_null($value)) {
continue;
}
}
$result[$field] = $value;
}
return $result;
}
}

View File

@@ -2,8 +2,8 @@
namespace BookStack\Api;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;
use BookStack\Auth\User;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;

View File

@@ -2,7 +2,7 @@
namespace BookStack\Api;
use BookStack\Access\LoginService;
use BookStack\Auth\Access\LoginService;
use BookStack\Exceptions\ApiAuthException;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable;

View File

@@ -2,31 +2,24 @@
namespace BookStack\Api;
use BookStack\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ListingResponseBuilder
{
protected Builder $query;
protected Request $request;
/**
* @var string[]
*/
protected array $fields;
protected $query;
protected $request;
protected $fields;
/**
* @var array<callable>
*/
protected array $resultModifiers = [];
protected $resultModifiers = [];
/**
* @var array<string, string>
*/
protected array $filterOperators = [
protected $filterOperators = [
'eq' => '=',
'ne' => '!=',
'gt' => '>',
@@ -70,9 +63,9 @@ class ListingResponseBuilder
/**
* Add a callback to modify each element of the results.
*
* @param (callable(Model): void) $modifier
* @param (callable(Model)) $modifier
*/
public function modifyResults(callable $modifier): void
public function modifyResults($modifier): void
{
$this->resultModifiers[] = $modifier;
}

View File

@@ -1,53 +0,0 @@
<?php
namespace BookStack\App\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.
*
* @return void
*/
protected function registerLoader()
{
$this->app->singleton('translation.loader', function ($app) {
return new FileLoader($app['files'], $app['path.lang']);
});
}
}

View File

@@ -1,31 +0,0 @@
<?php
namespace BookStack\App\Providers;
use BookStack\Entities\BreadcrumbsViewComposer;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class ViewTweaksServiceProvider extends ServiceProvider
{
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
{
// Set paginator to use bootstrap-style pagination
Paginator::useBootstrap();
// View Composers
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
// Custom blade view directives
Blade::directive('icon', function ($expression) {
return "<?php echo icon($expression); ?>";
});
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\App;
namespace BookStack;
class Application extends \Illuminate\Foundation\Application
{

View File

@@ -1,15 +1,15 @@
<?php
namespace BookStack\Access;
namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Notifications\ConfirmEmail;
use BookStack\Users\Models\User;
class EmailConfirmationService extends UserTokenService
{
protected string $tokenTable = 'email_confirmations';
protected int $expiryTime = 24;
protected $tokenTable = 'email_confirmations';
protected $expiryTime = 24;
/**
* Create new confirmation for a user,

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access;
namespace BookStack\Auth\Access;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;

View File

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

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Guards;
namespace BookStack\Auth\Access\Guards;
/**
* Saml2 Session Guard.

View File

@@ -1,8 +1,8 @@
<?php
namespace BookStack\Access\Guards;
namespace BookStack\Auth\Access\Guards;
use BookStack\Access\RegistrationService;
use BookStack\Auth\Access\RegistrationService;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\StatefulGuard;

View File

@@ -1,15 +1,15 @@
<?php
namespace BookStack\Access\Guards;
namespace BookStack\Auth\Access\Guards;
use BookStack\Access\LdapService;
use BookStack\Access\RegistrationService;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Users\Models\User;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Str;

136
app/Auth/Access/Ldap.php Normal file
View File

@@ -0,0 +1,136 @@
<?php
namespace BookStack\Auth\Access;
/**
* Class Ldap
* An object-orientated thin abstraction wrapper for common PHP LDAP functions.
* Allows the standard LDAP functions to be mocked for testing.
*/
class Ldap
{
/**
* Connect to an LDAP server.
*
* @return resource
*/
public function connect(string $hostName, int $port)
{
return ldap_connect($hostName, $port);
}
/**
* Set the value of a LDAP option for the given connection.
*
* @param resource $ldapConnection
* @param mixed $value
*/
public function setOption($ldapConnection, int $option, $value): bool
{
return ldap_set_option($ldapConnection, $option, $value);
}
/**
* Start TLS on the given LDAP connection.
*/
public function startTls($ldapConnection): bool
{
return ldap_start_tls($ldapConnection);
}
/**
* Set the version number for the given ldap connection.
*
* @param resource $ldapConnection
*/
public function setVersion($ldapConnection, int $version): bool
{
return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
}
/**
* Search LDAP tree using the provided filter.
*
* @param resource $ldapConnection
* @param string $baseDn
* @param string $filter
* @param array|null $attributes
*
* @return resource
*/
public function search($ldapConnection, $baseDn, $filter, array $attributes = null)
{
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
}
/**
* Get entries from an ldap search result.
*
* @param resource $ldapConnection
* @param resource $ldapSearchResult
*
* @return array
*/
public function getEntries($ldapConnection, $ldapSearchResult)
{
return ldap_get_entries($ldapConnection, $ldapSearchResult);
}
/**
* Search and get entries immediately.
*
* @param resource $ldapConnection
* @param string $baseDn
* @param string $filter
* @param array|null $attributes
*
* @return resource
*/
public function searchAndGetEntries($ldapConnection, $baseDn, $filter, array $attributes = null)
{
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
return $this->getEntries($ldapConnection, $search);
}
/**
* Bind to LDAP directory.
*
* @param resource $ldapConnection
* @param string $bindRdn
* @param string $bindPassword
*
* @return bool
*/
public function bind($ldapConnection, $bindRdn = null, $bindPassword = null)
{
return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
}
/**
* Explode a LDAP dn string into an array of components.
*
* @param string $dn
* @param int $withAttrib
*
* @return array
*/
public function explodeDn(string $dn, int $withAttrib)
{
return ldap_explode_dn($dn, $withAttrib);
}
/**
* Escape a string for use in an LDAP filter.
*
* @param string $value
* @param string $ignore
* @param int $flags
*
* @return string
*/
public function escape(string $value, string $ignore = '', int $flags = 0)
{
return ldap_escape($value, $ignore, $flags);
}
}

View File

@@ -1,11 +1,11 @@
<?php
namespace BookStack\Access;
namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException;
use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\User;
use ErrorException;
use Illuminate\Support\Facades\Log;
@@ -15,19 +15,26 @@ use Illuminate\Support\Facades\Log;
*/
class LdapService
{
protected Ldap $ldap;
protected GroupSyncService $groupSyncService;
protected UserAvatars $userAvatars;
/**
* @var resource|\LDAP\Connection
* @var resource
*/
protected $ldapConnection;
protected array $config;
protected bool $enabled;
public function __construct(
protected Ldap $ldap,
protected UserAvatars $userAvatars,
protected GroupSyncService $groupSyncService
) {
/**
* LdapService constructor.
*/
public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
{
$this->ldap = $ldap;
$this->userAvatars = $userAvatars;
$this->groupSyncService = $groupSyncService;
$this->config = config('services.ldap');
$this->enabled = config('auth.method') === 'ldap';
}
@@ -52,7 +59,7 @@ class LdapService
// Clean attributes
foreach ($attributes as $index => $attribute) {
if (str_starts_with($attribute, 'BIN;')) {
if (strpos($attribute, 'BIN;') === 0) {
$attributes[$index] = substr($attribute, strlen('BIN;'));
}
}
@@ -75,7 +82,7 @@ class LdapService
* Get the details of a user from LDAP using the given username.
* User found via configurable user filter.
*
* @throws LdapException|JsonDebugException
* @throws LdapException
*/
public function getUserDetails(string $userName): ?array
{
@@ -98,7 +105,7 @@ class LdapService
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
'avatar'=> $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
];
if ($this->config['dump_user_details']) {
@@ -119,7 +126,7 @@ class LdapService
*/
protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
{
$isBinary = str_starts_with($propertyKey, 'BIN;');
$isBinary = strpos($propertyKey, 'BIN;') === 0;
$propertyKey = strtolower($propertyKey);
$value = $defaultValue;
@@ -163,11 +170,11 @@ class LdapService
* Bind the system user to the LDAP connection using the given credentials
* otherwise anonymous access is attempted.
*
* @param resource|\LDAP\Connection $connection
* @param resource $connection
*
* @throws LdapException
*/
protected function bindSystemUser($connection): void
protected function bindSystemUser($connection)
{
$ldapDn = $this->config['dn'];
$ldapPass = $this->config['pass'];
@@ -190,7 +197,7 @@ class LdapService
*
* @throws LdapException
*
* @return resource|\LDAP\Connection
* @return resource
*/
protected function getConnection()
{
@@ -209,8 +216,8 @@ class LdapService
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
}
$ldapHost = $this->parseServerString($this->config['server']);
$ldapConnection = $this->ldap->connect($ldapHost);
$serverDetails = $this->parseServerString($this->config['server']);
$ldapConnection = $this->ldap->connect($serverDetails['host'], $serverDetails['port']);
if ($ldapConnection === false) {
throw new LdapException(trans('errors.ldap_cannot_connect'));
@@ -235,16 +242,23 @@ class LdapService
}
/**
* Parse an LDAP server string and return the host suitable for a connection.
* Parse a LDAP server string and return the host and port for a connection.
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
*/
protected function parseServerString(string $serverString): string
protected function parseServerString(string $serverString): array
{
if (str_starts_with($serverString, 'ldaps://') || str_starts_with($serverString, 'ldap://')) {
return $serverString;
$serverNameParts = explode(':', $serverString);
// If we have a protocol just return the full string since PHP will ignore a separate port.
if ($serverNameParts[0] === 'ldaps' || $serverNameParts[0] === 'ldap') {
return ['host' => $serverString, 'port' => 389];
}
return "ldap://{$serverString}";
// Otherwise, extract the port out
$hostName = $serverNameParts[0];
$ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
return ['host' => $hostName, 'port' => $ldapPort];
}
/**
@@ -372,7 +386,7 @@ class LdapService
* @throws LdapException
* @throws JsonDebugException
*/
public function syncGroups(User $user, string $username): void
public function syncGroups(User $user, string $username)
{
$userLdapGroups = $this->getUserGroups($username);
$this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);

View File

@@ -1,15 +1,14 @@
<?php
namespace BookStack\Access;
namespace BookStack\Auth\Access;
use BookStack\Access\Mfa\MfaSession;
use BookStack\Activity\ActivityType;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\Mfa\MfaSession;
use BookStack\Auth\User;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use Exception;
class LoginService
@@ -150,7 +149,6 @@ class LoginService
* May interrupt the flow if extra authentication requirements are imposed.
*
* @throws StoppedAuthenticationException
* @throws LoginAttemptException
*/
public function attempt(array $credentials, string $method, bool $remember = false): bool
{

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Mfa;
namespace BookStack\Auth\Access\Mfa;
use Illuminate\Support\Str;

View File

@@ -1,8 +1,8 @@
<?php
namespace BookStack\Access\Mfa;
namespace BookStack\Auth\Access\Mfa;
use BookStack\Users\Models\User;
use BookStack\Auth\User;
class MfaSession
{

View File

@@ -1,8 +1,8 @@
<?php
namespace BookStack\Access\Mfa;
namespace BookStack\Auth\Access\Mfa;
use BookStack\Users\Models\User;
use BookStack\Auth\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Mfa;
namespace BookStack\Auth\Access\Mfa;
use BaconQrCode\Renderer\Color\Rgb;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
@@ -8,7 +8,7 @@ use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\Fill;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use BookStack\Users\Models\User;
use BookStack\Auth\User;
use PragmaRX\Google2FA\Google2FA;
use PragmaRX\Google2FA\Support\Constants;

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Mfa;
namespace BookStack\Auth\Access\Mfa;
use Illuminate\Contracts\Validation\Rule;

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Oidc;
namespace BookStack\Auth\Access\Oidc;
use InvalidArgumentException;
use League\OAuth2\Client\Token\AccessToken;

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Oidc;
namespace BookStack\Auth\Access\Oidc;
use Exception;

View File

@@ -1,19 +1,38 @@
<?php
namespace BookStack\Access\Oidc;
namespace BookStack\Auth\Access\Oidc;
class OidcIdToken
{
protected array $header;
protected array $payload;
protected string $signature;
protected string $issuer;
protected array $tokenParts = [];
/**
* @var array
*/
protected $header;
/**
* @var array
*/
protected $payload;
/**
* @var string
*/
protected $signature;
/**
* @var array[]|string[]
*/
protected array $keys;
protected $keys;
/**
* @var string
*/
protected $issuer;
/**
* @var array
*/
protected $tokenParts = [];
public function __construct(string $token, string $issuer, array $keys)
{
@@ -87,14 +106,6 @@ class OidcIdToken
return $this->payload;
}
/**
* Replace the existing claim data of this token with that provided.
*/
public function replaceClaims(array $claims): void
{
$this->payload = $claims;
}
/**
* Validate the structure of the given token and ensure we have the required pieces.
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Oidc;
namespace BookStack\Auth\Access\Oidc;
class OidcInvalidKeyException extends \Exception
{

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Oidc;
namespace BookStack\Auth\Access\Oidc;
use Exception;

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Oidc;
namespace BookStack\Auth\Access\Oidc;
use Exception;

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Oidc;
namespace BookStack\Auth\Access\Oidc;
use phpseclib3\Crypt\Common\PublicKey;
use phpseclib3\Crypt\PublicKeyLoader;
@@ -67,10 +67,11 @@ class OidcJwtSigningKey
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
}
// 'use' is optional for a JWK but we assume 'sig' where no value exists since that's what
// the OIDC discovery spec infers since 'sig' MUST be set if encryption keys come into play.
$use = $jwk['use'] ?? 'sig';
if ($use !== 'sig') {
if (empty($jwk['use'])) {
throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected');
}
if ($jwk['use'] !== 'sig') {
throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
}

View File

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

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Oidc;
namespace BookStack\Auth\Access\Oidc;
use GuzzleHttp\Psr7\Request;
use Illuminate\Contracts\Cache\Repository;
@@ -15,17 +15,40 @@ use Psr\Http\Client\ClientInterface;
*/
class OidcProviderSettings
{
public string $issuer;
public string $clientId;
public string $clientSecret;
public ?string $redirectUri;
public ?string $authorizationEndpoint;
public ?string $tokenEndpoint;
/**
* @var string
*/
public $issuer;
/**
* @var string
*/
public $clientId;
/**
* @var string
*/
public $clientSecret;
/**
* @var string
*/
public $redirectUri;
/**
* @var string
*/
public $authorizationEndpoint;
/**
* @var string
*/
public $tokenEndpoint;
/**
* @var string[]|array[]
*/
public ?array $keys = [];
public $keys = [];
public function __construct(array $settings)
{
@@ -141,10 +164,9 @@ class OidcProviderSettings
protected function filterKeys(array $keys): array
{
return array_filter($keys, function (array $key) {
$alg = $key['alg'] ?? 'RS256';
$use = $key['use'] ?? 'sig';
$alg = $key['alg'] ?? null;
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
});
}

View File

@@ -1,21 +1,21 @@
<?php
namespace BookStack\Access\Oidc;
namespace BookStack\Auth\Access\Oidc;
use BookStack\Access\GroupSyncService;
use BookStack\Access\LoginService;
use BookStack\Access\RegistrationService;
use function auth;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use Illuminate\Support\Arr;
use function config;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Http\Client\ClientInterface as HttpClient;
use function trans;
use function url;
/**
* Class OpenIdConnectService
@@ -23,12 +23,18 @@ use Psr\Http\Client\ClientInterface as HttpClient;
*/
class OidcService
{
public function __construct(
protected RegistrationService $registrationService,
protected LoginService $loginService,
protected HttpClient $httpClient,
protected GroupSyncService $groupService
) {
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected HttpClient $httpClient;
/**
* OpenIdService constructor.
*/
public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
{
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->httpClient = $httpClient;
}
/**
@@ -42,6 +48,7 @@ class OidcService
{
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
return [
'url' => $provider->getAuthorizationUrl(),
'state' => $provider->getState(),
@@ -110,31 +117,10 @@ class OidcService
*/
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
return new OidcOAuthProvider($settings->arrayForProvider(), [
'httpClient' => $this->httpClient,
'optionProvider' => new HttpBasicAuthOptionProvider(),
]);
foreach ($this->getAdditionalScopes() as $scope) {
$provider->addScope($scope);
}
return $provider;
}
/**
* Get any user-defined addition/custom scopes to apply to the authentication request.
*
* @return string[]
*/
protected function getAdditionalScopes(): array
{
$scopeConfig = $this->config()['additional_scopes'] ?: '';
$scopeArr = explode(',', $scopeConfig);
$scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr);
return array_filter($scopeArr);
}
/**
@@ -159,43 +145,19 @@ class OidcService
return implode(' ', $displayName);
}
/**
* Extract the assigned groups from the id token.
*
* @return string[]
*/
protected function getUserGroups(OidcIdToken $token): array
{
$groupsAttr = $this->config()['groups_claim'];
if (empty($groupsAttr)) {
return [];
}
$groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
if (!is_array($groupsList)) {
return [];
}
return array_values(array_filter($groupsList, function ($val) {
return is_string($val);
}));
}
/**
* Extract the details of a user from an ID token.
*
* @return array{name: string, email: string, external_id: string, groups: string[]}
* @return array{name: string, email: string, external_id: string}
*/
protected function getUserDetails(OidcIdToken $token): array
{
$idClaim = $this->config()['external_id_claim'];
$id = $token->getClaim($idClaim);
$id = $token->getClaim('sub');
return [
'external_id' => $id,
'email' => $token->getClaim('email'),
'name' => $this->getUserDisplayName($token, $id),
'groups' => $this->getUserGroups($token),
];
}
@@ -216,16 +178,6 @@ class OidcService
$settings->keys,
);
$returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
'access_token' => $accessToken->getToken(),
'expires_in' => $accessToken->getExpires(),
'refresh_token' => $accessToken->getRefreshToken(),
]);
if (!is_null($returnClaims)) {
$idToken->replaceClaims($returnClaims);
}
if ($this->config()['dump_user_details']) {
throw new JsonDebugException($idToken->getAllClaims());
}
@@ -257,12 +209,6 @@ class OidcService
throw new OidcException($exception->getMessage());
}
if ($this->shouldSyncGroups()) {
$groups = $userDetails['groups'];
$detachExisting = $this->config()['remove_from_groups'];
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
}
$this->loginService->login($user, 'oidc');
return $user;
@@ -275,12 +221,4 @@ class OidcService
{
return config('oidc');
}
/**
* Check if groups should be synced.
*/
protected function shouldSyncGroups(): bool
{
return $this->config()['user_to_groups'] !== false;
}
}

View File

@@ -1,14 +1,15 @@
<?php
namespace BookStack\Access;
namespace BookStack\Auth\Access;
use BookStack\Activity\ActivityType;
use BookStack\Actions\ActivityType;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use BookStack\Users\UserRepo;
use Exception;
use Illuminate\Support\Str;

View File

@@ -1,12 +1,12 @@
<?php
namespace BookStack\Access;
namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\SamlException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Users\Models\User;
use Exception;
use OneLogin\Saml2\Auth;
use OneLogin\Saml2\Constants;
@@ -20,11 +20,14 @@ use OneLogin\Saml2\ValidationError;
*/
class Saml2Service
{
protected array $config;
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected GroupSyncService $groupSyncService;
protected $config;
protected $registrationService;
protected $loginService;
protected $groupSyncService;
/**
* Saml2Service constructor.
*/
public function __construct(
RegistrationService $registrationService,
LoginService $loginService,
@@ -67,7 +70,7 @@ class Saml2Service
$returnRoute,
[],
$user->email,
session()->get('saml2_session_index'),
null,
true,
Constants::NAMEID_EMAIL_ADDRESS
);
@@ -106,10 +109,9 @@ class Saml2Service
$errors = $toolkit->getErrors();
if (!empty($errors)) {
$reason = $toolkit->getLastErrorReason();
$message = 'Invalid ACS Response; Errors: ' . implode(', ', $errors);
$message .= $reason ? "; Reason: {$reason}" : '';
throw new Error($message);
throw new Error(
'Invalid ACS Response: ' . implode(', ', $errors)
);
}
if (!$toolkit->isAuthenticated()) {
@@ -118,7 +120,6 @@ class Saml2Service
$attrs = $toolkit->getAttributes();
$id = $toolkit->getNameId();
session()->put('saml2_session_index', $toolkit->getSessionIndex());
return $this->processLoginCallback($id, $attrs);
}
@@ -167,7 +168,7 @@ class Saml2Service
*/
public function metadata(): string
{
$toolKit = $this->getToolkit(true);
$toolKit = $this->getToolkit();
$settings = $toolKit->getSettings();
$metadata = $settings->getSPMetadata();
$errors = $settings->validateMetadata($metadata);
@@ -188,7 +189,7 @@ class Saml2Service
* @throws Error
* @throws Exception
*/
protected function getToolkit(bool $spOnly = false): Auth
protected function getToolkit(): Auth
{
$settings = $this->config['onelogin'];
$overrides = $this->config['onelogin_overrides'] ?? [];
@@ -198,14 +199,14 @@ class Saml2Service
}
$metaDataSettings = [];
if (!$spOnly && $this->config['autoload_from_metadata']) {
if ($this->config['autoload_from_metadata']) {
$metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
}
$spSettings = $this->loadOneloginServiceProviderDetails();
$settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
return new Auth($settings, $spOnly);
return new Auth($settings);
}
/**

View File

@@ -1,12 +1,12 @@
<?php
namespace BookStack\Access;
namespace BookStack\Auth\Access;
use BookStack\Auth\Access\handler;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\User;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite;

View File

@@ -1,18 +1,20 @@
<?php
namespace BookStack\Access;
namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Notifications\UserInvite;
use BookStack\Users\Models\User;
class UserInviteService extends UserTokenService
{
protected string $tokenTable = 'user_invites';
protected int $expiryTime = 336; // Two weeks
protected $tokenTable = 'user_invites';
protected $expiryTime = 336; // Two weeks
/**
* Send an invitation to a user to sign into BookStack
* Removes existing invitation tokens.
*
* @param User $user
*/
public function sendInvitation(User $user)
{

View File

@@ -1,10 +1,10 @@
<?php
namespace BookStack\Access;
namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use BookStack\Users\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@@ -14,29 +14,41 @@ class UserTokenService
{
/**
* Name of table where user tokens are stored.
*
* @var string
*/
protected string $tokenTable = 'user_tokens';
protected $tokenTable = 'user_tokens';
/**
* Token expiry time in hours.
*
* @var int
*/
protected int $expiryTime = 24;
protected $expiryTime = 24;
/**
* Delete all tokens that belong to a user.
* Delete all email confirmations that belong to a user.
*
* @param User $user
*
* @return mixed
*/
public function deleteByUser(User $user): void
public function deleteByUser(User $user)
{
DB::table($this->tokenTable)
return DB::table($this->tokenTable)
->where('user_id', '=', $user->id)
->delete();
}
/**
* Get the user id from a token, while checking the token exists and has not expired.
* Get the user id from a token, while check the token exists and has not expired.
*
* @param string $token
*
* @throws UserTokenNotFoundException
* @throws UserTokenExpiredException
*
* @return int
*/
public function checkTokenAndGetUserId(string $token): int
{
@@ -55,6 +67,8 @@ class UserTokenService
/**
* Creates a unique token within the email confirmation database.
*
* @return string
*/
protected function generateToken(): string
{
@@ -68,6 +82,10 @@ class UserTokenService
/**
* Generate and store a token for the given user.
*
* @param User $user
*
* @return string
*/
protected function createTokenForUser(User $user): string
{
@@ -84,6 +102,10 @@ class UserTokenService
/**
* Check if the given token exists.
*
* @param string $token
*
* @return bool
*/
protected function tokenExists(string $token): bool
{
@@ -93,8 +115,12 @@ class UserTokenService
/**
* Get a token entry for the given token.
*
* @param string $token
*
* @return object|null
*/
protected function getEntryByToken(string $token): ?stdClass
protected function getEntryByToken(string $token)
{
return DB::table($this->tokenTable)
->where('token', '=', $token)
@@ -103,6 +129,10 @@ class UserTokenService
/**
* Check if the given token entry has expired.
*
* @param stdClass $tokenEntry
*
* @return bool
*/
protected function entryExpired(stdClass $tokenEntry): bool
{

View File

@@ -0,0 +1,21 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Model;
class EntityPermission extends Model
{
protected $fillable = ['role_id', 'action'];
public $timestamps = false;
/**
* Get all this restriction's attached entity.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function restrictable()
{
return $this->morphTo('restrictable');
}
}

View File

@@ -1,10 +1,10 @@
<?php
namespace BookStack\Permissions\Models;
namespace BookStack\Auth\Permissions;
use BookStack\App\Model;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Entity;
use BookStack\Users\Models\Role;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphOne;

View File

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

View File

@@ -1,23 +1,30 @@
<?php
namespace BookStack\Permissions;
namespace BookStack\Auth\Permissions;
use BookStack\Activity\ActivityType;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Role;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use BookStack\Permissions\Models\RolePermission;
use BookStack\Users\Models\Role;
use Exception;
use Illuminate\Database\Eloquent\Collection;
class PermissionsRepo
{
protected JointPermissionBuilder $permissionBuilder;
protected array $systemRoles = ['admin', 'public'];
protected $permission;
protected $role;
protected $permissionService;
public function __construct(JointPermissionBuilder $permissionBuilder)
protected $systemRoles = ['admin', 'public'];
/**
* PermissionsRepo constructor.
*/
public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
{
$this->permissionBuilder = $permissionBuilder;
$this->permission = $permission;
$this->role = $role;
$this->permissionService = $permissionService;
}
/**
@@ -25,7 +32,7 @@ class PermissionsRepo
*/
public function getAllRoles(): Collection
{
return Role::query()->get();
return $this->role->all();
}
/**
@@ -33,15 +40,15 @@ class PermissionsRepo
*/
public function getAllRolesExcept(Role $role): Collection
{
return Role::query()->where('id', '!=', $role->id)->get();
return $this->role->where('id', '!=', $role->id)->get();
}
/**
* Get a role via its ID.
*/
public function getRoleById(int $id): Role
public function getRoleById($id): Role
{
return Role::query()->findOrFail($id);
return $this->role->newQuery()->findOrFail($id);
}
/**
@@ -49,14 +56,13 @@ class PermissionsRepo
*/
public function saveNewRole(array $roleData): Role
{
$role = new Role($roleData);
$role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
$role = $this->role->newInstance($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->save();
$permissions = $roleData['permissions'] ?? [];
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions);
$this->permissionBuilder->rebuildForRole($role);
$this->permissionService->buildJointPermissionForRole($role);
Activity::add(ActivityType::ROLE_CREATE, $role);
return $role;
@@ -64,46 +70,43 @@ class PermissionsRepo
/**
* Updates an existing role.
* Ensures Admin system role always have core permissions.
* Ensure Admin role always have core permissions.
*/
public function updateRole($roleId, array $roleData): Role
public function updateRole($roleId, array $roleData)
{
$role = $this->getRoleById($roleId);
/** @var Role $role */
$role = $this->role->newQuery()->findOrFail($roleId);
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
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
if ($role->system_name === 'admin') {
$permissionNameArray = array_unique(array_merge($permissionNameArray, [
$permissions = array_merge($permissions, [
'users-manage',
'user-roles-manage',
'restrictions-manage-all',
'restrictions-manage-own',
'settings-manage',
]));
]);
}
if (!empty($permissionNameArray)) {
$permissions = RolePermission::query()
$this->assignRolePermissions($role, $permissions);
$role->fill($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->save();
$this->permissionService->buildJointPermissionForRole($role);
Activity::add(ActivityType::ROLE_UPDATE, $role);
}
/**
* Assign an list of permission names to an role.
*/
protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
{
$permissions = [];
$permissionNameArray = array_values($permissionNameArray);
if ($permissionNameArray) {
$permissions = $this->permission->newQuery()
->whereIn('name', $permissionNameArray)
->pluck('id')
->toArray();
@@ -115,15 +118,16 @@ class PermissionsRepo
/**
* Delete a role from the system.
* Check it's not an admin role or set as default before deleting.
* If a migration Role ID is specified the users assign to the current role
* If an 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(int $roleId, int $migrateRoleId = 0): void
public function deleteRole($roleId, $migrateRoleId)
{
$role = $this->getRoleById($roleId);
/** @var Role $role */
$role = $this->role->newQuery()->findOrFail($roleId);
// Prevent deleting admin role or default registration role.
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
@@ -132,16 +136,15 @@ class PermissionsRepo
throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
}
if ($migrateRoleId !== 0) {
$newRole = Role::query()->find($migrateRoleId);
if ($migrateRoleId) {
$newRole = $this->role->newQuery()->find($migrateRoleId);
if ($newRole) {
$users = $role->users()->pluck('id')->toArray();
$newRole->users()->sync($users);
}
}
$role->entityPermissions()->delete();
$role->jointPermissions()->delete();
$this->permissionService->deleteJointPermissionsForRole($role);
Activity::add(ActivityType::ROLE_DELETE, $role);
$role->delete();
}

View File

@@ -1,15 +1,13 @@
<?php
namespace BookStack\Permissions\Models;
namespace BookStack\Auth\Permissions;
use BookStack\App\Model;
use BookStack\Users\Models\Role;
use BookStack\Auth\Role;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/**
* @property int $id
* @property string $name
* @property string $display_name
*/
class RolePermission extends Model
{

View File

@@ -1,9 +1,8 @@
<?php
namespace BookStack\Users\Queries;
namespace BookStack\Auth\Queries;
use BookStack\Users\Models\User;
use BookStack\Util\SimpleListOptions;
use BookStack\Auth\User;
use Illuminate\Pagination\LengthAwarePaginator;
/**
@@ -12,23 +11,23 @@ use Illuminate\Pagination\LengthAwarePaginator;
* user is assumed to be trusted. (Admin users).
* Email search can be abused to extract email addresses.
*/
class UsersAllPaginatedAndSorted
class AllUsersPaginatedAndSorted
{
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
/**
* @param array{sort: string, order: string, search: string} $sortData
*/
public function run(int $count, array $sortData): LengthAwarePaginator
{
$sort = $listOptions->getSort();
if ($sort === 'created_at') {
$sort = 'users.created_at';
}
$sort = $sortData['sort'];
$query = User::query()->select(['*'])
->scopes(['withLastActivityAt'])
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $listOptions->getOrder());
->orderBy($sort, $sortData['order']);
if ($listOptions->getSearch()) {
$term = '%' . $listOptions->getSearch() . '%';
if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%';
$query->where(function ($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('email', 'like', $term);

View File

@@ -1,12 +1,12 @@
<?php
namespace BookStack\Users\Queries;
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
/**
* Get asset created counts for the given user.

View File

@@ -1,12 +1,12 @@
<?php
namespace BookStack\Users\Queries;
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;

View File

@@ -1,12 +1,11 @@
<?php
namespace BookStack\Users\Models;
namespace BookStack\Auth;
use BookStack\Activity\Models\Loggable;
use BookStack\App\Model;
use BookStack\Permissions\Models\EntityPermission;
use BookStack\Permissions\Models\JointPermission;
use BookStack\Permissions\Models\RolePermission;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -27,14 +26,10 @@ class Role extends Model implements Loggable
{
use HasFactory;
protected $fillable = ['display_name', 'description', 'external_auth_id', 'mfa_enforced'];
protected $fillable = ['display_name', 'description', 'external_auth_id'];
protected $hidden = ['pivot'];
protected $casts = [
'mfa_enforced' => 'boolean',
];
/**
* The roles that belong to the role.
*/
@@ -59,14 +54,6 @@ class Role extends Model implements Loggable
return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
}
/**
* Get the entity permissions assigned to this role.
*/
public function entityPermissions(): HasMany
{
return $this->hasMany(EntityPermission::class);
}
/**
* Check if this role has a permission.
*/
@@ -111,13 +98,26 @@ class Role extends Model implements Loggable
*/
public static function getSystemRole(string $systemName): ?self
{
static $cache = [];
return static::query()->where('system_name', '=', $systemName)->first();
}
if (!isset($cache[$systemName])) {
$cache[$systemName] = static::query()->where('system_name', '=', $systemName)->first();
}
/**
* Get all visible roles.
*/
public static function visible(): Collection
{
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
}
return $cache[$systemName];
/**
* Get the roles that can be restricted.
*/
public static function restrictable(): Collection
{
return static::query()
->where('system_name', '!=', 'admin')
->orderBy('display_name', 'asc')
->get();
}
/**

View File

@@ -1,10 +1,9 @@
<?php
namespace BookStack\Access;
namespace BookStack\Auth;
use BookStack\Activity\Models\Loggable;
use BookStack\App\Model;
use BookStack\Users\Models\User;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
/**
* Class SocialAccount.

View File

@@ -1,15 +1,14 @@
<?php
namespace BookStack\Users\Models;
namespace BookStack\Auth;
use BookStack\Access\Mfa\MfaValue;
use BookStack\Access\SocialAccount;
use BookStack\Activity\Models\Favourite;
use BookStack\Activity\Models\Loggable;
use BookStack\Actions\Favourite;
use BookStack\Api\ApiToken;
use BookStack\App\Model;
use BookStack\App\Sluggable;
use BookStack\Auth\Access\Mfa\MfaValue;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable;
use BookStack\Model;
use BookStack\Notifications\ResetPassword;
use BookStack\Uploads\Image;
use Carbon\Carbon;
@@ -73,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', 'pivot',
'created_at', 'updated_at', 'image_id', 'roles', 'avatar', 'user_id',
];
/**
@@ -81,11 +80,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected ?Collection $permissions;
/**
* This holds the user's avatar URL when loaded to prevent re-calculating within the same request.
*/
protected string $avatarUrl = '';
/**
* This holds the default user when loaded.
*
@@ -169,7 +163,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
/**
* Get all permissions belonging to the current user.
* Get all permissions belonging to a the current user.
*/
protected function permissions(): Collection
{
@@ -201,7 +195,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
public function attachRole(Role $role)
{
$this->roles()->attach($role->id);
$this->unsetRelation('roles');
}
/**
@@ -240,18 +233,12 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $default;
}
if (!empty($this->avatarUrl)) {
return $this->avatarUrl;
}
try {
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
} catch (Exception $err) {
$avatar = $default;
}
$this->avatarUrl = $avatar;
return $avatar;
}

View File

@@ -1,18 +1,15 @@
<?php
namespace BookStack\Users;
namespace BookStack\Auth;
use BookStack\Access\UserInviteService;
use BookStack\Activity\ActivityType;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Entities\EntityProvider;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Facades\Activity;
use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\Role;
use BookStack\Users\Models\User;
use Exception;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@@ -64,7 +61,7 @@ class UserRepo
$user = new User();
$user->name = $data['name'];
$user->email = $data['email'];
$user->password = Hash::make(empty($data['password']) ? Str::random(32) : $data['password']);
$user->password = bcrypt(empty($data['password']) ? Str::random(32) : $data['password']);
$user->email_confirmed = $emailConfirmed;
$user->external_auth_id = $data['external_auth_id'] ?? '';
@@ -129,7 +126,7 @@ class UserRepo
}
if (!empty($data['password'])) {
$user->password = Hash::make($data['password']);
$user->password = bcrypt($data['password']);
}
if (!empty($data['language'])) {
@@ -160,9 +157,6 @@ class UserRepo
// Delete user profile images
$this->userAvatar->destroyAllForUser($user);
// Delete related activities
setting()->deleteUserSettings($user->id);
if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
@@ -236,8 +230,6 @@ class UserRepo
*/
protected function setUserRoles(User $user, array $roles)
{
$roles = array_filter(array_values($roles));
if ($this->demotingLastAdmin($user, $roles)) {
throw new UserUpdateException(trans('errors.role_cannot_remove_only_admin'), $user->getEditUrl());
}

View File

@@ -8,8 +8,6 @@
* 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.
@@ -24,7 +22,7 @@ return [
// The number of revisions to keep in the database.
// Once this limit is reached older revisions will be deleted.
// If set to false then a limit will not be enforced.
'revision_limit' => env('REVISION_LIMIT', 100),
'revision_limit' => env('REVISION_LIMIT', 50),
// The number of days that content will remain in the recycle bin before
// being considered for auto-removal. It is not a guarantee that content will
@@ -66,10 +64,6 @@ return [
// Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),
// Alter the precision of IP addresses stored by BookStack.
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
// Application timezone for back-end date functions.
'timezone' => env('APP_TIMEZONE', 'UTC'),
@@ -77,7 +71,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ka', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
// Application Fallback Locale
'fallback_locale' => 'en',
@@ -100,13 +94,7 @@ return [
// Encryption cipher
'cipher' => 'AES-256-CBC',
// Maintenance Mode Driver
'maintenance' => [
'driver' => 'file',
// 'store' => 'redis',
],
// Application Service Providers
// Application Services Provides
'providers' => [
// Laravel Framework Service Providers...
@@ -122,8 +110,6 @@ return [
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
@@ -131,37 +117,94 @@ return [
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
// Third party service providers
Barryvdh\DomPDF\ServiceProvider::class,
Barryvdh\Snappy\ServiceProvider::class,
Intervention\Image\ImageServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
SocialiteProviders\Manager\ServiceProvider::class,
// Third party service providers
Intervention\Image\ImageServiceProvider::class,
Barryvdh\DomPDF\ServiceProvider::class,
Barryvdh\Snappy\ServiceProvider::class,
// BookStack replacement service providers (Extends Laravel)
BookStack\Providers\PaginationServiceProvider::class,
BookStack\Providers\TranslationServiceProvider::class,
// BookStack custom service providers
\BookStack\App\Providers\ThemeServiceProvider::class,
\BookStack\App\Providers\AppServiceProvider::class,
\BookStack\App\Providers\AuthServiceProvider::class,
\BookStack\App\Providers\EventServiceProvider::class,
\BookStack\App\Providers\RouteServiceProvider::class,
\BookStack\App\Providers\TranslationServiceProvider::class,
\BookStack\App\Providers\ValidationRuleServiceProvider::class,
\BookStack\App\Providers\ViewTweaksServiceProvider::class,
BookStack\Providers\ThemeServiceProvider::class,
BookStack\Providers\AuthServiceProvider::class,
BookStack\Providers\AppServiceProvider::class,
BookStack\Providers\BroadcastServiceProvider::class,
BookStack\Providers\EventServiceProvider::class,
BookStack\Providers\RouteServiceProvider::class,
BookStack\Providers\CustomFacadeProvider::class,
BookStack\Providers\CustomValidationServiceProvider::class,
],
// Class Aliases
// This array of class aliases to be registered on application start.
'aliases' => Facade::defaultAliases()->merge([
/*
|--------------------------------------------------------------------------
| 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,
// Laravel Packages
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
// Third Party
'ImageTool' => Intervention\Image\Facades\Image::class,
'DomPDF' => Barryvdh\DomPDF\Facade::class,
'SnappyPDF' => Barryvdh\Snappy\Facades\SnappyPdf::class,
// Custom BookStack
'Activity' => BookStack\Facades\Activity::class,
'Permissions' => BookStack\Facades\Permissions::class,
'Theme' => BookStack\Facades\Theme::class,
])->toArray(),
],
// Proxy configuration
'proxies' => env('APP_PROXIES', ''),

View File

@@ -13,10 +13,6 @@ return [
// Options: standard, ldap, saml2, oidc
'method' => env('AUTH_METHOD', 'standard'),
// Automatically initiate login via external auth system if it's the sole auth method.
// Works with saml2 or oidc auth methods.
'auto_initiate' => env('AUTH_AUTO_INITIATE', false),
// Authentication Defaults
// This option controls the default authentication "guard" and password
// reset options for your application.
@@ -59,12 +55,12 @@ return [
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => \BookStack\Users\Models\User::class,
'model' => \BookStack\Auth\User::class,
],
'external' => [
'driver' => 'external-users',
'model' => \BookStack\Users\Models\User::class,
'model' => \BookStack\Auth\User::class,
],
// 'users' => [

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' => 'null',
'default' => env('BROADCAST_DRIVER', 'pusher'),
// Broadcast Connections
// Here you may define all of the broadcast connections that will be used
@@ -22,7 +22,21 @@ return [
// each available type of connection are provided inside this array.
'connections' => [
// Default options removed since we don't use broadcasting.
'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',
],
'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

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

View File

@@ -33,20 +33,17 @@ 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' => [
@@ -57,7 +54,6 @@ 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,15 +21,6 @@ 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

@@ -8,17 +8,19 @@
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
// Configured mail encryption method.
// STARTTLS should still be attempted, but tls/ssl forces TLS usage.
$mailEncryption = env('MAIL_ENCRYPTION', null);
return [
// Mail driver to use.
// From Laravel 7+ this is MAIL_MAILER in laravel.
// Kept as MAIL_DRIVER in BookStack to prevent breaking change.
// Options: smtp, sendmail, log, array
'default' => env('MAIL_DRIVER', 'smtp'),
'driver' => env('MAIL_DRIVER', 'smtp'),
// SMTP host address
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
// SMTP host port
'port' => env('MAIL_PORT', 587),
// Global "From" address & name
'from' => [
@@ -26,43 +28,17 @@ return [
'name' => env('MAIL_FROM_NAME', 'BookStack'),
],
// Mailer Configurations
// Available mailing methods and their settings.
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl') ? 'smtps' : null,
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
'port' => env('MAIL_PORT', 587),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'verify_peer' => env('MAIL_VERIFY_SSL', true),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN'),
],
// Email encryption protocol
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'),
],
// SMTP server username
'username' => env('MAIL_USERNAME'),
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
// SMTP server password
'password' => env('MAIL_PASSWORD'),
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
],
],
// Sendmail application path
'sendmail' => '/usr/sbin/sendmail -bs',
// Email markdown configuration
'markdown' => [
@@ -71,4 +47,11 @@ 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,12 +8,9 @@ return [
// Dump user details after a login request for debugging purposes
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
// Claim, within an OpenId token, to find the user's display name
// Attribute, within a 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),
@@ -35,16 +32,4 @@ return [
// OAuth2 endpoints.
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
// Add extra scopes, upon those required, to the OIDC authentication request
// Multiple values can be provided comma seperated.
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
// Group sync options
// Enable syncing, upon login, of OIDC groups to BookStack roles
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
// Attribute, within a OIDC ID token, to find group names within
'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'),
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
];

View File

@@ -16,27 +16,16 @@ 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,
// User-level default settings
'user' => [
'ui-shortcuts' => '{}',
'ui-shortcuts-enabled' => false,
'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false),
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),

View File

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

View File

@@ -6,7 +6,7 @@ use BookStack\Uploads\ImageService;
use Illuminate\Console\Command;
use Symfony\Component\Console\Output\OutputInterface;
class CleanupImagesCommand extends Command
class CleanupImages extends Command
{
/**
* The name and signature of the console command.
@@ -25,49 +25,60 @@ class CleanupImagesCommand extends Command
*/
protected $description = 'Cleanup images and drawings';
protected $imageService;
/**
* Create a new command instance.
*
* @param \BookStack\Uploads\ImageService $imageService
*/
public function __construct(ImageService $imageService)
{
$this->imageService = $imageService;
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle(ImageService $imageService): int
public function handle()
{
$checkRevisions = !$this->option('all');
$dryRun = !$this->option('force');
$checkRevisions = $this->option('all') ? false : true;
$dryRun = $this->option('force') ? false : true;
if (!$dryRun) {
$this->warn("This operation is destructive and is not guaranteed to be fully accurate.\nEnsure you have a backup of your images.\n");
$proceed = $this->confirm("Are you sure you want to proceed?");
$proceed = $this->confirm("This operation is destructive and is not guaranteed to be fully accurate.\nEnsure you have a backup of your images.\nAre you sure you want to proceed?");
if (!$proceed) {
return 0;
return;
}
}
$deleted = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
$deleted = $this->imageService->deleteUnusedImages($checkRevisions, $dryRun);
$deleteCount = count($deleted);
if ($dryRun) {
$this->comment('Dry run, no images have been deleted');
$this->comment('Dry run, No images have been deleted');
$this->comment($deleteCount . ' images found that would have been deleted');
$this->showDeletedImages($deleted);
$this->comment('Run with -f or --force to perform deletions');
return 0;
return;
}
$this->showDeletedImages($deleted);
$this->comment($deleteCount . ' images deleted');
return 0;
}
protected function showDeletedImages($paths): void
protected function showDeletedImages($paths)
{
if ($this->getOutput()->getVerbosity() <= OutputInterface::VERBOSITY_NORMAL) {
return;
}
if (count($paths) > 0) {
$this->line('Images to delete:');
}
foreach ($paths as $path) {
$this->line($path);
}

View File

@@ -2,10 +2,10 @@
namespace BookStack\Console\Commands;
use BookStack\Activity\Models\Activity;
use BookStack\Actions\Activity;
use Illuminate\Console\Command;
class ClearActivityCommand extends Command
class ClearActivity extends Command
{
/**
* The name and signature of the console command.
@@ -21,13 +21,27 @@ class ClearActivityCommand extends Command
*/
protected $description = 'Clear user activity from the system';
protected $activity;
/**
* Create a new command instance.
*
* @param Activity $activity
*/
public function __construct(Activity $activity)
{
$this->activity = $activity;
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle(): int
public function handle()
{
Activity::query()->truncate();
$this->activity->newQuery()->truncate();
$this->comment('System activity cleared');
return 0;
}
}

View File

@@ -5,7 +5,7 @@ namespace BookStack\Console\Commands;
use BookStack\Entities\Models\PageRevision;
use Illuminate\Console\Command;
class ClearRevisionsCommand extends Command
class ClearRevisions extends Command
{
/**
* The name and signature of the console command.
@@ -23,14 +23,28 @@ class ClearRevisionsCommand extends Command
*/
protected $description = 'Clear page revisions';
protected $pageRevision;
/**
* Create a new command instance.
*
* @param PageRevision $pageRevision
*/
public function __construct(PageRevision $pageRevision)
{
$this->pageRevision = $pageRevision;
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle(): int
public function handle()
{
$deleteTypes = $this->option('all') ? ['version', 'update_draft'] : ['version'];
PageRevision::query()->whereIn('type', $deleteTypes)->delete();
$this->pageRevision->newQuery()->whereIn('type', $deleteTypes)->delete();
$this->comment('Revisions deleted');
return 0;
}
}

View File

@@ -2,10 +2,10 @@
namespace BookStack\Console\Commands;
use BookStack\Activity\Models\View;
use BookStack\Actions\View;
use Illuminate\Console\Command;
class ClearViewsCommand extends Command
class ClearViews extends Command
{
/**
* The name and signature of the console command.
@@ -22,12 +22,21 @@ class ClearViewsCommand extends Command
protected $description = 'Clear all view-counts for all entities';
/**
* Execute the console command.
* Create a new command instance.
*/
public function handle(): int
public function __construct()
{
View::query()->truncate();
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
View::clearAll();
$this->comment('Views cleared');
return 0;
}
}

View File

@@ -3,10 +3,10 @@
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Entities\Repos\BookshelfRepo;
use Illuminate\Console\Command;
class CopyShelfPermissionsCommand extends Command
class CopyShelfPermissions extends Command
{
/**
* The name and signature of the console command.
@@ -26,9 +26,27 @@ class CopyShelfPermissionsCommand extends Command
protected $description = 'Copy shelf permissions to all child books';
/**
* Execute the console command.
* @var BookshelfRepo
*/
public function handle(PermissionsUpdater $permissionsUpdater): int
protected $bookshelfRepo;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(BookshelfRepo $repo)
{
$this->bookshelfRepo = $repo;
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$shelfSlug = $this->option('slug');
$cascadeAll = $this->option('all');
@@ -37,7 +55,7 @@ class CopyShelfPermissionsCommand extends Command
if (!$cascadeAll && !$shelfSlug) {
$this->error('Either a --slug or --all option must be provided.');
return 1;
return;
}
if ($cascadeAll) {
@@ -48,25 +66,24 @@ class CopyShelfPermissionsCommand extends Command
);
if (!$continue && !$this->hasOption('no-interaction')) {
return 0;
return;
}
$shelves = Bookshelf::query()->get(['id']);
$shelves = Bookshelf::query()->get(['id', 'restricted']);
}
if ($shelfSlug) {
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id']);
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id', 'restricted']);
if ($shelves->count() === 0) {
$this->info('No shelves found with the given slug.');
}
}
foreach ($shelves as $shelf) {
$permissionsUpdater->updateBookPermissionsFromShelf($shelf, false);
$this->bookshelfRepo->copyDownPermissions($shelf, false);
$this->info('Copied permissions for shelf [' . $shelf->id . ']');
}
$this->info('Permissions copied for ' . $shelves->count() . ' shelves.');
return 0;
}
}

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