Merge branch 'master' into release

This commit is contained in:
Dan Brown 2021-08-31 22:07:23 +01:00
commit 9865446267
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
794 changed files with 11512 additions and 4555 deletions

View File

@ -281,6 +281,12 @@ ALLOW_CONTENT_SCRIPTS=false
# Contents of the robots.txt file can be overridden, making this option obsolete.
ALLOW_ROBOTS=null
# Allow server-side fetches to be performed to potentially unknown
# and user-provided locations. Primarily used in exports when loading
# in externally referenced assets.
# Can be 'true' or 'false'.
ALLOW_UNTRUSTED_SERVER_FETCHING=false
# A list of hosts that BookStack can be iframed within.
# Space separated if multiple. BookStack host domain is auto-inferred.
# For Example: ALLOWED_IFRAME_HOSTS="https://example.com https://a.example.com"

View File

@ -167,3 +167,19 @@ whenwesober :: Indonesian
Rem (remkovdhoef) :: Dutch
syn7ax69 :: Bulgarian; Turkish
Blaade :: French
Behzad HosseinPoor (behzad.hp) :: Persian
Ole Aldric (Swoy) :: Norwegian Bokmal
fharis arabia (raednahdi) :: Arabic
Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: Turkish
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
Nathanaël (nathanaelhoun) :: French
A Ibnu Hibban (abd.ibnuhibban) :: Indonesian
Frost-ZX :: Chinese Simplified
Kuzma Simonov (ovmach) :: Russian
Vojtěch Krystek (acantophis) :: Czech

View File

@ -20,7 +20,6 @@ use Illuminate\Support\Str;
*/
class Activity extends Model
{
/**
* Get the entity for this activity.
*/
@ -29,6 +28,7 @@ class Activity extends Model
if ($this->entity_type === '') {
$this->entity_type = null;
}
return $this->morphTo('entity');
}
@ -54,7 +54,7 @@ class Activity extends Model
public function isForEntity(): bool
{
return Str::startsWith($this->type, [
'page_', 'chapter_', 'book_', 'bookshelf_'
'page_', 'chapter_', 'book_', 'bookshelf_',
]);
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Actions;
<?php
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
@ -33,6 +35,7 @@ class ActivityService
/**
* Add a generic activity event to the database.
*
* @param string|Loggable $detail
*/
public function add(string $type, $detail = '')
@ -98,10 +101,10 @@ class ActivityService
$queryIds = [$entity->getMorphClass() => [$entity->id]];
if ($entity->isA('book')) {
$queryIds[(new Chapter)->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
$queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
}
if ($entity->isA('book') || $entity->isA('chapter')) {
$queryIds[(new Page)->getMorphClass()] = $entity->pages()->visible()->pluck('id');
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->visible()->pluck('id');
}
$query = $this->activity->newQuery();
@ -143,7 +146,9 @@ class ActivityService
/**
* Filters out similar activity.
*
* @param Activity[] $activities
*
* @return array
*/
protected function filterSimilar(iterable $activities): array
@ -185,7 +190,7 @@ class ActivityService
return;
}
$message = str_replace("%u", $username, $message);
$message = str_replace('%u', $username, $message);
$channel = config('logging.failed_login.channel');
Log::channel($channel)->warning($message);
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Actions;
<?php
namespace BookStack\Actions;
class ActivityType
{
@ -48,4 +50,7 @@ class ActivityType
const AUTH_PASSWORD_RESET_UPDATE = 'auth_password_reset_update';
const AUTH_LOGIN = 'auth_login';
const AUTH_REGISTER = 'auth_register';
const MFA_SETUP_METHOD = 'mfa_setup_method';
const MFA_REMOVE_METHOD = 'mfa_remove_method';
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Actions;
<?php
namespace BookStack\Actions;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
@ -18,7 +20,7 @@ class Comment extends Model
protected $appends = ['created', 'updated'];
/**
* Get the entity that this comment belongs to
* Get the entity that this comment belongs to.
*/
public function entity(): MorphTo
{
@ -35,6 +37,7 @@ class Comment extends Model
/**
* Get created date as a relative diff.
*
* @return mixed
*/
public function getCreatedAttribute()
@ -44,6 +47,7 @@ class Comment extends Model
/**
* Get updated date as a relative diff.
*
* @return mixed
*/
public function getUpdatedAttribute()

View File

@ -1,21 +1,21 @@
<?php namespace BookStack\Actions;
<?php
namespace BookStack\Actions;
use BookStack\Entities\Models\Entity;
use League\CommonMark\CommonMarkConverter;
use BookStack\Facades\Activity as ActivityService;
use League\CommonMark\CommonMarkConverter;
/**
* Class CommentRepo
* Class CommentRepo.
*/
class CommentRepo
{
/**
* @var Comment $comment
* @var Comment
*/
protected $comment;
public function __construct(Comment $comment)
{
$this->comment = $comment;
@ -46,6 +46,7 @@ class CommentRepo
$entity->comments()->save($comment);
ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
return $comment;
}
@ -58,6 +59,7 @@ class CommentRepo
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
$comment->save();
return $comment;
}
@ -89,6 +91,7 @@ class CommentRepo
protected function getNextLocalId(Entity $entity): int
{
$comments = $entity->comments(false)->orderBy('local_id', 'desc')->first();
return ($comments->local_id ?? 0) + 1;
}
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Actions;
<?php
namespace BookStack\Actions;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Actions;
<?php
namespace BookStack\Actions;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
@ -9,7 +11,7 @@ class Tag extends Model
protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at'];
/**
* Get the entity that this tag belongs to
* Get the entity that this tag belongs to.
*/
public function entity(): MorphTo
{

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Actions;
<?php
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
@ -7,7 +9,6 @@ use Illuminate\Support\Collection;
class TagRepo
{
protected $tag;
protected $permissionService;
@ -37,6 +38,7 @@ class TagRepo
}
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['name'])->pluck('name');
}
@ -62,11 +64,12 @@ class TagRepo
}
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['value'])->pluck('value');
}
/**
* Save an array of tags to an entity
* Save an array of tags to an entity.
*/
public function saveTagsToEntity(Entity $entity, array $tags = []): iterable
{
@ -89,6 +92,7 @@ class TagRepo
{
$name = trim($input['name']);
$value = isset($input['value']) ? trim($input['value']) : '';
return $this->tag->newInstance(['name' => $name, 'value' => $value]);
}
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Actions;
<?php
namespace BookStack\Actions;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
@ -16,7 +18,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
*/
class View extends Model
{
protected $fillable = ['user_id', 'views'];
/**

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Api;
<?php
namespace BookStack\Api;
use BookStack\Http\Controllers\Api\ApiController;
use Illuminate\Contracts\Container\BindingResolutionException;
@ -12,7 +14,6 @@ use ReflectionMethod;
class ApiDocsGenerator
{
protected $reflectionClasses = [];
protected $controllerClasses = [];
@ -30,6 +31,7 @@ class ApiDocsGenerator
$docs = (new static())->generate();
Cache::put($cacheKey, $docs, 60 * 24);
}
return $docs;
}
@ -42,6 +44,7 @@ class ApiDocsGenerator
$apiRoutes = $this->loadDetailsFromControllers($apiRoutes);
$apiRoutes = $this->loadDetailsFromFiles($apiRoutes);
$apiRoutes = $apiRoutes->groupBy('base_model');
return $apiRoutes;
}
@ -57,6 +60,7 @@ class ApiDocsGenerator
$exampleContent = file_exists($exampleFile) ? file_get_contents($exampleFile) : null;
$route["example_{$exampleType}"] = $exampleContent;
}
return $route;
});
}
@ -71,12 +75,14 @@ class ApiDocsGenerator
$comment = $method->getDocComment();
$route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
$route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
return $route;
});
}
/**
* Load body params and their rules by inspecting the given class and method name.
*
* @throws BindingResolutionException
*/
protected function getBodyParamsFromClass(string $className, string $methodName): ?array
@ -92,6 +98,7 @@ class ApiDocsGenerator
foreach ($rules as $param => $ruleString) {
$rules[$param] = explode('|', $ruleString);
}
return count($rules) > 0 ? $rules : null;
}
@ -102,11 +109,13 @@ class ApiDocsGenerator
{
$matches = [];
preg_match_all('/^\s*?\*\s((?![@\s]).*?)$/m', $comment, $matches);
return implode(' ', $matches[1] ?? []);
}
/**
* Get a reflection method from the given class name and method name.
*
* @throws ReflectionException
*/
protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
@ -131,6 +140,7 @@ class ApiDocsGenerator
[$controller, $controllerMethod] = explode('@', $route->action['uses']);
$baseModelName = explode('.', explode('/', $route->uri)[1])[0];
$shortName = $baseModelName . '-' . $controllerMethod;
return [
'name' => $shortName,
'uri' => $route->uri,

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Api;
<?php
namespace BookStack\Api;
use BookStack\Auth\User;
use BookStack\Interfaces\Loggable;
@ -7,7 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* Class ApiToken
* Class ApiToken.
*
* @property int $id
* @property string $token_id
* @property string $secret
@ -19,7 +22,7 @@ class ApiToken extends Model implements Loggable
{
protected $fillable = ['name', 'expires_at'];
protected $casts = [
'expires_at' => 'date:Y-m-d'
'expires_at' => 'date:Y-m-d',
];
/**

View File

@ -2,6 +2,7 @@
namespace BookStack\Api;
use BookStack\Auth\Access\LoginService;
use BookStack\Exceptions\ApiAuthException;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable;
@ -12,7 +13,6 @@ use Symfony\Component\HttpFoundation\Request;
class ApiTokenGuard implements Guard
{
use GuardHelpers;
/**
@ -20,9 +20,14 @@ class ApiTokenGuard implements Guard
*/
protected $request;
/**
* @var LoginService
*/
protected $loginService;
/**
* The last auth exception thrown in this request.
*
* @var ApiAuthException
*/
protected $lastAuthException;
@ -30,9 +35,10 @@ class ApiTokenGuard implements Guard
/**
* ApiTokenGuard constructor.
*/
public function __construct(Request $request)
public function __construct(Request $request, LoginService $loginService)
{
$this->request = $request;
$this->loginService = $loginService;
}
/**
@ -47,6 +53,7 @@ class ApiTokenGuard implements Guard
}
$user = null;
try {
$user = $this->getAuthorisedUserFromRequest();
} catch (ApiAuthException $exception) {
@ -54,15 +61,16 @@ class ApiTokenGuard implements Guard
}
$this->user = $user;
return $user;
}
/**
* Determine if current user is authenticated. If not, throw an exception.
*
* @return \Illuminate\Contracts\Auth\Authenticatable
*
* @throws ApiAuthException
*
* @return \Illuminate\Contracts\Auth\Authenticatable
*/
public function authenticate()
{
@ -79,6 +87,7 @@ class ApiTokenGuard implements Guard
/**
* Check the API token in the request and fetch a valid authorised user.
*
* @throws ApiAuthException
*/
protected function getAuthorisedUserFromRequest(): Authenticatable
@ -93,11 +102,16 @@ class ApiTokenGuard implements Guard
$this->validateToken($token, $secret);
if ($this->loginService->awaitingEmailConfirmation($token->user)) {
throw new ApiAuthException(trans('errors.email_confirmation_awaiting'));
}
return $token->user;
}
/**
* Validate the format of the token header value string.
*
* @throws ApiAuthException
*/
protected function validateTokenHeaderValue(string $authToken): void
@ -114,6 +128,7 @@ class ApiTokenGuard implements Guard
/**
* Validate the given secret against the given token and ensure the token
* currently has access to the instance API.
*
* @throws ApiAuthException
*/
protected function validateToken(?ApiToken $token, string $secret): void

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Api;
<?php
namespace BookStack\Api;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
@ -6,7 +8,6 @@ use Illuminate\Http\Request;
class ListingResponseBuilder
{
protected $query;
protected $request;
protected $fields;
@ -18,7 +19,7 @@ class ListingResponseBuilder
'lt' => '<',
'gte' => '>=',
'lte' => '<=',
'like' => 'like'
'like' => 'like',
];
/**
@ -54,6 +55,7 @@ class ListingResponseBuilder
{
$query = $this->countAndOffsetQuery($query);
$query = $this->sortQuery($query);
return $query->get($this->fields);
}
@ -95,6 +97,7 @@ class ListingResponseBuilder
}
$queryOperator = $this->filterOperators[$filterOperator];
return [$field, $queryOperator, $value];
}

View File

@ -4,11 +4,11 @@ namespace BookStack;
class Application extends \Illuminate\Foundation\Application
{
/**
* Get the path to the application configuration files.
*
* @param string $path Optionally, a path to append to the config path
*
* @return string
*/
public function configPath($path = '')

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Auth\Access;
<?php
namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\ConfirmationEmailException;
@ -12,7 +14,7 @@ class EmailConfirmationService extends UserTokenService
/**
* Create new confirmation for a user,
* Also removes any existing old ones.
* @param User $user
*
* @throws ConfirmationEmailException
*/
public function sendConfirmation(User $user)
@ -29,7 +31,6 @@ class EmailConfirmationService extends UserTokenService
/**
* Check if confirmation is required in this instance.
* @return bool
*/
public function confirmationRequired(): bool
{

View File

@ -1,10 +1,10 @@
<?php namespace BookStack\Auth\Access;
<?php
namespace BookStack\Auth\Access;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class ExternalAuthService
{
@ -19,6 +19,7 @@ class ExternalAuthService
}
$roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
return in_array($roleName, $groupNames);
}
@ -57,7 +58,7 @@ class ExternalAuthService
}
/**
* Sync the groups to the user roles for the current user
* Sync the groups to the user roles for the current user.
*/
public function syncWithGroups(User $user, array $userGroups): void
{

View File

@ -7,7 +7,6 @@ use Illuminate\Contracts\Auth\UserProvider;
class ExternalBaseUserProvider implements UserProvider
{
/**
* The user model.
*
@ -17,6 +16,7 @@ class ExternalBaseUserProvider implements UserProvider
/**
* LdapUserProvider constructor.
*
* @param $model
*/
public function __construct(string $model)
@ -32,13 +32,15 @@ class ExternalBaseUserProvider implements UserProvider
public function createModel()
{
$class = '\\' . ltrim($this->model, '\\');
return new $class;
return new $class();
}
/**
* Retrieve a user by their unique identifier.
*
* @param mixed $identifier
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveById($identifier)
@ -51,6 +53,7 @@ class ExternalBaseUserProvider implements UserProvider
*
* @param mixed $identifier
* @param string $token
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveByToken($identifier, $token)
@ -58,12 +61,12 @@ class ExternalBaseUserProvider implements UserProvider
return null;
}
/**
* Update the "remember me" token for the given user in storage.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param string $token
*
* @return void
*/
public function updateRememberToken(Authenticatable $user, $token)
@ -75,12 +78,14 @@ class ExternalBaseUserProvider implements UserProvider
* Retrieve a user by the given credentials.
*
* @param array $credentials
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveByCredentials(array $credentials)
{
// Search current user base by looking up a uid
$model = $this->createModel();
return $model->newQuery()
->where('external_auth_id', $credentials['external_auth_id'])
->first();
@ -91,6 +96,7 @@ class ExternalBaseUserProvider implements UserProvider
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param array $credentials
*
* @return bool
*/
public function validateCredentials(Authenticatable $user, array $credentials)

View File

@ -119,6 +119,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
* Log a user into the application without sessions or cookies.
*
* @param array $credentials
*
* @return bool
*/
public function once(array $credentials = [])
@ -136,6 +137,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
* Log the given user ID into the application without sessions or cookies.
*
* @param mixed $id
*
* @return \Illuminate\Contracts\Auth\Authenticatable|false
*/
public function onceUsingId($id)
@ -153,6 +155,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
* Validate a user's credentials.
*
* @param array $credentials
*
* @return bool
*/
public function validate(array $credentials = [])
@ -160,12 +163,12 @@ class ExternalBaseSessionGuard implements StatefulGuard
return false;
}
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
*
* @return bool
*/
public function attempt(array $credentials = [], $remember = false)
@ -178,16 +181,13 @@ class ExternalBaseSessionGuard implements StatefulGuard
*
* @param mixed $id
* @param bool $remember
*
* @return \Illuminate\Contracts\Auth\Authenticatable|false
*/
public function loginUsingId($id, $remember = false)
{
if (! is_null($user = $this->provider->retrieveById($id))) {
$this->login($user, $remember);
return $user;
}
// Always return false as to disable this method,
// Logins should route through LoginService.
return false;
}
@ -196,6 +196,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param bool $remember
*
* @return void
*/
public function login(AuthenticatableContract $user, $remember = false)
@ -209,6 +210,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
* Update the session with the given ID.
*
* @param string $id
*
* @return void
*/
protected function updateSession($id)
@ -289,6 +291,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
* Set the current user.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
*
* @return $this
*/
public function setUser(AuthenticatableContract $user)

View File

@ -6,8 +6,8 @@ use BookStack\Auth\Access\LdapService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Exceptions\UserRegistrationException;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session;
@ -15,7 +15,6 @@ use Illuminate\Support\Str;
class LdapSessionGuard extends ExternalBaseSessionGuard
{
protected $ldapService;
/**
@ -36,8 +35,10 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
* Validate a user's credentials.
*
* @param array $credentials
* @return bool
*
* @throws LdapException
*
* @return bool
*/
public function validate(array $credentials = [])
{
@ -45,7 +46,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
if (isset($userDetails['uid'])) {
$this->lastAttempted = $this->provider->retrieveByCredentials([
'external_auth_id' => $userDetails['uid']
'external_auth_id' => $userDetails['uid'],
]);
}
@ -57,9 +58,11 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
*
* @param array $credentials
* @param bool $remember
* @return bool
*
* @throws LoginAttemptException
* @throws LdapException
*
* @return bool
*/
public function attempt(array $credentials = [], $remember = false)
{
@ -69,7 +72,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
$user = null;
if (isset($userDetails['uid'])) {
$this->lastAttempted = $user = $this->provider->retrieveByCredentials([
'external_auth_id' => $userDetails['uid']
'external_auth_id' => $userDetails['uid'],
]);
}
@ -96,11 +99,13 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
}
$this->login($user, $remember);
return true;
}
/**
* Create a new user from the given ldap credentials and login credentials
* Create a new user from the given ldap credentials and login credentials.
*
* @throws LoginAttemptEmailNeededException
* @throws LoginAttemptException
* @throws UserRegistrationException
@ -122,6 +127,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
$user = $this->registrationService->registerUser($details, null, false);
$this->ldapService->saveAndAttachAvatar($user, $ldapUserDetails);
return $user;
}
}

View File

@ -3,7 +3,7 @@
namespace BookStack\Auth\Access\Guards;
/**
* Saml2 Session Guard
* Saml2 Session Guard.
*
* The saml2 login process is async in nature meaning it does not fit very well
* into the default laravel 'Guard' auth flow. Instead most of the logic is done
@ -16,6 +16,7 @@ class Saml2SessionGuard extends ExternalBaseSessionGuard
* Validate a user's credentials.
*
* @param array $credentials
*
* @return bool
*/
public function validate(array $credentials = [])
@ -28,6 +29,7 @@ class Saml2SessionGuard extends ExternalBaseSessionGuard
*
* @param array $credentials
* @param bool $remember
*
* @return bool
*/
public function attempt(array $credentials = [], $remember = false)

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Auth\Access;
<?php
namespace BookStack\Auth\Access;
/**
* Class Ldap
@ -7,11 +9,12 @@
*/
class Ldap
{
/**
* Connect to a LDAP server.
*
* @param string $hostName
* @param int $port
*
* @return resource
*/
public function connect($hostName, $port)
@ -21,9 +24,11 @@ class Ldap
/**
* Set the value of a LDAP option for the given connection.
*
* @param resource $ldapConnection
* @param int $option
* @param mixed $value
*
* @return bool
*/
public function setOption($ldapConnection, $option, $value)
@ -41,8 +46,10 @@ class Ldap
/**
* Set the version number for the given ldap connection.
*
* @param $ldapConnection
* @param $version
*
* @return bool
*/
public function setVersion($ldapConnection, $version)
@ -52,10 +59,12 @@ class Ldap
/**
* 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)
@ -65,8 +74,10 @@ class Ldap
/**
* Get entries from an ldap search result.
*
* @param resource $ldapConnection
* @param resource $ldapSearchResult
*
* @return array
*/
public function getEntries($ldapConnection, $ldapSearchResult)
@ -76,23 +87,28 @@ class Ldap
/**
* 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)
@ -102,8 +118,10 @@ class Ldap
/**
* 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)
@ -113,12 +131,14 @@ class Ldap
/**
* 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)
public function escape(string $value, string $ignore = '', int $flags = 0)
{
return ldap_escape($value, $ignore, $flags);
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Auth\Access;
<?php
namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
@ -13,7 +15,6 @@ use Illuminate\Support\Facades\Log;
*/
class LdapService extends ExternalAuthService
{
protected $ldap;
protected $ldapConnection;
protected $userAvatars;
@ -33,6 +34,7 @@ class LdapService extends ExternalAuthService
/**
* Check if groups should be synced.
*
* @return bool
*/
public function shouldSyncGroups()
@ -42,6 +44,7 @@ class LdapService extends ExternalAuthService
/**
* Search for attributes for a specific user on the ldap.
*
* @throws LdapException
*/
private function getUserWithAttributes(string $userName, array $attributes): ?array
@ -73,6 +76,7 @@ class LdapService extends ExternalAuthService
/**
* Get the details of a user from LDAP using the given username.
* User found via configurable user filter.
*
* @throws LdapException
*/
public function getUserDetails(string $userName): ?array
@ -137,6 +141,7 @@ class LdapService extends ExternalAuthService
/**
* Check if the given credentials are valid for the given user.
*
* @throws LdapException
*/
public function validateUserCredentials(?array $ldapUserDetails, string $password): bool
@ -146,6 +151,7 @@ class LdapService extends ExternalAuthService
}
$ldapConnection = $this->getConnection();
try {
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password);
} catch (ErrorException $e) {
@ -158,7 +164,9 @@ class LdapService extends ExternalAuthService
/**
* Bind the system user to the LDAP connection using the given credentials
* otherwise anonymous access is attempted.
*
* @param $connection
*
* @throws LdapException
*/
protected function bindSystemUser($connection)
@ -181,8 +189,10 @@ class LdapService extends ExternalAuthService
/**
* Get the connection to the LDAP server.
* Creates a new connection if one does not exist.
* @return resource
*
* @throws LdapException
*
* @return resource
*/
protected function getConnection()
{
@ -222,6 +232,7 @@ class LdapService extends ExternalAuthService
}
$this->ldapConnection = $ldapConnection;
return $this->ldapConnection;
}
@ -241,6 +252,7 @@ class LdapService extends ExternalAuthService
// Otherwise, extract the port out
$hostName = $serverNameParts[0];
$ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
return ['host' => $hostName, 'port' => $ldapPort];
}
@ -254,11 +266,13 @@ class LdapService extends ExternalAuthService
$newKey = '${' . $key . '}';
$newAttrs[$newKey] = $this->ldap->escape($attrText);
}
return strtr($filterString, $newAttrs);
}
/**
* Get the groups a user is a part of on ldap.
*
* @throws LdapException
*/
public function getUserGroups(string $userName): array
@ -272,11 +286,13 @@ class LdapService extends ExternalAuthService
$userGroups = $this->groupFilter($user);
$userGroups = $this->getGroupsRecursive($userGroups, []);
return $userGroups;
}
/**
* Get the parent groups of an array of groups.
*
* @throws LdapException
*/
private function getGroupsRecursive(array $groupsArray, array $checked): array
@ -303,6 +319,7 @@ class LdapService extends ExternalAuthService
/**
* Get the parent groups of a single group.
*
* @throws LdapException
*/
private function getGroupGroups(string $groupName): array
@ -351,6 +368,7 @@ class LdapService extends ExternalAuthService
/**
* Sync the LDAP groups to the user roles for the current user.
*
* @throws LdapException
*/
public function syncGroups(User $user, string $username)

View File

@ -0,0 +1,164 @@
<?php
namespace BookStack\Auth\Access;
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 Exception;
class LoginService
{
protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
protected $mfaSession;
protected $emailConfirmationService;
public function __construct(MfaSession $mfaSession, EmailConfirmationService $emailConfirmationService)
{
$this->mfaSession = $mfaSession;
$this->emailConfirmationService = $emailConfirmationService;
}
/**
* Log the given user into the system.
* Will start a login of the given user but will prevent if there's
* a reason to (MFA or Unconfirmed Email).
* Returns a boolean to indicate the current login result.
*
* @throws StoppedAuthenticationException
*/
public function login(User $user, string $method, bool $remember = false): void
{
if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
$this->setLastLoginAttemptedForUser($user, $method, $remember);
throw new StoppedAuthenticationException($user, $this);
}
$this->clearLastLoginAttempted();
auth()->login($user, $remember);
Activity::add(ActivityType::AUTH_LOGIN, "{$method}; {$user->logDescriptor()}");
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
// Authenticate on all session guards if a likely admin
if ($user->can('users-manage') && $user->can('user-roles-manage')) {
$guards = ['standard', 'ldap', 'saml2'];
foreach ($guards as $guard) {
auth($guard)->login($user);
}
}
}
/**
* Reattempt a system login after a previous stopped attempt.
*
* @throws Exception
*/
public function reattemptLoginFor(User $user)
{
if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
throw new Exception('Login reattempt user does align with current session state');
}
$lastLoginDetails = $this->getLastLoginAttemptDetails();
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false);
}
/**
* Get the last user that was attempted to be logged in.
* Only exists if the last login attempt had correct credentials
* but had been prevented by a secondary factor.
*/
public function getLastLoginAttemptUser(): ?User
{
$id = $this->getLastLoginAttemptDetails()['user_id'];
return User::query()->where('id', '=', $id)->first();
}
/**
* Get the details of the last login attempt.
* Checks upon a ttl of about 1 hour since that last attempted login.
*
* @return array{user_id: ?string, method: ?string, remember: bool}
*/
protected function getLastLoginAttemptDetails(): array
{
$value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
if (!$value) {
return ['user_id' => null, 'method' => null];
}
[$id, $method, $remember, $time] = explode(':', $value);
$hourAgo = time() - (60 * 60);
if ($time < $hourAgo) {
$this->clearLastLoginAttempted();
return ['user_id' => null, 'method' => null];
}
return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
}
/**
* Set the last login attempted user.
* Must be only used when credentials are correct and a login could be
* achieved but a secondary factor has stopped the login.
*/
protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
{
session()->put(
self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,
implode(':', [$user->id, $method, $remember, time()])
);
}
/**
* Clear the last login attempted session value.
*/
protected function clearLastLoginAttempted(): void
{
session()->remove(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
}
/**
* Check if MFA verification is needed.
*/
public function needsMfaVerification(User $user): bool
{
return !$this->mfaSession->isVerifiedForUser($user) && $this->mfaSession->isRequiredForUser($user);
}
/**
* Check if the given user is awaiting email confirmation.
*/
public function awaitingEmailConfirmation(User $user): bool
{
return $this->emailConfirmationService->confirmationRequired() && !$user->email_confirmed;
}
/**
* Attempt the login of a user using the given credentials.
* Meant to mirror Laravel's default guard 'attempt' method
* but in a manner that always routes through our login system.
* May interrupt the flow if extra authentication requirements are imposed.
*
* @throws StoppedAuthenticationException
*/
public function attempt(array $credentials, string $method, bool $remember = false): bool
{
$result = auth()->attempt($credentials, $remember);
if ($result) {
$user = auth()->user();
auth()->logout();
$this->login($user, $method, $remember);
}
return $result;
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace BookStack\Auth\Access\Mfa;
use Illuminate\Support\Str;
class BackupCodeService
{
/**
* Generate a new set of 16 backup codes.
*/
public function generateNewSet(): array
{
$codes = [];
while (count($codes) < 16) {
$code = Str::random(5) . '-' . Str::random(5);
if (!in_array($code, $codes)) {
$codes[] = strtolower($code);
}
}
return $codes;
}
/**
* Check if the given code matches one of the available options.
*/
public function inputCodeExistsInSet(string $code, string $codeSet): bool
{
$cleanCode = $this->cleanInputCode($code);
$codes = json_decode($codeSet);
return in_array($cleanCode, $codes);
}
/**
* Remove the given input code from the given available options.
* Will return a JSON string containing the codes.
*/
public function removeInputCodeFromSet(string $code, string $codeSet): string
{
$cleanCode = $this->cleanInputCode($code);
$codes = json_decode($codeSet);
$pos = array_search($cleanCode, $codes, true);
array_splice($codes, $pos, 1);
return json_encode($codes);
}
/**
* Count the number of codes in the given set.
*/
public function countCodesInSet(string $codeSet): int
{
return count(json_decode($codeSet));
}
protected function cleanInputCode(string $code): string
{
return strtolower(str_replace(' ', '-', trim($code)));
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace BookStack\Auth\Access\Mfa;
use BookStack\Auth\User;
class MfaSession
{
/**
* Check if MFA is required for the given user.
*/
public function isRequiredForUser(User $user): bool
{
// TODO - Test both these cases
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
}
/**
* Check if the given user is pending MFA setup.
* (MFA required but not yet configured).
*/
public function isPendingMfaSetup(User $user): bool
{
return $this->isRequiredForUser($user) && !$user->mfaValues()->exists();
}
/**
* Check if a role of the given user enforces MFA.
*/
protected function userRoleEnforcesMfa(User $user): bool
{
return $user->roles()
->where('mfa_enforced', '=', true)
->exists();
}
/**
* Check if the current MFA session has already been verified for the given user.
*/
public function isVerifiedForUser(User $user): bool
{
return session()->get($this->getMfaVerifiedSessionKey($user)) === 'true';
}
/**
* Mark the current session as MFA-verified.
*/
public function markVerifiedForUser(User $user): void
{
session()->put($this->getMfaVerifiedSessionKey($user), 'true');
}
/**
* Get the session key in which the MFA verification status is stored.
*/
protected function getMfaVerifiedSessionKey(User $user): string
{
return 'mfa-verification-passed:' . $user->id;
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace BookStack\Auth\Access\Mfa;
use BookStack\Auth\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $user_id
* @property string $method
* @property string $value
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class MfaValue extends Model
{
protected static $unguarded = true;
const METHOD_TOTP = 'totp';
const METHOD_BACKUP_CODES = 'backup_codes';
/**
* Get all the MFA methods available.
*/
public static function allMethods(): array
{
return [self::METHOD_TOTP, self::METHOD_BACKUP_CODES];
}
/**
* Upsert a new MFA value for the given user and method
* using the provided value.
*/
public static function upsertWithValue(User $user, string $method, string $value): void
{
/** @var MfaValue $mfaVal */
$mfaVal = static::query()->firstOrNew([
'user_id' => $user->id,
'method' => $method,
]);
$mfaVal->setValue($value);
$mfaVal->save();
}
/**
* Easily get the decrypted MFA value for the given user and method.
*/
public static function getValueForUser(User $user, string $method): ?string
{
/** @var MfaValue $mfaVal */
$mfaVal = static::query()
->where('user_id', '=', $user->id)
->where('method', '=', $method)
->first();
return $mfaVal ? $mfaVal->getValue() : null;
}
/**
* Decrypt the value attribute upon access.
*/
protected function getValue(): string
{
return decrypt($this->value);
}
/**
* Encrypt the value attribute upon access.
*/
protected function setValue($value): void
{
$this->value = encrypt($value);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace BookStack\Auth\Access\Mfa;
use BaconQrCode\Renderer\Color\Rgb;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\Fill;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use PragmaRX\Google2FA\Google2FA;
use PragmaRX\Google2FA\Support\Constants;
class TotpService
{
protected $google2fa;
public function __construct(Google2FA $google2fa)
{
$this->google2fa = $google2fa;
// Use SHA1 as a default, Personal testing of other options in 2021 found
// many apps lack support for other algorithms yet still will scan
// the code causing a confusing UX.
$this->google2fa->setAlgorithm(Constants::SHA1);
}
/**
* Generate a new totp secret key.
*/
public function generateSecret(): string
{
/** @noinspection PhpUnhandledExceptionInspection */
return $this->google2fa->generateSecretKey();
}
/**
* Generate a TOTP URL from secret key.
*/
public function generateUrl(string $secret): string
{
return $this->google2fa->getQRCodeUrl(
setting('app-name'),
user()->email,
$secret
);
}
/**
* Generate a QR code to display a TOTP URL.
*/
public function generateQrCodeSvg(string $url): string
{
$color = Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(32, 110, 167));
return (new Writer(
new ImageRenderer(
new RendererStyle(192, 0, null, null, $color),
new SvgImageBackEnd()
)
))->writeString($url);
}
/**
* Verify that the user provided code is valid for the secret.
* The secret must be known, not user-provided.
*/
public function verifyCode(string $code, string $secret): bool
{
/** @noinspection PhpUnhandledExceptionInspection */
return $this->google2fa->verifyKey($secret, $code);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace BookStack\Auth\Access\Mfa;
use Illuminate\Contracts\Validation\Rule;
class TotpValidationRule implements Rule
{
protected $secret;
protected $totpService;
/**
* Create a new rule instance.
* Takes the TOTP secret that must be system provided, not user provided.
*/
public function __construct(string $secret)
{
$this->secret = $secret;
$this->totpService = app()->make(TotpService::class);
}
/**
* Determine if the validation rule passes.
*/
public function passes($attribute, $value)
{
return $this->totpService->verifyCode($value, $this->secret);
}
/**
* Get the validation error message.
*/
public function message()
{
return trans('validation.totp');
}
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Auth\Access;
<?php
namespace BookStack\Auth\Access;
use BookStack\Actions\ActivityType;
use BookStack\Auth\SocialAccount;
@ -12,7 +14,6 @@ use Exception;
class RegistrationService
{
protected $userRepo;
protected $emailConfirmationService;
@ -27,6 +28,7 @@ class RegistrationService
/**
* Check whether or not registrations are allowed in the app settings.
*
* @throws UserRegistrationException
*/
public function ensureRegistrationAllowed()
@ -44,11 +46,13 @@ class RegistrationService
{
$authMethod = config('auth.method');
$authMethodsWithRegistration = ['standard'];
return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled');
}
/**
* The registrations flow for all users.
*
* @throws UserRegistrationException
*/
public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
@ -84,6 +88,7 @@ class RegistrationService
session()->flash('sent-email-confirmation', true);
} catch (Exception $e) {
$message = trans('auth.email_confirm_send_error');
throw new UserRegistrationException($message, '/register/confirm');
}
}
@ -94,6 +99,7 @@ class RegistrationService
/**
* Ensure that the given email meets any active email domain registration restrictions.
* Throws if restrictions are active and the email does not match an allowed domain.
*
* @throws UserRegistrationException
*/
protected function ensureEmailDomainAllowed(string $userEmail): void
@ -105,9 +111,10 @@ class RegistrationService
}
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
$userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, "@"), 1);
$userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, '@'), 1);
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
$redirect = $this->registrationAllowed() ? '/register' : '/login';
throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), $redirect);
}
}

View File

@ -1,13 +1,12 @@
<?php namespace BookStack\Auth\Access;
<?php
namespace BookStack\Auth\Access;
use BookStack\Actions\ActivityType;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\SamlException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Exception;
use Illuminate\Support\Str;
use OneLogin\Saml2\Auth;
@ -23,26 +22,28 @@ class Saml2Service extends ExternalAuthService
{
protected $config;
protected $registrationService;
protected $user;
protected $loginService;
/**
* Saml2Service constructor.
*/
public function __construct(RegistrationService $registrationService, User $user)
public function __construct(RegistrationService $registrationService, LoginService $loginService)
{
$this->config = config('saml2');
$this->registrationService = $registrationService;
$this->user = $user;
$this->loginService = $loginService;
}
/**
* Initiate a login flow.
*
* @throws Error
*/
public function login(): array
{
$toolKit = $this->getToolkit();
$returnRoute = url('/saml2/acs');
return [
'url' => $toolKit->login($returnRoute, [], false, false, true),
'id' => $toolKit->getLastRequestID(),
@ -51,6 +52,7 @@ class Saml2Service extends ExternalAuthService
/**
* Initiate a logout flow.
*
* @throws Error
*/
public function logout(): array
@ -78,6 +80,7 @@ class Saml2Service extends ExternalAuthService
* Process the ACS response from the idp and return the
* matching, or new if registration active, user matched to the idp.
* Returns null if not authenticated.
*
* @throws Error
* @throws SamlException
* @throws ValidationError
@ -108,6 +111,7 @@ class Saml2Service extends ExternalAuthService
/**
* Process a response for the single logout service.
*
* @throws Error
*/
public function processSlsResponse(?string $requestId): ?string
@ -124,6 +128,7 @@ class Saml2Service extends ExternalAuthService
}
$this->actionLogout();
return $redirect;
}
@ -138,6 +143,7 @@ class Saml2Service extends ExternalAuthService
/**
* Get the metadata for this service provider.
*
* @throws Error
*/
public function metadata(): string
@ -159,6 +165,7 @@ class Saml2Service extends ExternalAuthService
/**
* Load the underlying Onelogin SAML2 toolkit.
*
* @throws Error
* @throws Exception
*/
@ -178,6 +185,7 @@ class Saml2Service extends ExternalAuthService
$spSettings = $this->loadOneloginServiceProviderDetails();
$settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
return new Auth($settings);
}
@ -192,13 +200,13 @@ class Saml2Service extends ExternalAuthService
'url' => url('/saml2/acs'),
],
'singleLogoutService' => [
'url' => url('/saml2/sls')
'url' => url('/saml2/sls'),
],
];
return [
'baseurl' => url('/saml2'),
'sp' => $spDetails
'sp' => $spDetails,
];
}
@ -211,7 +219,7 @@ class Saml2Service extends ExternalAuthService
}
/**
* Calculate the display name
* Calculate the display name.
*/
protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string
{
@ -297,6 +305,7 @@ class Saml2Service extends ExternalAuthService
$data = $data[0];
break;
}
return $data;
}
@ -315,11 +324,12 @@ class Saml2Service extends ExternalAuthService
/**
* Get the user from the database for the specified details.
*
* @throws UserRegistrationException
*/
protected function getOrRegisterUser(array $userDetails): ?User
{
$user = $this->user->newQuery()
$user = User::query()
->where('external_auth_id', '=', $userDetails['external_id'])
->first();
@ -340,9 +350,11 @@ class Saml2Service extends ExternalAuthService
/**
* Process the SAML response for a user. Login the user when
* they exist, optionally registering them automatically.
*
* @throws SamlException
* @throws JsonDebugException
* @throws UserRegistrationException
* @throws StoppedAuthenticationException
*/
public function processLoginCallback(string $samlID, array $samlAttributes): User
{
@ -375,9 +387,8 @@ class Saml2Service extends ExternalAuthService
$this->syncWithGroups($user, $groups);
}
auth()->login($user);
Activity::add(ActivityType::AUTH_LOGIN, "saml2; {$user->logDescriptor()}");
Theme::dispatch(ThemeEvents::AUTH_LOGIN, 'saml2', $user);
$this->loginService->login($user, 'saml2');
return $user;
}
}

View File

@ -1,14 +1,12 @@
<?php namespace BookStack\Auth\Access;
<?php
namespace BookStack\Auth\Access;
use BookStack\Actions\ActivityType;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\User;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite;
@ -21,12 +19,19 @@ class SocialAuthService
{
/**
* The core socialite library used.
*
* @var Socialite
*/
protected $socialite;
/**
* @var LoginService
*/
protected $loginService;
/**
* The default built-in social drivers we support.
*
* @var string[]
*/
protected $validSocialDrivers = [
@ -39,7 +44,7 @@ class SocialAuthService
'okta',
'gitlab',
'twitch',
'discord'
'discord',
];
/**
@ -47,6 +52,7 @@ class SocialAuthService
* for an initial redirect action.
* Array is keyed by social driver name.
* Callbacks are passed an instance of the driver.
*
* @var array<string, callable>
*/
protected $configureForRedirectCallbacks = [];
@ -54,33 +60,39 @@ class SocialAuthService
/**
* SocialAuthService constructor.
*/
public function __construct(Socialite $socialite)
public function __construct(Socialite $socialite, LoginService $loginService)
{
$this->socialite = $socialite;
$this->loginService = $loginService;
}
/**
* Start the social login path.
*
* @throws SocialDriverNotConfigured
*/
public function startLogIn(string $socialDriver): RedirectResponse
{
$driver = $this->validateDriver($socialDriver);
return $this->getDriverForRedirect($driver)->redirect();
}
/**
* Start the social registration process
* Start the social registration process.
*
* @throws SocialDriverNotConfigured
*/
public function startRegister(string $socialDriver): RedirectResponse
{
$driver = $this->validateDriver($socialDriver);
return $this->getDriverForRedirect($driver)->redirect();
}
/**
* Handle the social registration process on callback.
*
* @throws UserRegistrationException
*/
public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser
@ -92,6 +104,7 @@ class SocialAuthService
if (User::query()->where('email', '=', $socialUser->getEmail())->exists()) {
$email = $socialUser->getEmail();
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login');
}
@ -100,16 +113,19 @@ class SocialAuthService
/**
* Get the social user details via the social driver.
*
* @throws SocialDriverNotConfigured
*/
public function getSocialUser(string $socialDriver): SocialUser
{
$driver = $this->validateDriver($socialDriver);
return $this->socialite->driver($driver)->user();
}
/**
* Handle the login process on a oAuth callback.
*
* @throws SocialSignInAccountNotUsed
*/
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
@ -125,9 +141,8 @@ class SocialAuthService
// When a user is not logged in and a matching SocialAccount exists,
// Simply log the user into the application.
if (!$isLoggedIn && $socialAccount !== null) {
auth()->login($socialAccount->user);
Activity::add(ActivityType::AUTH_LOGIN, $socialAccount);
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $socialDriver, $socialAccount->user);
$this->loginService->login($socialAccount->user, $socialAccount);
return redirect()->intended('/');
}
@ -137,18 +152,21 @@ class SocialAuthService
$account = $this->newSocialAccount($socialDriver, $socialUser);
$currentUser->socialAccounts()->save($account);
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl());
}
// When a user is logged in and the social account exists and is already linked to the current user.
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl());
}
// When a user is logged in, A social account exists but the users do not match.
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl());
}
@ -163,6 +181,7 @@ class SocialAuthService
/**
* Ensure the social driver is correct and supported.
*
* @throws SocialDriverNotConfigured
*/
protected function validateDriver(string $socialDriver): string
@ -188,6 +207,7 @@ class SocialAuthService
$lowerName = strtolower($driver);
$configPrefix = 'services.' . $lowerName . '.';
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
return !in_array(false, $config) && !in_array(null, $config);
}
@ -239,7 +259,7 @@ class SocialAuthService
return new SocialAccount([
'driver' => $socialDriver,
'driver_id' => $socialUser->getId(),
'avatar' => $socialUser->getAvatar()
'avatar' => $socialUser->getAvatar(),
]);
}
@ -252,7 +272,7 @@ class SocialAuthService
}
/**
* Provide redirect options per service for the Laravel Socialite driver
* Provide redirect options per service for the Laravel Socialite driver.
*/
protected function getDriverForRedirect(string $driverName): Provider
{

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Auth\Access;
<?php
namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Notifications\UserInvite;
@ -11,6 +13,7 @@ class UserInviteService extends UserTokenService
/**
* 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,57 +1,54 @@
<?php namespace BookStack\Auth\Access;
<?php
namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use Carbon\Carbon;
use Illuminate\Database\Connection as Database;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use stdClass;
class UserTokenService
{
/**
* Name of table where user tokens are stored.
*
* @var string
*/
protected $tokenTable = 'user_tokens';
/**
* Token expiry time in hours.
*
* @var int
*/
protected $expiryTime = 24;
protected $db;
/**
* UserTokenService constructor.
* @param Database $db
*/
public function __construct(Database $db)
{
$this->db = $db;
}
/**
* Delete all email confirmations that belong to a user.
*
* @param User $user
*
* @return mixed
*/
public function deleteByUser(User $user)
{
return $this->db->table($this->tokenTable)
return DB::table($this->tokenTable)
->where('user_id', '=', $user->id)
->delete();
}
/**
* Get the user id from a token, while check the token exists and has not expired.
*
* @param string $token
* @return int
*
* @throws UserTokenNotFoundException
* @throws UserTokenExpiredException
*
* @return int
*/
public function checkTokenAndGetUserId(string $token): int
{
@ -70,6 +67,7 @@ class UserTokenService
/**
* Creates a unique token within the email confirmation database.
*
* @return string
*/
protected function generateToken(): string
@ -78,52 +76,62 @@ class UserTokenService
while ($this->tokenExists($token)) {
$token = Str::random(25);
}
return $token;
}
/**
* Generate and store a token for the given user.
*
* @param User $user
*
* @return string
*/
protected function createTokenForUser(User $user): string
{
$token = $this->generateToken();
$this->db->table($this->tokenTable)->insert([
DB::table($this->tokenTable)->insert([
'user_id' => $user->id,
'token' => $token,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now()
'updated_at' => Carbon::now(),
]);
return $token;
}
/**
* Check if the given token exists.
*
* @param string $token
*
* @return bool
*/
protected function tokenExists(string $token): bool
{
return $this->db->table($this->tokenTable)
return DB::table($this->tokenTable)
->where('token', '=', $token)->exists();
}
/**
* Get a token entry for the given token.
*
* @param string $token
*
* @return object|null
*/
protected function getEntryByToken(string $token)
{
return $this->db->table($this->tokenTable)
return DB::table($this->tokenTable)
->where('token', '=', $token)
->first();
}
/**
* Check if the given token entry has expired.
*
* @param stdClass $tokenEntry
*
* @return bool
*/
protected function entryExpired(stdClass $tokenEntry): bool

View File

@ -1,15 +1,17 @@
<?php namespace BookStack\Auth\Permissions;
<?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()

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Auth\Permissions;
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Entity;

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Auth\Permissions;
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Auth\User;
@ -48,7 +50,7 @@ class PermissionService
}
/**
* Set the database connection
* Set the database connection.
*/
public function setConnection(Connection $connection)
{
@ -56,7 +58,8 @@ class PermissionService
}
/**
* Prepare the local entity cache and ensure it's empty
* Prepare the local entity cache and ensure it's empty.
*
* @param Entity[] $entities
*/
protected function readyEntityCache(array $entities = [])
@ -73,7 +76,7 @@ class PermissionService
}
/**
* Get a book via ID, Checks local cache
* Get a book via ID, Checks local cache.
*/
protected function getBook(int $bookId): ?Book
{
@ -85,7 +88,7 @@ class PermissionService
}
/**
* Get a chapter via ID, Checks local cache
* Get a chapter via ID, Checks local cache.
*/
protected function getChapter(int $chapterId): ?Chapter
{
@ -151,12 +154,13 @@ class PermissionService
},
'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)
@ -169,6 +173,7 @@ class PermissionService
/**
* Build joint permissions for the given book and role combinations.
*
* @throws Throwable
*/
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
@ -193,6 +198,7 @@ class PermissionService
/**
* Rebuild the entity jointPermissions for a particular entity.
*
* @throws Throwable
*/
public function buildJointPermissionsForEntity(Entity $entity)
@ -201,6 +207,7 @@ class PermissionService
if ($entity instanceof Book) {
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
$this->buildJointPermissionsForBooks($books, Role::query()->get()->all(), true);
return;
}
@ -224,6 +231,7 @@ class PermissionService
/**
* Rebuild the entity jointPermissions for a collection of entities.
*
* @throws Throwable
*/
public function buildJointPermissionsForEntities(array $entities)
@ -263,6 +271,7 @@ class PermissionService
/**
* Delete all of the entity jointPermissions for a list of entities.
*
* @param Role[] $roles
*/
protected function deleteManyJointPermissionsForRoles($roles)
@ -275,7 +284,9 @@ class PermissionService
/**
* Delete the entity jointPermissions for a particular entity.
*
* @param Entity $entity
*
* @throws Throwable
*/
public function deleteJointPermissionsForEntity(Entity $entity)
@ -285,7 +296,9 @@ class PermissionService
/**
* Delete all of the entity jointPermissions for a list of entities.
*
* @param Entity[] $entities
*
* @throws Throwable
*/
protected function deleteManyJointPermissionsForEntities(array $entities)
@ -295,7 +308,6 @@ class PermissionService
}
$this->db->transaction(function () use ($entities) {
foreach (array_chunk($entities, 1000) as $entityChunk) {
$query = $this->db->table('joint_permissions');
foreach ($entityChunk as $entity) {
@ -311,8 +323,10 @@ class PermissionService
/**
* Create & Save entity jointPermissions for many entities and roles.
*
* @param Entity[] $entities
* @param Role[] $roles
*
* @throws Throwable
*/
protected function createManyJointPermissions(array $entities, array $roles)
@ -363,7 +377,6 @@ class PermissionService
});
}
/**
* Get the actions related to an entity.
*/
@ -376,6 +389,7 @@ class PermissionService
if ($entity instanceof Book) {
$baseActions[] = 'chapter-create';
}
return $baseActions;
}
@ -397,6 +411,7 @@ class PermissionService
if ($entity->restricted) {
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction);
return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
}
@ -433,6 +448,7 @@ class PermissionService
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;
}
@ -455,6 +471,7 @@ class PermissionService
/**
* Checks if an entity has a restriction set upon it.
*
* @param HasCreatorAndUpdater|HasOwner $ownable
*/
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
@ -473,7 +490,8 @@ class PermissionService
$ownPermission = $user && $user->can($permission . '-own');
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
$isOwner = $user && $user->id === $ownable->$ownerField;
return ($allPermission || ($isOwner && $ownPermission));
return $allPermission || ($isOwner && $ownPermission);
}
// Handle abnormal create jointPermissions
@ -483,6 +501,7 @@ class PermissionService
$hasAccess = $this->entityRestrictionQuery($baseQuery, $action)->count() > 0;
$this->clean();
return $hasAccess;
}
@ -509,6 +528,7 @@ class PermissionService
$hasPermission = $permissionQuery->count() > 0;
$this->clean();
return $hasPermission;
}
@ -529,6 +549,7 @@ class PermissionService
});
$this->clean();
return $q;
}
@ -539,6 +560,7 @@ class PermissionService
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())
@ -580,6 +602,7 @@ class PermissionService
/**
* Filter items that have entities set as a polymorphic relation.
*
* @param Builder|\Illuminate\Database\Query\Builder $query
*/
public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
@ -600,6 +623,7 @@ class PermissionService
});
$this->clean();
return $q;
}
@ -628,12 +652,14 @@ class PermissionService
});
$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)
@ -645,7 +671,7 @@ class PermissionService
}
/**
* Get the current user
* Get the current user.
*/
private function currentUser(): User
{

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Auth\Permissions;
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Role;
@ -9,7 +11,6 @@ use Illuminate\Database\Eloquent\Collection;
class PermissionsRepo
{
protected $permission;
protected $role;
protected $permissionService;
@ -56,12 +57,14 @@ class PermissionsRepo
public function saveNewRole(array $roleData): Role
{
$role = $this->role->newInstance($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->save();
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions);
$this->permissionService->buildJointPermissionForRole($role);
Activity::add(ActivityType::ROLE_CREATE, $role);
return $role;
}
@ -88,6 +91,7 @@ class PermissionsRepo
$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);
@ -116,6 +120,7 @@ class PermissionsRepo
* Check it's not an admin role or set as default before deleting.
* If an migration Role ID is specified the users assign to the current role
* will be added to the role of the specified id.
*
* @throws PermissionsException
* @throws Exception
*/

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Auth\Permissions;
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Model;
@ -18,7 +20,9 @@ class RolePermission extends Model
/**
* Get the permission object by name.
*
* @param $name
*
* @return mixed
*/
public static function getByName($name)

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Auth;
<?php
namespace BookStack\Auth;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\RolePermission;
@ -9,16 +11,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Class Role
* Class Role.
*
* @property int $id
* @property string $display_name
* @property string $description
* @property string $external_auth_id
* @property string $system_name
* @property bool $mfa_enforced
*/
class Role extends Model implements Loggable
{
protected $fillable = ['display_name', 'description', 'external_auth_id'];
/**
@ -56,6 +59,7 @@ class Role extends Model implements Loggable
return true;
}
}
return false;
}

View File

@ -1,16 +1,18 @@
<?php namespace BookStack\Auth;
<?php
namespace BookStack\Auth;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
/**
* Class SocialAccount
* Class SocialAccount.
*
* @property string $driver
* @property User $user
*/
class SocialAccount extends Model implements Loggable
{
protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
public function user()

View File

@ -1,7 +1,10 @@
<?php namespace BookStack\Auth;
<?php
namespace BookStack\Auth;
use BookStack\Actions\Favourite;
use BookStack\Api\ApiToken;
use BookStack\Auth\Access\Mfa\MfaValue;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable;
@ -22,7 +25,8 @@ use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
/**
* Class User
* Class User.
*
* @property string $id
* @property string $name
* @property string $slug
@ -35,19 +39,24 @@ use Illuminate\Support\Collection;
* @property string $external_auth_id
* @property string $system_name
* @property Collection $roles
* @property Collection $mfaValues
*/
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
{
use Authenticatable, CanResetPassword, Notifiable;
use Authenticatable;
use CanResetPassword;
use Notifiable;
/**
* The database table used by the model.
*
* @var string
*/
protected $table = 'users';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = ['name', 'email'];
@ -56,6 +65,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* The attributes excluded from the model's JSON form.
*
* @var array
*/
protected $hidden = [
@ -65,12 +75,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* This holds the user's permissions when loaded.
*
* @var ?Collection
*/
protected $permissions;
/**
* This holds the default user when loaded.
*
* @var null|User
*/
protected static $defaultUser = null;
@ -85,6 +97,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
static::$defaultUser = static::query()->where('system_name', '=', 'public')->first();
return static::$defaultUser;
}
@ -98,6 +111,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* The roles that belong to the user.
*
* @return BelongsToMany
*/
public function roles()
@ -105,6 +119,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
if ($this->id === 0) {
return;
}
return $this->belongsToMany(Role::class);
}
@ -194,7 +209,9 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Check if the user has a social account,
* If a driver is passed it checks for that single account type.
*
* @param bool|string $socialDriver
*
* @return bool
*/
public function hasSocialAccount($socialDriver = false)
@ -207,7 +224,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
/**
* Returns a URL to the user's avatar
* Returns a URL to the user's avatar.
*/
public function getAvatar(int $size = 50): string
{
@ -222,6 +239,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
} catch (Exception $err) {
$avatar = $default;
}
return $avatar;
}
@ -249,6 +267,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $this->hasMany(Favourite::class);
}
/**
* Get the MFA values belonging to this use.
*/
public function mfaValues(): HasMany
{
return $this->hasMany(MfaValue::class);
}
/**
* Get the last activity time for this user.
*/
@ -268,6 +294,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
public function getEditUrl(string $path = ''): string
{
$uri = '/settings/users/' . $this->id . '/' . trim($path, '/');
return url(rtrim($uri, '/'));
}
@ -298,7 +325,9 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Send the password reset notification.
*
* @param string $token
*
* @return void
*/
public function sendPasswordResetNotification($token)
@ -320,6 +349,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
public function refreshSlug(): string
{
$this->slug = app(SlugGenerator::class)->generate($this);
return $this->slug;
}
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Auth;
<?php
namespace BookStack\Auth;
use Activity;
use BookStack\Entities\EntityProvider;
@ -69,6 +71,7 @@ class UserRepo
$query = User::query()->select(['*'])
->withLastActivityAt()
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $sortData['order']);
if ($sortData['search']) {
@ -96,6 +99,7 @@ class UserRepo
/**
* Assign a user to a system-level role.
*
* @throws NotFoundException
*/
public function attachSystemRole(User $user, string $systemRoleName)
@ -126,6 +130,7 @@ class UserRepo
/**
* Set the assigned user roles via an array of role IDs.
*
* @throws UserUpdateException
*/
public function setUserRoles(User $user, array $roles)
@ -176,6 +181,7 @@ class UserRepo
/**
* Remove the given user from storage, Delete all related content.
*
* @throws Exception
*/
public function destroy(User $user, ?int $newOwnerId = null)
@ -183,6 +189,7 @@ class UserRepo
$user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->delete();
// Delete user profile images
@ -201,7 +208,7 @@ class UserRepo
*/
protected function migrateOwnership(User $fromUser, User $toUser)
{
$entities = (new EntityProvider)->all();
$entities = (new EntityProvider())->all();
foreach ($entities as $instance) {
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $toUser->id]);
@ -242,6 +249,7 @@ class UserRepo
public function getAssetCounts(User $user): array
{
$createdBy = ['created_by' => $user->id];
return [
'pages' => Page::visible()->where($createdBy)->count(),
'chapters' => Chapter::visible()->where($createdBy)->count(),

View File

@ -18,6 +18,6 @@ return [
'max_item_count' => env('API_MAX_ITEM_COUNT', 500),
// The number of API requests that can be made per minute by a single user.
'requests_per_minute' => env('API_REQUESTS_PER_MIN', 180)
'requests_per_minute' => env('API_REQUESTS_PER_MIN', 180),
];

View File

@ -36,6 +36,11 @@ return [
// Even when overridden the WYSIWYG editor may still escape script content.
'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
// Allow server-side fetches to be performed to potentially unknown
// and user-provided locations. Primarily used in exports when loading
// in externally referenced assets.
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
// Override the default behaviour for allowing crawlers to crawl the instance.
// May be ignored if view has be overridden or modified.
// Defaults to null since, if not set, 'app-public' status used instead.
@ -56,7 +61,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'],
// Application Fallback Locale
'fallback_locale' => 'en',

View File

@ -46,7 +46,6 @@ return [
'driver' => 'null',
],
],
];

View File

@ -1,7 +1,7 @@
<?php
/**
* Debugbar Configuration Options
* Debugbar Configuration Options.
*
* Changes to these config files are not supported by BookStack and may break upon updates.
* Configuration should be altered via the `.env` file or environment variables.
@ -16,10 +16,9 @@ return [
// You can provide an array of URI's that must be ignored (eg. 'api/*')
'enabled' => env('DEBUGBAR_ENABLED', false),
'except' => [
'telescope*'
'telescope*',
],
// DebugBar stores data for session/ajax requests.
// You can disable this, so the debugbar stores data in headers/session,
// but this can cause problems with large data collectors.
@ -30,7 +29,7 @@ return [
'driver' => 'file', // redis, file, pdo, custom
'path' => storage_path('debugbar'), // For file driver
'connection' => null, // Leave null for default connection (Redis/PDO)
'provider' => '' // Instance of StorageInterface for custom driver
'provider' => '', // Instance of StorageInterface for custom driver
],
// Vendor files are included by default, but can be set to false.
@ -98,19 +97,19 @@ return [
'hints' => true, // Show hints for common mistakes
],
'mail' => [
'full_log' => false
'full_log' => false,
],
'views' => [
'data' => false, //Note: Can slow down the application, because the data can be quite large..
],
'route' => [
'label' => true // show complete route on bar
'label' => true, // show complete route on bar
],
'logs' => [
'file' => null
'file' => null,
],
'cache' => [
'values' => true // collect cache values
'values' => true, // collect cache values
],
],

View File

@ -10,12 +10,11 @@
return [
'show_warnings' => false, // Throw an Exception on warnings from dompdf
'orientation' => 'portrait',
'defines' => [
/**
* The location of the DOMPDF font directory
* The location of the DOMPDF font directory.
*
* The location of the directory where DOMPDF will store fonts and font metrics
* Note: This directory must exist and be writable by the webserver process.
@ -38,17 +37,17 @@ return [
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
* Symbol, ZapfDingbats.
*/
"DOMPDF_FONT_DIR" => storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
'font_dir' => storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
/**
* The location of the DOMPDF font cache directory
* The location of the DOMPDF font cache directory.
*
* This directory contains the cached font metrics for the fonts used by DOMPDF.
* This directory can be the same as DOMPDF_FONT_DIR
*
* Note: This directory must exist and be writable by the webserver process.
*/
"DOMPDF_FONT_CACHE" => storage_path('fonts/'),
'font_cache' => storage_path('fonts/'),
/**
* The location of a temporary directory.
@ -57,10 +56,10 @@ return [
* The temporary directory is required to download remote images and when
* using the PFDLib back end.
*/
"DOMPDF_TEMP_DIR" => sys_get_temp_dir(),
'temp_dir' => sys_get_temp_dir(),
/**
* ==== IMPORTANT ====
* ==== IMPORTANT ====.
*
* dompdf's "chroot": Prevents dompdf from accessing system files or other
* files on the webserver. All local files opened by dompdf must be in a
@ -71,7 +70,7 @@ return [
* direct class use like:
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
*/
"DOMPDF_CHROOT" => realpath(base_path()),
'chroot' => realpath(base_path()),
/**
* Whether to use Unicode fonts or not.
@ -82,20 +81,19 @@ return [
* When enabled, dompdf can support all Unicode glyphs. Any glyphs used in a
* document must be present in your fonts, however.
*/
"DOMPDF_UNICODE_ENABLED" => true,
'unicode_enabled' => true,
/**
* Whether to enable font subsetting or not.
*/
"DOMPDF_ENABLE_FONTSUBSETTING" => false,
'enable_fontsubsetting' => false,
/**
* The PDF rendering backend to use
* The PDF rendering backend to use.
*
* Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and
* 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will
* fall back on CPDF. 'GD' renders PDFs to graphic files. {@link
* Canvas_Factory} ultimately determines which rendering class to instantiate
* fall back on CPDF. 'GD' renders PDFs to graphic files. {@link * Canvas_Factory} ultimately determines which rendering class to instantiate
* based on this setting.
*
* Both PDFLib & CPDF rendering backends provide sufficient rendering
@ -117,10 +115,10 @@ return [
* @link http://www.ros.co.nz/pdf
* @link http://www.php.net/image
*/
"DOMPDF_PDF_BACKEND" => "CPDF",
'pdf_backend' => 'CPDF',
/**
* PDFlib license key
* PDFlib license key.
*
* If you are using a licensed, commercial version of PDFlib, specify
* your license key here. If you are using PDFlib-Lite or are evaluating
@ -143,7 +141,7 @@ return [
* the desired content might be different (e.g. screen or projection view of html file).
* Therefore allow specification of content here.
*/
"DOMPDF_DEFAULT_MEDIA_TYPE" => "print",
'default_media_type' => 'print',
/**
* The default paper size.
@ -152,18 +150,19 @@ return [
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
"DOMPDF_DEFAULT_PAPER_SIZE" => "a4",
'default_paper_size' => 'a4',
/**
* The default font family
* The default font family.
*
* Used if no suitable fonts can be found. This must exist in the font folder.
*
* @var string
*/
"DOMPDF_DEFAULT_FONT" => "dejavu sans",
'default_font' => 'dejavu sans',
/**
* Image DPI setting
* Image DPI setting.
*
* This setting determines the default DPI setting for images and fonts. The
* DPI may be overridden for inline images by explictly setting the
@ -195,10 +194,10 @@ return [
*
* @var int
*/
"DOMPDF_DPI" => 96,
'dpi' => 96,
/**
* Enable inline PHP
* Enable inline PHP.
*
* If this setting is set to true then DOMPDF will automatically evaluate
* inline PHP contained within <script type="text/php"> ... </script> tags.
@ -209,20 +208,20 @@ return [
*
* @var bool
*/
"DOMPDF_ENABLE_PHP" => false,
'enable_php' => false,
/**
* Enable inline Javascript
* Enable inline Javascript.
*
* If this setting is set to true then DOMPDF will automatically insert
* JavaScript code contained within <script type="text/javascript"> ... </script> tags.
*
* @var bool
*/
"DOMPDF_ENABLE_JAVASCRIPT" => false,
'enable_javascript' => false,
/**
* Enable remote file access
* Enable remote file access.
*
* If this setting is set to true, DOMPDF will access remote sites for
* images and CSS files as required.
@ -238,29 +237,27 @@ return [
*
* @var bool
*/
"DOMPDF_ENABLE_REMOTE" => true,
'enable_remote' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
/**
* A ratio applied to the fonts height to be more like browsers' line height
* A ratio applied to the fonts height to be more like browsers' line height.
*/
"DOMPDF_FONT_HEIGHT_RATIO" => 1.1,
'font_height_ratio' => 1.1,
/**
* Enable CSS float
* Enable CSS float.
*
* Allows people to disabled CSS float support
*
* @var bool
*/
"DOMPDF_ENABLE_CSS_FLOAT" => true,
'enable_css_float' => true,
/**
* Use the more-than-experimental HTML5 Lib parser
* Use the more-than-experimental HTML5 Lib parser.
*/
"DOMPDF_ENABLE_HTML5PARSER" => true,
'enable_html5parser' => true,
],
];

View File

@ -84,7 +84,7 @@ return [
'handler_with' => [4],
'formatter' => LineFormatter::class,
'formatter_with' => [
'format' => "%message%",
'format' => '%message%',
],
],
@ -101,7 +101,6 @@ return [
],
],
// Failed Login Message
// Allows a configurable message to be logged when a login request fails.
'failed_login' => [

View File

@ -23,7 +23,7 @@ return [
// Global "From" address & name
'from' => [
'address' => env('MAIL_FROM', 'mail@bookstackapp.com'),
'name' => env('MAIL_FROM_NAME', 'BookStack')
'name' => env('MAIL_FROM_NAME', 'BookStack'),
],
// Email encryption protocol

View File

@ -17,7 +17,6 @@ return [
// Queue connection configuration
'connections' => [
'sync' => [
'driver' => 'sync',
],

View File

@ -31,7 +31,6 @@ return [
// Overrides, in JSON format, to the configuration passed to underlying onelogin library.
'onelogin_overrides' => env('SAML2_ONELOGIN_OVERRIDES', null),
'onelogin' => [
// If 'strict' is True, then the PHP Toolkit will reject unsigned
// or unencrypted messages if it expects them signed or encrypted

View File

@ -1,6 +1,6 @@
<?php
use \Illuminate\Support\Str;
use Illuminate\Support\Str;
/**
* Session configuration options.

View File

@ -14,7 +14,7 @@ return [
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
'timeout' => false,
'options' => [
'outline' => true
'outline' => true,
],
'env' => [],
],

View File

@ -25,11 +25,11 @@ class CleanupImages 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)
@ -63,6 +63,7 @@ class CleanupImages extends Command
$this->comment($deleteCount . ' images found that would have been deleted');
$this->showDeletedImages($deleted);
$this->comment('Run with -f or --force to perform deletions');
return;
}

View File

@ -23,7 +23,6 @@ class ClearViews extends Command
/**
* Create a new command instance.
*
*/
public function __construct()
{

View File

@ -54,6 +54,7 @@ class CopyShelfPermissions extends Command
if (!$cascadeAll && !$shelfSlug) {
$this->error('Either a --slug or --all option must be provided.');
return;
}

View File

@ -38,8 +38,9 @@ class CreateAdmin extends Command
/**
* Execute the console command.
*
* @return mixed
* @throws \BookStack\Exceptions\NotFoundException
*
* @return mixed
*/
public function handle()
{
@ -71,7 +72,6 @@ class CreateAdmin extends Command
return $this->error('Invalid password provided, Must be at least 5 characters');
}
$user = $this->userRepo->create(['email' => $email, 'name' => $name, 'password' => $password]);
$this->userRepo->attachSystemRole($user, 'admin');
$this->userRepo->downloadAndAssignUserAvatar($user);

View File

@ -8,7 +8,6 @@ use Illuminate\Console\Command;
class DeleteUsers extends Command
{
/**
* The name and signature of the console command.
*
@ -47,7 +46,7 @@ class DeleteUsers extends Command
continue;
}
$this->userRepo->destroy($user);
++$numDeleted;
$numDeleted++;
}
$this->info("Deleted $numDeleted of $totalUsers total users.");
} else {

View File

@ -0,0 +1,77 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Auth\User;
use Illuminate\Console\Command;
class ResetMfa extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:reset-mfa
{--id= : Numeric ID of the user to reset MFA for}
{--email= : Email address of the user to reset MFA for}
';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Reset & Clear any configured MFA methods for the given user';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$id = $this->option('id');
$email = $this->option('email');
if (!$id && !$email) {
$this->error('Either a --id=<number> or --email=<email> option must be provided.');
return 1;
}
/** @var User $user */
$field = $id ? 'id' : 'email';
$value = $id ?: $email;
$user = User::query()
->where($field, '=', $value)
->first();
if (!$user) {
$this->error("A user where {$field}={$value} could not be found.");
return 1;
}
$this->info("This will delete any configure multi-factor authentication methods for user: \n- ID: {$user->id}\n- Name: {$user->name}\n- Email: {$user->email}\n");
$this->info('If multi-factor authentication is required for this user they will be asked to reconfigure their methods on next login.');
$confirm = $this->confirm('Are you sure you want to proceed?');
if ($confirm) {
$user->mfaValues()->delete();
$this->info('User MFA methods have been reset.');
return 0;
}
return 1;
}
}

View File

@ -4,7 +4,6 @@ namespace BookStack\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
class UpdateUrl extends Command
{
@ -49,7 +48,8 @@ class UpdateUrl extends Command
$urlPattern = '/https?:\/\/(.+)/';
if (!preg_match($urlPattern, $oldUrl) || !preg_match($urlPattern, $newUrl)) {
$this->error("The given urls are expected to be full urls starting with http:// or https://");
$this->error('The given urls are expected to be full urls starting with http:// or https://');
return 1;
}
@ -58,11 +58,11 @@ class UpdateUrl extends Command
}
$columnsToUpdateByTable = [
"attachments" => ["path"],
"pages" => ["html", "text", "markdown"],
"images" => ["url"],
"settings" => ["value"],
"comments" => ["html", "text"],
'attachments' => ['path'],
'pages' => ['html', 'text', 'markdown'],
'images' => ['url'],
'settings' => ['value'],
'comments' => ['html', 'text'],
];
foreach ($columnsToUpdateByTable as $table => $columns) {
@ -73,7 +73,7 @@ class UpdateUrl extends Command
}
$jsonColumnsToUpdateByTable = [
"settings" => ["value"],
'settings' => ['value'],
];
foreach ($jsonColumnsToUpdateByTable as $table => $columns) {
@ -85,10 +85,11 @@ class UpdateUrl extends Command
}
}
$this->info("URL update procedure complete.");
$this->info('URL update procedure complete.');
$this->info('============================================================================');
$this->info('Be sure to run "php artisan cache:clear" to clear any old URLs in the cache.');
$this->info('============================================================================');
return 0;
}
@ -100,8 +101,9 @@ class UpdateUrl extends Command
{
$oldQuoted = $this->db->getPdo()->quote($oldUrl);
$newQuoted = $this->db->getPdo()->quote($newUrl);
return $this->db->table($table)->update([
$column => $this->db->raw("REPLACE({$column}, {$oldQuoted}, {$newQuoted})")
$column => $this->db->raw("REPLACE({$column}, {$oldQuoted}, {$newQuoted})"),
]);
}
@ -112,8 +114,8 @@ class UpdateUrl extends Command
protected function checkUserOkayToProceed(string $oldUrl, string $newUrl): bool
{
$dangerWarning = "This will search for \"{$oldUrl}\" in your database and replace it with \"{$newUrl}\".\n";
$dangerWarning .= "Are you sure you want to proceed?";
$backupConfirmation = "This operation could cause issues if used incorrectly. Have you made a backup of your existing database?";
$dangerWarning .= 'Are you sure you want to proceed?';
$backupConfirmation = 'This operation could cause issues if used incorrectly. Have you made a backup of your existing database?';
return $this->confirm($dangerWarning) && $this->confirm($backupConfirmation);
}

View File

@ -23,7 +23,6 @@ class UpgradeDatabaseEncoding extends Command
/**
* Create a new command instance.
*
*/
public function __construct()
{

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Console;
<?php
namespace BookStack\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@ -18,6 +20,7 @@ class Kernel extends ConsoleKernel
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
*
* @return void
*/
protected function schedule(Schedule $schedule)

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities;
<?php
namespace BookStack\Entities;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Tools\ShelfContext;
@ -6,11 +8,11 @@ use Illuminate\View\View;
class BreadcrumbsViewComposer
{
protected $entityContextManager;
/**
* BreadcrumbsViewComposer constructor.
*
* @param ShelfContext $entityContextManager
*/
public function __construct(ShelfContext $entityContextManager)
@ -20,6 +22,7 @@ class BreadcrumbsViewComposer
/**
* Modify data when the view is composed.
*
* @param View $view
*/
public function compose(View $view)

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities;
<?php
namespace BookStack\Entities;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
@ -8,7 +10,7 @@ use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
/**
* Class EntityProvider
* Class EntityProvider.
*
* Provides access to the core entity models.
* Wrapped up in this provider since they are often used together
@ -16,7 +18,6 @@ use BookStack\Entities\Models\PageRevision;
*/
class EntityProvider
{
/**
* @var Bookshelf
*/
@ -42,7 +43,6 @@ class EntityProvider
*/
public $pageRevision;
public function __construct()
{
$this->bookshelf = new Bookshelf();
@ -55,6 +55,7 @@ class EntityProvider
/**
* Fetch all core entity types as an associated array
* with their basic names as the keys.
*
* @return array<Entity>
*/
public function all(): array
@ -73,6 +74,7 @@ class EntityProvider
public function get(string $type): Entity
{
$type = strtolower($type);
return $this->all()[$type];
}
@ -86,6 +88,7 @@ class EntityProvider
$model = $this->get($type);
$morphClasses[] = $model->getMorphClass();
}
return $morphClasses;
}
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Models;
<?php
namespace BookStack\Entities\Models;
use BookStack\Uploads\Image;
use Exception;
@ -8,7 +10,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
/**
* Class Book
* Class Book.
*
* @property string $description
* @property int $image_id
* @property Image|null $cover
@ -30,8 +33,10 @@ class Book extends Entity implements HasCoverImage
/**
* Returns book cover image, if book cover not exists return default cover image.
*
* @param int $width - Width of the image
* @param int $height - Height of the image
*
* @return string
*/
public function getBookCover($width = 440, $height = 250)
@ -46,11 +51,12 @@ class Book extends Entity implements HasCoverImage
} catch (Exception $err) {
$cover = $default;
}
return $cover;
}
/**
* Get the cover image of the book
* Get the cover image of the book.
*/
public function cover(): BelongsTo
{
@ -67,6 +73,7 @@ class Book extends Entity implements HasCoverImage
/**
* Get all pages within this book.
*
* @return HasMany
*/
public function pages()
@ -76,6 +83,7 @@ class Book extends Entity implements HasCoverImage
/**
* Get the direct child pages of this book.
*
* @return HasMany
*/
public function directPages()
@ -85,6 +93,7 @@ class Book extends Entity implements HasCoverImage
/**
* Get all chapters within this book.
*
* @return HasMany
*/
public function chapters()
@ -94,6 +103,7 @@ class Book extends Entity implements HasCoverImage
/**
* Get the shelves this book is contained within.
*
* @return BelongsToMany
*/
public function shelves()
@ -103,12 +113,14 @@ class Book extends Entity implements HasCoverImage
/**
* Get the direct child items within this book.
*
* @return Collection
*/
public function getDirectChildren(): Collection
{
$pages = $this->directPages()->visible()->get();
$chapters = $this->chapters()->visible()->get();
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}
}

View File

@ -1,20 +1,38 @@
<?php namespace BookStack\Entities\Models;
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Class BookChild
* Class BookChild.
*
* @property int $book_id
* @property int $priority
* @property string $book_slug
* @property Book $book
*
* @method Builder whereSlugs(string $bookSlug, string $childSlug)
*/
abstract class BookChild extends Entity
{
protected static function boot()
{
parent::boot();
// Load book slugs onto these models by default during query-time
static::addGlobalScope('book_slug', function (Builder $builder) {
$builder->addSelect(['book_slug' => function ($builder) {
$builder->select('slug')
->from('books')
->whereColumn('books.id', '=', 'book_id');
}]);
});
}
/**
* Scope a query to find items where the the child has the given childSlug
* Scope a query to find items where the child has the given childSlug
* where its parent has the bookSlug.
*/
public function scopeWhereSlugs(Builder $query, string $bookSlug, string $childSlug)

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Models;
<?php
namespace BookStack\Entities\Models;
use BookStack\Uploads\Image;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -17,6 +19,7 @@ class Bookshelf extends Entity implements HasCoverImage
/**
* Get the books in this shelf.
* Should not be used directly since does not take into account permissions.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function books()
@ -44,8 +47,10 @@ class Bookshelf extends Entity implements HasCoverImage
/**
* Returns BookShelf cover image, if cover does not exists return default cover image.
*
* @param int $width - Width of the image
* @param int $height - Height of the image
*
* @return string
*/
public function getBookCover($width = 440, $height = 250)
@ -61,11 +66,12 @@ class Bookshelf extends Entity implements HasCoverImage
} catch (\Exception $err) {
$cover = $default;
}
return $cover;
}
/**
* Get the cover image of the shelf
* Get the cover image of the shelf.
*/
public function cover(): BelongsTo
{
@ -82,7 +88,9 @@ class Bookshelf extends Entity implements HasCoverImage
/**
* Check if this shelf contains the given book.
*
* @param Book $book
*
* @return bool
*/
public function contains(Book $book): bool
@ -92,6 +100,7 @@ class Bookshelf extends Entity implements HasCoverImage
/**
* Add a book to the end of this shelf.
*
* @param Book $book
*/
public function appendBook(Book $book)

View File

@ -1,10 +1,14 @@
<?php namespace BookStack\Entities\Models;
<?php
namespace BookStack\Entities\Models;
use Illuminate\Support\Collection;
/**
* Class Chapter
* Class Chapter.
*
* @property Collection<Page> $pages
* @property mixed description
*/
class Chapter extends BookChild
{
@ -15,7 +19,9 @@ class Chapter extends BookChild
/**
* Get the pages that this chapter contains.
*
* @param string $dir
*
* @return mixed
*/
public function pages($dir = 'ASC')
@ -30,7 +36,7 @@ class Chapter extends BookChild
{
$parts = [
'books',
urlencode($this->getAttribute('bookSlug') ?? $this->book->slug),
urlencode($this->book_slug ?? $this->book->slug),
'chapter',
urlencode($this->slug),
trim($path, '/'),

View File

@ -1,15 +1,18 @@
<?php namespace BookStack\Entities\Models;
<?php
namespace BookStack\Entities\Models;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property Model deletable
*/
class Deletion extends Model implements Loggable
{
/**
* Get the related deletable record.
*/
@ -37,12 +40,22 @@ class Deletion extends Model implements Loggable
'deletable_id' => $entity->id,
]);
$record->save();
return $record;
}
public function logDescriptor(): string
{
$deletable = $this->deletable()->first();
return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}";
}
/**
* Get a URL for this specific deletion.
*/
public function getUrl($path): string
{
return url("/settings/recycle-bin/{$this->id}/" . ltrim($path, '/'));
}
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Models;
<?php
namespace BookStack\Entities\Models;
use BookStack\Actions\Activity;
use BookStack\Actions\Comment;
@ -34,8 +36,9 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property Carbon $updated_at
* @property int $created_by
* @property int $updated_by
* @property boolean $restricted
* @property bool $restricted
* @property Collection $tags
*
* @method static Entity|Builder visible()
* @method static Entity|Builder hasPermission(string $permission)
* @method static Builder withLastView()
@ -154,11 +157,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
}
/**
* Get the comments for an entity
* Get the comments for an entity.
*/
public function comments(bool $orderByCreated = true): MorphMany
{
$query = $this->morphMany(Comment::class, 'entity');
return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
}
@ -205,7 +209,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/**
* Check if this instance or class is a certain type of entity.
* Examples of $type are 'page', 'book', 'chapter'
* Examples of $type are 'page', 'book', 'chapter'.
*/
public static function isA(string $type): bool
{
@ -218,6 +222,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
public static function getType(): string
{
$className = array_slice(explode('\\', static::class), -1, 1)[0];
return strtolower($className);
}
@ -229,6 +234,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
if (mb_strlen($this->name) <= $length) {
return $this->name;
}
return mb_substr($this->name, 0, $length - 3) . '...';
}
@ -255,7 +261,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
}
/**
* Get the url of this entity
* Get the url of this entity.
*/
abstract public function getUrl(string $path = '/'): string;
@ -266,12 +272,13 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function getParent(): ?Entity
{
if ($this->isA('page')) {
if ($this instanceof Page) {
return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first();
}
if ($this->isA('chapter')) {
if ($this instanceof Chapter) {
return $this->book()->withTrashed()->first();
}
return null;
}
@ -285,7 +292,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
}
/**
* Index the current entity for search
* Index the current entity for search.
*/
public function indexForSearch()
{
@ -298,6 +305,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
public function refreshSlug(): string
{
$this->slug = app(SlugGenerator::class)->generate($this);
return $this->slug;
}

View File

@ -1,13 +1,11 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
interface HasCoverImage
{
/**
* Get the cover image for this item.
*/

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Models;
<?php
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\PageContent;
use BookStack\Uploads\Attachment;
@ -9,7 +11,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Permissions;
/**
* Class Page
* Class Page.
*
* @property int $chapter_id
* @property string $html
* @property string $markdown
@ -22,9 +25,10 @@ use Permissions;
*/
class Page extends BookChild
{
protected $fillable = ['name', 'priority', 'markdown'];
public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at'];
public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at'];
protected $simpleAttributes = ['name', 'id', 'slug'];
protected $fillable = ['name', 'priority', 'markdown'];
public $textField = 'text';
@ -41,22 +45,13 @@ class Page extends BookChild
public function scopeVisible(Builder $query): Builder
{
$query = Permissions::enforceDraftVisibilityOnQuery($query);
return parent::scopeVisible($query);
}
/**
* Converts this page into a simplified array.
* @return mixed
*/
public function toSimpleArray()
{
$array = array_intersect_key($this->toArray(), array_flip($this->simpleAttributes));
$array['url'] = $this->getUrl();
return $array;
}
/**
* Get the chapter that this page is in, If applicable.
*
* @return BelongsTo
*/
public function chapter()
@ -66,6 +61,7 @@ class Page extends BookChild
/**
* Check if this page has a chapter.
*
* @return bool
*/
public function hasChapter()
@ -96,6 +92,7 @@ class Page extends BookChild
/**
* Get the attachments assigned to this page.
*
* @return HasMany
*/
public function attachments()
@ -110,7 +107,7 @@ class Page extends BookChild
{
$parts = [
'books',
urlencode($this->getAttribute('bookSlug') ?? $this->book->slug),
urlencode($this->book_slug ?? $this->book->slug),
$this->draft ? 'draft' : 'page',
$this->draft ? $this->id : urlencode($this->slug),
trim($path, '/'),
@ -120,7 +117,8 @@ class Page extends BookChild
}
/**
* Get the current revision for the page if existing
* Get the current revision for the page if existing.
*
* @return PageRevision|null
*/
public function getCurrentRevision()
@ -136,6 +134,7 @@ class Page extends BookChild
$refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
$refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));
$refreshed->html = (new PageContent($refreshed))->render();
return $refreshed;
}
}

View File

@ -1,12 +1,14 @@
<?php namespace BookStack\Entities\Models;
<?php
namespace BookStack\Entities\Models;
use BookStack\Auth\User;
use BookStack\Entities\Models\Page;
use BookStack\Model;
use Carbon\Carbon;
/**
* Class PageRevision
* Class PageRevision.
*
* @property int $page_id
* @property string $slug
* @property string $book_slug
@ -23,7 +25,8 @@ class PageRevision extends Model
protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
/**
* Get the user that created the page revision
* Get the user that created the page revision.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function createdBy()
@ -33,6 +36,7 @@ class PageRevision extends Model
/**
* Get the page this revision originates from.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function page()
@ -42,7 +46,9 @@ class PageRevision extends Model
/**
* Get the url for this revision.
*
* @param null|string $path
*
* @return string
*/
public function getUrl($path = null)
@ -51,11 +57,13 @@ class PageRevision extends Model
if ($path) {
return $url . '/' . trim($path, '/');
}
return $url;
}
/**
* Get the previous revision for the same page if existing
* Get the previous revision for the same page if existing.
*
* @return \BookStack\Entities\PageRevision|null
*/
public function getPrevious()
@ -74,8 +82,10 @@ class PageRevision extends Model
/**
* Allows checking of the exact class, Used to check entity type.
* Included here to align with entities in similar use cases.
* (Yup, Bit of an awkward hack)
* (Yup, Bit of an awkward hack).
*
* @param $type
*
* @return bool
*/
public static function isA($type)

View File

@ -1,15 +1,17 @@
<?php namespace BookStack\Entities\Models;
<?php
namespace BookStack\Entities\Models;
use BookStack\Model;
class SearchTerm extends Model
{
protected $fillable = ['term', 'entity_id', 'entity_type', 'score'];
public $timestamps = false;
/**
* Get the entity that this term belongs to
* Get the entity that this term belongs to.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function entity()

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Queries;
<?php
namespace BookStack\Entities\Queries;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\EntityProvider;

View File

@ -1,5 +1,6 @@
<?php namespace BookStack\Entities\Queries;
<?php
namespace BookStack\Entities\Queries;
use BookStack\Actions\View;
use Illuminate\Support\Facades\DB;
@ -25,5 +26,4 @@ class Popular extends EntityQuery
->pluck('viewable')
->filter();
}
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Queries;
<?php
namespace BookStack\Entities\Queries;
use BookStack\Actions\View;
use Illuminate\Support\Collection;

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Queries;
<?php
namespace BookStack\Entities\Queries;
use BookStack\Actions\Favourite;
use Illuminate\Database\Query\JoinClause;

View File

@ -2,24 +2,18 @@
namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Actions\TagRepo;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Activity;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BaseRepo
{
protected $tagRepo;
protected $imageRepo;
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
{
$this->tagRepo = $tagRepo;
@ -27,7 +21,7 @@ class BaseRepo
}
/**
* Create a new entity in the system
* Create a new entity in the system.
*/
public function create(Entity $entity, array $input)
{
@ -72,6 +66,7 @@ class BaseRepo
/**
* Update the given items' cover image, or clear it.
*
* @throws ImageUploadException
* @throws \Exception
*/

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Repos;
<?php
namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Actions\TagRepo;
@ -15,7 +17,6 @@ use Illuminate\Support\Collection;
class BookRepo
{
protected $baseRepo;
protected $tagRepo;
protected $imageRepo;
@ -84,13 +85,14 @@ class BookRepo
}
/**
* Create a new book in the system
* Create a new book in the system.
*/
public function create(array $input): Book
{
$book = new Book();
$this->baseRepo->create($book, $input);
Activity::addForEntity($book, ActivityType::BOOK_CREATE);
return $book;
}
@ -101,11 +103,13 @@ class BookRepo
{
$this->baseRepo->update($book, $input);
Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
return $book;
}
/**
* Update the given book's cover image, or clear it.
*
* @throws ImageUploadException
* @throws Exception
*/
@ -116,6 +120,7 @@ class BookRepo
/**
* Remove a book from the system.
*
* @throws Exception
*/
public function destroy(Book $book)

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Repos;
<?php
namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
@ -89,6 +91,7 @@ class BookshelfRepo
$this->baseRepo->create($shelf, $input);
$this->updateBooks($shelf, $bookIds);
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
return $shelf;
}
@ -104,6 +107,7 @@ class BookshelfRepo
}
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
return $shelf;
}
@ -129,6 +133,7 @@ class BookshelfRepo
/**
* Update the given shelf cover image, or clear it.
*
* @throws ImageUploadException
* @throws Exception
*/
@ -164,6 +169,7 @@ class BookshelfRepo
/**
* Remove a bookshelf from the system.
*
* @throws Exception
*/
public function destroy(Bookshelf $shelf)

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Repos;
<?php
namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
@ -9,11 +11,9 @@ use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use Exception;
use Illuminate\Support\Collection;
class ChapterRepo
{
protected $baseRepo;
/**
@ -26,6 +26,7 @@ class ChapterRepo
/**
* Get a chapter via the slug.
*
* @throws NotFoundException
*/
public function getBySlug(string $bookSlug, string $chapterSlug): Chapter
@ -49,6 +50,7 @@ class ChapterRepo
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
$this->baseRepo->create($chapter, $input);
Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE);
return $chapter;
}
@ -59,11 +61,13 @@ class ChapterRepo
{
$this->baseRepo->update($chapter, $input);
Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
return $chapter;
}
/**
* Remove a chapter from the system.
*
* @throws Exception
*/
public function destroy(Chapter $chapter)
@ -77,7 +81,8 @@ class ChapterRepo
/**
* Move the given chapter into a new parent book.
* The $parentIdentifier must be a string of the following format:
* 'book:<id>' (book:5)
* 'book:<id>' (book:5).
*
* @throws MoveOperationException
*/
public function move(Chapter $chapter, string $parentIdentifier): Book

View File

@ -1,14 +1,16 @@
<?php namespace BookStack\Entities\Repos;
<?php
namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
@ -16,11 +18,9 @@ use BookStack\Facades\Activity;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class PageRepo
{
protected $baseRepo;
/**
@ -33,6 +33,7 @@ class PageRepo
/**
* Get a page by ID.
*
* @throws NotFoundException
*/
public function getById(int $id, array $relations = ['book']): Page
@ -48,6 +49,7 @@ class PageRepo
/**
* Get a page its book and own slug.
*
* @throws NotFoundException
*/
public function getBySlug(string $bookSlug, string $pageSlug): Page
@ -77,6 +79,7 @@ class PageRepo
->orderBy('created_at', 'desc')
->with('page')
->first();
return $revision ? $revision->page : null;
}
@ -119,6 +122,7 @@ class PageRepo
public function getUserDraft(Page $page): ?PageRevision
{
$revision = $this->getUserDraftQuery($page)->first();
return $revision;
}
@ -144,6 +148,7 @@ class PageRepo
$page->save();
$page->refresh()->rebuildPermissions();
return $page;
}
@ -166,6 +171,7 @@ class PageRepo
$draft->refresh();
Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
return $draft;
}
@ -190,7 +196,7 @@ class PageRepo
$this->getUserDraftQuery($page)->delete();
// Save a revision after updating
$summary = trim($input['summary'] ?? "");
$summary = trim($input['summary'] ?? '');
$htmlChanged = isset($input['html']) && $input['html'] !== $oldHtml;
$nameChanged = isset($input['name']) && $input['name'] !== $oldName;
$markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown;
@ -199,6 +205,7 @@ class PageRepo
}
Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
return $page;
}
@ -211,8 +218,8 @@ class PageRepo
$pageContent = new PageContent($page);
if (!empty($input['markdown'] ?? '')) {
$pageContent->setNewMarkdown($input['markdown']);
} else {
$pageContent->setNewHTML($input['html'] ?? '');
} elseif (isset($input['html'])) {
$pageContent->setNewHTML($input['html']);
}
}
@ -234,6 +241,7 @@ class PageRepo
$revision->save();
$this->deleteOldRevisions($page);
return $revision;
}
@ -249,6 +257,7 @@ class PageRepo
}
$page->fill($input);
$page->save();
return $page;
}
@ -260,11 +269,13 @@ class PageRepo
}
$draft->save();
return $draft;
}
/**
* Destroy a page from the system.
*
* @throws Exception
*/
public function destroy(Page $page)
@ -301,13 +312,15 @@ class PageRepo
$this->savePageRevision($page, $summary);
Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
return $page;
}
/**
* Move the given page into a new parent book or chapter.
* The $parentIdentifier must be a string of the following format:
* 'book:<id>' (book:5)
* 'book:<id>' (book:5).
*
* @throws MoveOperationException
* @throws PermissionsException
*/
@ -327,12 +340,14 @@ class PageRepo
$page->rebuildPermissions();
Activity::addForEntity($page, ActivityType::PAGE_MOVE);
return $parent;
}
/**
* Copy an existing page in the system.
* Optionally providing a new parent via string identifier and a new name.
*
* @throws MoveOperationException
* @throws PermissionsException
*/
@ -369,7 +384,8 @@ class PageRepo
/**
* Find a page parent entity via a identifier string in the format:
* {type}:{id}
* Example: (book:5)
* Example: (book:5).
*
* @throws MoveOperationException
*/
protected function findParentByIdentifier(string $identifier): ?Entity
@ -383,6 +399,7 @@ class PageRepo
}
$parentClass = $entityType === 'book' ? Book::class : Chapter::class;
return $parentClass::visible()->where('id', '=', $entityId)->first();
}
@ -420,6 +437,7 @@ class PageRepo
$draft->book_slug = $page->book->slug;
$draft->created_by = user()->id;
$draft->type = 'update_draft';
return $draft;
}
@ -445,13 +463,14 @@ class PageRepo
}
/**
* Get a new priority for a page
* Get a new priority for a page.
*/
protected function getNewPriority(Page $page): int
{
$parent = $page->getParent();
if ($parent instanceof Chapter) {
$lastPage = $parent->pages('desc')->first();
return $lastPage ? $lastPage->priority + 1 : 0;
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Tools;
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
@ -10,7 +12,6 @@ use Illuminate\Support\Collection;
class BookContents
{
/**
* @var Book
*/
@ -35,6 +36,7 @@ class BookContents
->where('chapter_id', '=', 0)->max('priority');
$maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id)
->max('priority');
return max($maxChapter, $maxPage, 1);
}
@ -43,7 +45,7 @@ class BookContents
*/
public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
{
$pages = $this->getPages($showDrafts);
$pages = $this->getPages($showDrafts, $renderPages);
$chapters = Chapter::visible()->where('book_id', '=', $this->book->id)->get();
$all = collect()->concat($pages)->concat($chapters);
$chapterMap = $chapters->keyBy('id');
@ -83,6 +85,7 @@ class BookContents
if (isset($entity['draft']) && $entity['draft']) {
return -100;
}
return $entity['priority'] ?? 0;
};
}
@ -90,9 +93,11 @@ class BookContents
/**
* Get the visible pages within this book.
*/
protected function getPages(bool $showDrafts = false): Collection
protected function getPages(bool $showDrafts = false, bool $getPageContent = false): Collection
{
$query = Page::visible()->where('book_id', '=', $this->book->id);
$query = Page::visible()
->select($getPageContent ? Page::$contentAttributes : Page::$listAttributes)
->where('book_id', '=', $this->book->id);
if (!$showDrafts) {
$query->where('draft', '=', false);
@ -110,9 +115,10 @@ class BookContents
* +"parentChapter": false (ID of parent chapter, as string, or false)
* +"type": "page" (Entity type of item)
* +"book": "1" (Id of book to place item in)
* }
* }.
*
* Returns a list of books that were involved in the operation.
*
* @throws SortOperationException
*/
public function sortUsingMap(Collection $sortMap): Collection
@ -190,6 +196,7 @@ class BookContents
/**
* Get the books involved in a sort.
* The given sort map should have its models loaded first.
*
* @throws SortOperationException
*/
protected function getBooksInvolvedInSort(Collection $sortMap): Collection
@ -202,7 +209,7 @@ class BookContents
$books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get();
if (count($books) !== count($bookIdsInvolved)) {
throw new SortOperationException("Could not find all books requested in sort operation");
throw new SortOperationException('Could not find all books requested in sort operation');
}
return $books;

View File

@ -1,8 +1,11 @@
<?php namespace BookStack\Entities\Tools;
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Uploads\ImageService;
use DomPDF;
use Exception;
@ -11,7 +14,6 @@ use Throwable;
class ExportFormatter
{
protected $imageService;
/**
@ -25,6 +27,7 @@ class ExportFormatter
/**
* Convert a page to a self-contained HTML file.
* Includes required CSS & image content. Images are base64 encoded into the HTML.
*
* @throws Throwable
*/
public function pageToContainedHtml(Page $page)
@ -34,11 +37,13 @@ class ExportFormatter
'page' => $page,
'format' => 'html',
])->render();
return $this->containHtml($pageHtml);
}
/**
* Convert a chapter to a self-contained HTML file.
*
* @throws Throwable
*/
public function chapterToContainedHtml(Chapter $chapter)
@ -52,11 +57,13 @@ class ExportFormatter
'pages' => $pages,
'format' => 'html',
])->render();
return $this->containHtml($html);
}
/**
* Convert a book to a self-contained HTML file.
*
* @throws Throwable
*/
public function bookToContainedHtml(Book $book)
@ -67,11 +74,13 @@ class ExportFormatter
'bookChildren' => $bookTree,
'format' => 'html',
])->render();
return $this->containHtml($html);
}
/**
* Convert a page to a PDF file.
*
* @throws Throwable
*/
public function pageToPdf(Page $page)
@ -81,11 +90,13 @@ class ExportFormatter
'page' => $page,
'format' => 'pdf',
])->render();
return $this->htmlToPdf($html);
}
/**
* Convert a chapter to a PDF file.
*
* @throws Throwable
*/
public function chapterToPdf(Chapter $chapter)
@ -106,6 +117,7 @@ class ExportFormatter
/**
* Convert a book to a PDF file.
*
* @throws Throwable
*/
public function bookToPdf(Book $book)
@ -116,28 +128,32 @@ class ExportFormatter
'bookChildren' => $bookTree,
'format' => 'pdf',
])->render();
return $this->htmlToPdf($html);
}
/**
* Convert normal web-page HTML to a PDF.
*
* @throws Exception
*/
protected function htmlToPdf(string $html): string
{
$containedHtml = $this->containHtml($html);
$useWKHTML = config('snappy.pdf.binary') !== false;
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
if ($useWKHTML) {
$pdf = SnappyPDF::loadHTML($containedHtml);
$pdf->setOption('print-media-type', true);
} else {
$pdf = DomPDF::loadHTML($containedHtml);
}
return $pdf->output();
}
/**
* Bundle of the contents of a html file to be self-contained.
*
* @throws Exception
*/
protected function containHtml(string $htmlContent): string
@ -194,6 +210,7 @@ class ExportFormatter
$text = html_entity_decode($text);
// Add title
$text = $page->name . "\n\n" . $text;
return $text;
}
@ -207,6 +224,7 @@ class ExportFormatter
foreach ($chapter->getVisiblePages() as $page) {
$text .= $this->pageToPlainText($page);
}
return $text;
}
@ -224,6 +242,51 @@ class ExportFormatter
$text .= $this->pageToPlainText($bookChild);
}
}
return $text;
}
/**
* Convert a page to a Markdown file.
*/
public function pageToMarkdown(Page $page): string
{
if ($page->markdown) {
return '# ' . $page->name . "\n\n" . $page->markdown;
}
return '# ' . $page->name . "\n\n" . (new HtmlToMarkdown($page->html))->convert();
}
/**
* Convert a chapter to a Markdown file.
*/
public function chapterToMarkdown(Chapter $chapter): string
{
$text = '# ' . $chapter->name . "\n\n";
$text .= $chapter->description . "\n\n";
foreach ($chapter->pages as $page) {
$text .= $this->pageToMarkdown($page) . "\n\n";
}
return $text;
}
/**
* Convert a book into a plain text string.
*/
public function bookToMarkdown(Book $book): string
{
$bookTree = (new BookContents($book))->getTree(false, true);
$text = '# ' . $book->name . "\n\n";
foreach ($bookTree as $bookChild) {
if ($bookChild instanceof Chapter) {
$text .= $this->chapterToMarkdown($bookChild);
} else {
$text .= $this->pageToMarkdown($bookChild);
}
}
return $text;
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\Block\Element\Paragraph;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\Block\Renderer\ListItemRenderer;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
use League\CommonMark\HtmlElement;
class CustomListItemRenderer implements BlockRendererInterface
{
protected $baseRenderer;
public function __construct()
{
$this->baseRenderer = new ListItemRenderer();
}
/**
* @return HtmlElement|string|null
*/
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
{
$listItem = $this->baseRenderer->render($block, $htmlRenderer, $inTightList);
if ($this->startsTaskListItem($block)) {
$listItem->setAttribute('class', 'task-list-item');
}
return $listItem;
}
private function startsTaskListItem(ListItem $block): bool
{
$firstChild = $block->firstChild();
return $firstChild instanceof Paragraph && $firstChild->firstChild() instanceof TaskListItemMarker;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\ParagraphConverter;
use League\HTMLToMarkdown\ElementInterface;
class CustomParagraphConverter extends ParagraphConverter
{
public function convert(ElementInterface $element): string
{
$class = $element->getAttribute('class');
if (strpos($class, 'callout') !== false) {
return "<{$element->getTagName()} class=\"{$class}\">{$element->getValue()}</{$element->getTagName()}>\n\n";
}
return parent::convert($element);
}
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Tools\Markdown;
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Extension\ExtensionInterface;
@ -7,7 +9,6 @@ use League\CommonMark\Extension\Strikethrough\StrikethroughDelimiterProcessor;
class CustomStrikeThroughExtension implements ExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
{
$environment->addDelimiterProcessor(new StrikethroughDelimiterProcessor());

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Tools\Markdown;
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Extension\Strikethrough\Strikethrough;

View File

@ -0,0 +1,93 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\BlockquoteConverter;
use League\HTMLToMarkdown\Converter\CodeConverter;
use League\HTMLToMarkdown\Converter\CommentConverter;
use League\HTMLToMarkdown\Converter\DivConverter;
use League\HTMLToMarkdown\Converter\EmphasisConverter;
use League\HTMLToMarkdown\Converter\HardBreakConverter;
use League\HTMLToMarkdown\Converter\HeaderConverter;
use League\HTMLToMarkdown\Converter\HorizontalRuleConverter;
use League\HTMLToMarkdown\Converter\ImageConverter;
use League\HTMLToMarkdown\Converter\LinkConverter;
use League\HTMLToMarkdown\Converter\ListBlockConverter;
use League\HTMLToMarkdown\Converter\ListItemConverter;
use League\HTMLToMarkdown\Converter\PreformattedConverter;
use League\HTMLToMarkdown\Converter\TextConverter;
use League\HTMLToMarkdown\Environment;
use League\HTMLToMarkdown\HtmlConverter;
class HtmlToMarkdown
{
protected $html;
public function __construct(string $html)
{
$this->html = $html;
}
/**
* Run the conversion.
*/
public function convert(): string
{
$converter = new HtmlConverter($this->getConverterEnvironment());
$html = $this->prepareHtml($this->html);
return $converter->convert($html);
}
/**
* Run any pre-processing to the HTML to clean it up manually before conversion.
*/
protected function prepareHtml(string $html): string
{
// Carriage returns can cause whitespace issues in output
$html = str_replace("\r\n", "\n", $html);
// Attributes on the pre tag can cause issues with conversion
return preg_replace('/<pre .*?>/', '<pre>', $html);
}
/**
* Get the HTML to Markdown customized environment.
* Extends the default provided environment with some BookStack specific tweaks.
*/
protected function getConverterEnvironment(): Environment
{
$environment = new Environment([
'header_style' => 'atx', // Set to 'atx' to output H1 and H2 headers as # Header1 and ## Header2
'suppress_errors' => true, // Set to false to show warnings when loading malformed HTML
'strip_tags' => false, // Set to true to strip tags that don't have markdown equivalents. N.B. Strips tags, not their content. Useful to clean MS Word HTML output.
'strip_placeholder_links' => false, // Set to true to remove <a> that doesn't have href.
'bold_style' => '**', // DEPRECATED: Set to '__' if you prefer the underlined style
'italic_style' => '*', // DEPRECATED: Set to '_' if you prefer the underlined style
'remove_nodes' => '', // space-separated list of dom nodes that should be removed. example: 'meta style script'
'hard_break' => false, // Set to true to turn <br> into `\n` instead of ` \n`
'list_item_style' => '-', // Set the default character for each <li> in a <ul>. Can be '-', '*', or '+'
'preserve_comments' => false, // Set to true to preserve comments, or set to an array of strings to preserve specific comments
'use_autolinks' => false, // Set to true to use simple link syntax if possible. Will always use []() if set to false
'table_pipe_escape' => '\|', // Replacement string for pipe characters inside markdown table cells
'table_caption_side' => 'top', // Set to 'top' or 'bottom' to show <caption> content before or after table, null to suppress
]);
$environment->addConverter(new BlockquoteConverter());
$environment->addConverter(new CodeConverter());
$environment->addConverter(new CommentConverter());
$environment->addConverter(new DivConverter());
$environment->addConverter(new EmphasisConverter());
$environment->addConverter(new HardBreakConverter());
$environment->addConverter(new HeaderConverter());
$environment->addConverter(new HorizontalRuleConverter());
$environment->addConverter(new ImageConverter());
$environment->addConverter(new LinkConverter());
$environment->addConverter(new ListBlockConverter());
$environment->addConverter(new ListItemConverter());
$environment->addConverter(new CustomParagraphConverter());
$environment->addConverter(new PreformattedConverter());
$environment->addConverter(new TextConverter());
return $environment;
}
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Tools;
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
@ -48,6 +50,7 @@ class NextPreviousContentLocator
return get_class($entity) === get_class($this->relativeBookItem)
&& $entity->id === $this->relativeBookItem->id;
});
return $index === false ? null : $index;
}
@ -64,6 +67,7 @@ class NextPreviousContentLocator
$childPages = $item->visible_pages ?? [];
$flatOrdered = $flatOrdered->concat($childPages);
}
return $flatOrdered;
}
}

View File

@ -1,16 +1,20 @@
<?php namespace BookStack\Entities\Tools;
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\CustomListItemRenderer;
use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Util\HtmlContentFilter;
use BookStack\Uploads\ImageRepo;
use BookStack\Util\HtmlContentFilter;
use DOMDocument;
use DOMNodeList;
use DOMXPath;
use Illuminate\Support\Str;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment;
use League\CommonMark\Extension\Table\TableExtension;
@ -18,7 +22,6 @@ use League\CommonMark\Extension\TaskList\TaskListExtension;
class PageContent
{
protected $page;
/**
@ -62,11 +65,14 @@ class PageContent
$environment->addExtension(new CustomStrikeThroughExtension());
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
$converter = new CommonMarkConverter([], $environment);
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
return $converter->convertToHtml($markdown);
}
/**
* Convert all base64 image data to saved images
* Convert all base64 image data to saved images.
*/
public function extractBase64Images(Page $page, string $htmlText): string
{
@ -97,6 +103,7 @@ class PageContent
// Save image from data with a random name
$imageName = 'embedded-image-' . Str::random(8) . '.' . $extension;
try {
$image = $imageRepo->saveNewFromData($imageName, base64_decode($base64ImageData), 'gallery', $page->id);
$imageNode->setAttribute('src', $image->url);
@ -171,7 +178,7 @@ class PageContent
/**
* Set a unique id on the given DOMElement.
* A map for existing ID's should be passed in to check for current existence.
* Returns a pair of strings in the format [old_id, new_id]
* Returns a pair of strings in the format [old_id, new_id].
*/
protected function setUniqueId(\DOMNode $element, array &$idMap): array
{
@ -183,6 +190,7 @@ class PageContent
$existingId = $element->getAttribute('id');
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
$idMap[$existingId] = true;
return [$existingId, $existingId];
}
@ -200,6 +208,7 @@ class PageContent
$element->setAttribute('id', $newId);
$idMap[$newId] = true;
return [$existingId, $newId];
}
@ -209,15 +218,16 @@ class PageContent
protected function toPlainText(): string
{
$html = $this->render(true);
return html_entity_decode(strip_tags($html));
}
/**
* Render the page for viewing
* Render the page for viewing.
*/
public function render(bool $blankIncludes = false): string
{
$content = $this->page->html;
$content = $this->page->html ?? '';
if (!config('app.allow_content_scripts')) {
$content = HtmlContentFilter::removeScripts($content);
@ -233,7 +243,7 @@ class PageContent
}
/**
* Parse the headers on the page to get a navigation menu
* Parse the headers on the page to get a navigation menu.
*/
public function getNavigation(string $htmlContent): array
{
@ -243,7 +253,7 @@ class PageContent
$doc = $this->loadDocumentFromHtml($htmlContent);
$xPath = new DOMXPath($doc);
$headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
$headers = $xPath->query('//h1|//h2|//h3|//h4|//h5|//h6');
return $headers ? $this->headerNodesToLevelList($headers) : [];
}
@ -272,6 +282,7 @@ class PageContent
$levelChange = ($tree->pluck('level')->min() - 1);
$tree = $tree->map(function ($header) use ($levelChange) {
$header['level'] -= ($levelChange);
return $header;
});
@ -325,7 +336,6 @@ class PageContent
return $html;
}
/**
* Fetch the content from a specific section of the given page.
*/
@ -365,6 +375,7 @@ class PageContent
$doc = new DOMDocument();
$html = '<body>' . $html . '</body>';
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
return $doc;
}
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Tools;
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
@ -7,7 +9,6 @@ use Illuminate\Database\Eloquent\Builder;
class PageEditActivity
{
protected $page;
/**
@ -20,6 +21,7 @@ class PageEditActivity
/**
* Check if there's active editing being performed on this page.
*
* @return bool
*/
public function hasActiveEditing(): bool
@ -37,12 +39,15 @@ class PageEditActivity
$userMessage = $count > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $count]) : trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
}
/**
* Get the message to show when the user will be editing one of their drafts.
*
* @param PageRevision $draft
*
* @return string
*/
public function getEditingActiveDraftMessage(PageRevision $draft): string
@ -51,6 +56,7 @@ class PageEditActivity
if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
return $message;
}
return $message . "\n" . trans('entities.pages_draft_edited_notification');
}

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Tools;
<?php
namespace BookStack\Entities\Tools;
use BookStack\Actions\ActivityType;
use BookStack\Auth\User;
@ -9,7 +11,6 @@ use Illuminate\Support\Collection;
class PermissionsUpdater
{
/**
* Update an entities permissions from a permission form submit request.
*/

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Tools;
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
@ -17,14 +19,12 @@ class SearchIndex
*/
protected $entityProvider;
public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider)
{
$this->searchTerm = $searchTerm;
$this->entityProvider = $entityProvider;
}
/**
* Index the given entity.
*/
@ -42,7 +42,8 @@ class SearchIndex
}
/**
* Index multiple Entities at once
* Index multiple Entities at once.
*
* @param Entity[] $entities
*/
protected function indexEntities(array $entities)
@ -111,7 +112,7 @@ class SearchIndex
foreach ($tokenMap as $token => $count) {
$terms[] = [
'term' => $token,
'score' => $count * $scoreAdjustment
'score' => $count * $scoreAdjustment,
];
}

View File

@ -1,10 +1,11 @@
<?php namespace BookStack\Entities\Tools;
<?php
namespace BookStack\Entities\Tools;
use Illuminate\Http\Request;
class SearchOptions
{
/**
* @var array
*/
@ -35,6 +36,7 @@ class SearchOptions
foreach ($decoded as $type => $value) {
$instance->$type = $value;
}
return $instance;
}
@ -67,6 +69,7 @@ class SearchOptions
if (isset($inputs['types']) && count($inputs['types']) < 4) {
$instance->filters['type'] = implode('|', $inputs['types']);
}
return $instance;
}
@ -79,13 +82,13 @@ class SearchOptions
'searches' => [],
'exacts' => [],
'tags' => [],
'filters' => []
'filters' => [],
];
$patterns = [
'exacts' => '/"(.*?)"/',
'tags' => '/\[(.*?)\]/',
'filters' => '/\{(.*?)\}/'
'filters' => '/\{(.*?)\}/',
];
// Parse special terms

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Tools;
<?php
namespace BookStack\Entities\Tools;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
@ -13,7 +15,6 @@ use Illuminate\Support\Str;
class SearchRunner
{
/**
* @var EntityProvider
*/
@ -29,14 +30,13 @@ class SearchRunner
*/
protected $permissionService;
/**
* Acceptable operators to be used in a query
* Acceptable operators to be used in a query.
*
* @var array
*/
protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
public function __construct(EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
{
$this->entityProvider = $entityProvider;
@ -85,9 +85,8 @@ class SearchRunner
];
}
/**
* Search a book for entities
* Search a book for entities.
*/
public function searchBook(int $bookId, string $searchString): Collection
{
@ -108,12 +107,13 @@ class SearchRunner
}
/**
* Search a chapter for entities
* Search a chapter for entities.
*/
public function searchChapter(int $chapterId, string $searchString): Collection
{
$opts = SearchOptions::fromString($searchString);
$pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
return $pages->sortByDesc('score');
}
@ -121,6 +121,7 @@ class SearchRunner
* Search across a particular entity type.
* Setting getCount = true will return the total
* matching instead of the items themselves.
*
* @return \Illuminate\Database\Eloquent\Collection|int|static[]
*/
protected function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
@ -131,11 +132,12 @@ class SearchRunner
}
$query = $query->skip(($page - 1) * $count)->take($count);
return $query->get();
}
/**
* Create a search query for an entity
* Create a search query for an entity.
*/
protected function buildEntitySearchQuery(SearchOptions $searchOpts, string $entityType = 'page', string $action = 'view'): EloquentBuilder
{
@ -191,6 +193,7 @@ class SearchRunner
foreach ($this->queryOperators as $operator) {
$escapedOperators[] = preg_quote($operator);
}
return join('|', $escapedOperators);
}
@ -199,7 +202,7 @@ class SearchRunner
*/
protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
{
preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
preg_match('/^(.*?)((' . $this->getRegexEscapedOperators() . ')(.*?))?$/', $tagTerm, $tagSplit);
$query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
$tagName = $tagSplit[1];
$tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : '';
@ -222,13 +225,13 @@ class SearchRunner
$query->where('name', '=', $tagName);
}
});
return $query;
}
/**
* Custom entity search filters
* Custom entity search filters.
*/
protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input)
{
try {
@ -338,11 +341,9 @@ class SearchRunner
}
}
/**
* Sorting filter options
* Sorting filter options.
*/
protected function sortByLastCommented(EloquentBuilder $query, Entity $model)
{
$commentsTable = $this->db->getTablePrefix() . 'comments';

View File

@ -1,4 +1,6 @@
<?php namespace BookStack\Entities\Tools;
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;

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