Merge branch 'development' into release
This commit is contained in:
commit
64b41dd626
|
@ -280,3 +280,11 @@ DerLinkman (derlinkman) :: German; German Informal
|
||||||
TurnArabic :: Arabic
|
TurnArabic :: Arabic
|
||||||
Martin Sebek (sebekmartin) :: Czech
|
Martin Sebek (sebekmartin) :: Czech
|
||||||
Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
|
Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
|
||||||
|
digilady :: Greek
|
||||||
|
Linus (LinusOP) :: Swedish
|
||||||
|
Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian
|
||||||
|
RandomUser0815 :: German
|
||||||
|
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
|
||||||
|
구인회 (laskdjlaskdj12) :: Korean
|
||||||
|
LiZerui (CNLiZerui) :: Chinese Traditional
|
||||||
|
Fabrice Boyer (FabriceBoyer) :: French
|
||||||
|
|
3
LICENSE
3
LICENSE
|
@ -1,7 +1,6 @@
|
||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2015-present, Dan Brown and the BookStack Project contributors
|
Copyright (c) 2015-2022, Dan Brown and the BookStack Project contributors.
|
||||||
https://github.com/BookStackApp/BookStack/graphs/contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
@ -57,21 +57,21 @@ class TagRepo
|
||||||
* Get tag name suggestions from scanning existing tag names.
|
* Get tag name suggestions from scanning existing tag names.
|
||||||
* If no search term is given the 50 most popular tag names are provided.
|
* If no search term is given the 50 most popular tag names are provided.
|
||||||
*/
|
*/
|
||||||
public function getNameSuggestions(?string $searchTerm): Collection
|
public function getNameSuggestions(string $searchTerm): Collection
|
||||||
{
|
{
|
||||||
$query = Tag::query()
|
$query = Tag::query()
|
||||||
->select('*', DB::raw('count(*) as count'))
|
->select('*', DB::raw('count(*) as count'))
|
||||||
->groupBy('name');
|
->groupBy('name');
|
||||||
|
|
||||||
if ($searchTerm) {
|
if ($searchTerm) {
|
||||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'asc');
|
||||||
} else {
|
} else {
|
||||||
$query = $query->orderBy('count', 'desc')->take(50);
|
$query = $query->orderBy('count', 'desc')->take(50);
|
||||||
}
|
}
|
||||||
|
|
||||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||||
|
|
||||||
return $query->get(['name'])->pluck('name');
|
return $query->pluck('name');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -79,7 +79,7 @@ class TagRepo
|
||||||
* If no search is given the 50 most popular values are provided.
|
* If no search is given the 50 most popular values are provided.
|
||||||
* Passing a tagName will only find values for a tags with a particular name.
|
* Passing a tagName will only find values for a tags with a particular name.
|
||||||
*/
|
*/
|
||||||
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
|
public function getValueSuggestions(string $searchTerm, string $tagName): Collection
|
||||||
{
|
{
|
||||||
$query = Tag::query()
|
$query = Tag::query()
|
||||||
->select('*', DB::raw('count(*) as count'))
|
->select('*', DB::raw('count(*) as count'))
|
||||||
|
@ -97,7 +97,7 @@ class TagRepo
|
||||||
|
|
||||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||||
|
|
||||||
return $query->get(['value'])->pluck('value');
|
return $query->pluck('value');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,107 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Api;
|
||||||
|
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
|
|
||||||
|
class ApiEntityListFormatter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The list to be formatted.
|
||||||
|
* @var Entity[]
|
||||||
|
*/
|
||||||
|
protected $list = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fields to show in the formatted data.
|
||||||
|
* Can be a plain string array item for a direct model field (If existing on model).
|
||||||
|
* If the key is a string, with a callable value, the return value of the callable
|
||||||
|
* will be used for the resultant value. A null return value will omit the property.
|
||||||
|
* @var array<string|int, string|callable>
|
||||||
|
*/
|
||||||
|
protected $fields = [
|
||||||
|
'id', 'name', 'slug', 'book_id', 'chapter_id',
|
||||||
|
'draft', 'template', 'created_at', 'updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(array $list)
|
||||||
|
{
|
||||||
|
$this->list = $list;
|
||||||
|
|
||||||
|
// Default dynamic fields
|
||||||
|
$this->withField('url', fn(Entity $entity) => $entity->getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a field to be used in the formatter, with the property using the given
|
||||||
|
* name and value being the return type of the given callback.
|
||||||
|
*/
|
||||||
|
public function withField(string $property, callable $callback): self
|
||||||
|
{
|
||||||
|
$this->fields[$property] = $callback;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the 'type' property in the response reflecting the entity type.
|
||||||
|
* EG: page, chapter, bookshelf, book
|
||||||
|
* To be included in results with non-pre-determined types.
|
||||||
|
*/
|
||||||
|
public function withType(): self
|
||||||
|
{
|
||||||
|
$this->withField('type', fn(Entity $entity) => $entity->getType());
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include tags in the formatted data.
|
||||||
|
*/
|
||||||
|
public function withTags(): self
|
||||||
|
{
|
||||||
|
$this->withField('tags', fn(Entity $entity) => $entity->tags);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the data and return an array of formatted content.
|
||||||
|
* @return array[]
|
||||||
|
*/
|
||||||
|
public function format(): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($this->list as $item) {
|
||||||
|
$results[] = $this->formatSingle($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a single entity item to a plain array.
|
||||||
|
*/
|
||||||
|
protected function formatSingle(Entity $entity): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
$values = (clone $entity)->toArray();
|
||||||
|
|
||||||
|
foreach ($this->fields as $field => $callback) {
|
||||||
|
if (is_string($callback)) {
|
||||||
|
$field = $callback;
|
||||||
|
if (!isset($values[$field])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = $values[$field];
|
||||||
|
} else {
|
||||||
|
$value = $callback($entity);
|
||||||
|
if (is_null($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[$field] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace BookStack\Api;
|
namespace BookStack\Api;
|
||||||
|
|
||||||
use BookStack\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
|
@ -5,6 +5,7 @@ namespace BookStack\Auth\Access;
|
||||||
use BookStack\Actions\ActivityType;
|
use BookStack\Actions\ActivityType;
|
||||||
use BookStack\Auth\Access\Mfa\MfaSession;
|
use BookStack\Auth\Access\Mfa\MfaSession;
|
||||||
use BookStack\Auth\User;
|
use BookStack\Auth\User;
|
||||||
|
use BookStack\Exceptions\LoginAttemptException;
|
||||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||||
use BookStack\Facades\Activity;
|
use BookStack\Facades\Activity;
|
||||||
use BookStack\Facades\Theme;
|
use BookStack\Facades\Theme;
|
||||||
|
@ -149,6 +150,7 @@ class LoginService
|
||||||
* May interrupt the flow if extra authentication requirements are imposed.
|
* May interrupt the flow if extra authentication requirements are imposed.
|
||||||
*
|
*
|
||||||
* @throws StoppedAuthenticationException
|
* @throws StoppedAuthenticationException
|
||||||
|
* @throws LoginAttemptException
|
||||||
*/
|
*/
|
||||||
public function attempt(array $credentials, string $method, bool $remember = false): bool
|
public function attempt(array $credentials, string $method, bool $remember = false): bool
|
||||||
{
|
{
|
||||||
|
|
|
@ -20,14 +20,11 @@ use OneLogin\Saml2\ValidationError;
|
||||||
*/
|
*/
|
||||||
class Saml2Service
|
class Saml2Service
|
||||||
{
|
{
|
||||||
protected $config;
|
protected array $config;
|
||||||
protected $registrationService;
|
protected RegistrationService $registrationService;
|
||||||
protected $loginService;
|
protected LoginService $loginService;
|
||||||
protected $groupSyncService;
|
protected GroupSyncService $groupSyncService;
|
||||||
|
|
||||||
/**
|
|
||||||
* Saml2Service constructor.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
RegistrationService $registrationService,
|
RegistrationService $registrationService,
|
||||||
LoginService $loginService,
|
LoginService $loginService,
|
||||||
|
@ -169,7 +166,7 @@ class Saml2Service
|
||||||
*/
|
*/
|
||||||
public function metadata(): string
|
public function metadata(): string
|
||||||
{
|
{
|
||||||
$toolKit = $this->getToolkit();
|
$toolKit = $this->getToolkit(true);
|
||||||
$settings = $toolKit->getSettings();
|
$settings = $toolKit->getSettings();
|
||||||
$metadata = $settings->getSPMetadata();
|
$metadata = $settings->getSPMetadata();
|
||||||
$errors = $settings->validateMetadata($metadata);
|
$errors = $settings->validateMetadata($metadata);
|
||||||
|
@ -190,7 +187,7 @@ class Saml2Service
|
||||||
* @throws Error
|
* @throws Error
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
protected function getToolkit(): Auth
|
protected function getToolkit(bool $spOnly = false): Auth
|
||||||
{
|
{
|
||||||
$settings = $this->config['onelogin'];
|
$settings = $this->config['onelogin'];
|
||||||
$overrides = $this->config['onelogin_overrides'] ?? [];
|
$overrides = $this->config['onelogin_overrides'] ?? [];
|
||||||
|
@ -200,14 +197,14 @@ class Saml2Service
|
||||||
}
|
}
|
||||||
|
|
||||||
$metaDataSettings = [];
|
$metaDataSettings = [];
|
||||||
if ($this->config['autoload_from_metadata']) {
|
if (!$spOnly && $this->config['autoload_from_metadata']) {
|
||||||
$metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
|
$metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$spSettings = $this->loadOneloginServiceProviderDetails();
|
$spSettings = $this->loadOneloginServiceProviderDetails();
|
||||||
$settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
|
$settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
|
||||||
|
|
||||||
return new Auth($settings);
|
return new Auth($settings, $spOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -2,20 +2,41 @@
|
||||||
|
|
||||||
namespace BookStack\Auth\Permissions;
|
namespace BookStack\Auth\Permissions;
|
||||||
|
|
||||||
|
use BookStack\Auth\Role;
|
||||||
use BookStack\Model;
|
use BookStack\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int $role_id
|
||||||
|
* @property int $entity_id
|
||||||
|
* @property string $entity_type
|
||||||
|
* @property boolean $view
|
||||||
|
* @property boolean $create
|
||||||
|
* @property boolean $update
|
||||||
|
* @property boolean $delete
|
||||||
|
*/
|
||||||
class EntityPermission extends Model
|
class EntityPermission extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['role_id', 'action'];
|
public const PERMISSIONS = ['view', 'create', 'update', 'delete'];
|
||||||
|
|
||||||
|
protected $fillable = ['role_id', 'view', 'create', 'update', 'delete'];
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all this restriction's attached entity.
|
* Get this restriction's attached entity.
|
||||||
*
|
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
|
||||||
*/
|
*/
|
||||||
public function restrictable()
|
public function restrictable(): MorphTo
|
||||||
{
|
{
|
||||||
return $this->morphTo('restrictable');
|
return $this->morphTo('restrictable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the role assigned to this entity permission.
|
||||||
|
*/
|
||||||
|
public function role(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Role::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,7 +40,7 @@ class JointPermissionBuilder
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chunk through all bookshelves
|
// Chunk through all bookshelves
|
||||||
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
|
Bookshelf::query()->withTrashed()->select(['id', 'owned_by'])
|
||||||
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
|
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
|
||||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||||
});
|
});
|
||||||
|
@ -92,7 +92,7 @@ class JointPermissionBuilder
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chunk through all bookshelves
|
// Chunk through all bookshelves
|
||||||
Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
|
Bookshelf::query()->select(['id', 'owned_by'])
|
||||||
->chunk(50, function ($shelves) use ($roles) {
|
->chunk(50, function ($shelves) use ($roles) {
|
||||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||||
});
|
});
|
||||||
|
@ -138,12 +138,11 @@ class JointPermissionBuilder
|
||||||
protected function bookFetchQuery(): Builder
|
protected function bookFetchQuery(): Builder
|
||||||
{
|
{
|
||||||
return Book::query()->withTrashed()
|
return Book::query()->withTrashed()
|
||||||
->select(['id', 'restricted', 'owned_by'])->with([
|
->select(['id', 'owned_by'])->with([
|
||||||
'chapters' => function ($query) {
|
'chapters' => function ($query) {
|
||||||
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
|
|
||||||
},
|
},
|
||||||
'pages' => function ($query) {
|
'pages' => function ($query) {
|
||||||
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
|
$query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
@ -218,7 +217,6 @@ class JointPermissionBuilder
|
||||||
$simple = new SimpleEntityData();
|
$simple = new SimpleEntityData();
|
||||||
$simple->id = $attrs['id'];
|
$simple->id = $attrs['id'];
|
||||||
$simple->type = $entity->getMorphClass();
|
$simple->type = $entity->getMorphClass();
|
||||||
$simple->restricted = boolval($attrs['restricted'] ?? 0);
|
|
||||||
$simple->owned_by = $attrs['owned_by'] ?? 0;
|
$simple->owned_by = $attrs['owned_by'] ?? 0;
|
||||||
$simple->book_id = $attrs['book_id'] ?? null;
|
$simple->book_id = $attrs['book_id'] ?? null;
|
||||||
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
||||||
|
@ -240,21 +238,14 @@ class JointPermissionBuilder
|
||||||
$this->readyEntityCache($entities);
|
$this->readyEntityCache($entities);
|
||||||
$jointPermissions = [];
|
$jointPermissions = [];
|
||||||
|
|
||||||
// Create a mapping of entity restricted statuses
|
|
||||||
$entityRestrictedMap = [];
|
|
||||||
foreach ($entities as $entity) {
|
|
||||||
$entityRestrictedMap[$entity->type . ':' . $entity->id] = $entity->restricted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch related entity permissions
|
// Fetch related entity permissions
|
||||||
$permissions = $this->getEntityPermissionsForEntities($entities);
|
$permissions = $this->getEntityPermissionsForEntities($entities);
|
||||||
|
|
||||||
// Create a mapping of explicit entity permissions
|
// Create a mapping of explicit entity permissions
|
||||||
$permissionMap = [];
|
$permissionMap = [];
|
||||||
foreach ($permissions as $permission) {
|
foreach ($permissions as $permission) {
|
||||||
$key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id;
|
$key = $permission->entity_type . ':' . $permission->entity_id . ':' . $permission->role_id;
|
||||||
$isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
|
$permissionMap[$key] = $permission->view;
|
||||||
$permissionMap[$key] = $isRestricted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a mapping of role permissions
|
// Create a mapping of role permissions
|
||||||
|
@ -319,11 +310,10 @@ class JointPermissionBuilder
|
||||||
{
|
{
|
||||||
$idsByType = $this->entitiesToTypeIdMap($entities);
|
$idsByType = $this->entitiesToTypeIdMap($entities);
|
||||||
$permissionFetch = EntityPermission::query()
|
$permissionFetch = EntityPermission::query()
|
||||||
->where('action', '=', 'view')
|
|
||||||
->where(function (Builder $query) use ($idsByType) {
|
->where(function (Builder $query) use ($idsByType) {
|
||||||
foreach ($idsByType as $type => $ids) {
|
foreach ($idsByType as $type => $ids) {
|
||||||
$query->orWhere(function (Builder $query) use ($type, $ids) {
|
$query->orWhere(function (Builder $query) use ($type, $ids) {
|
||||||
$query->where('restrictable_type', '=', $type)->whereIn('restrictable_id', $ids);
|
$query->where('entity_type', '=', $type)->whereIn('entity_id', $ids);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -345,7 +335,7 @@ class JointPermissionBuilder
|
||||||
return $this->createJointPermissionDataArray($entity, $roleId, true, true);
|
return $this->createJointPermissionDataArray($entity, $roleId, true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($entity->restricted) {
|
if ($this->entityPermissionsActiveForRole($permissionMap, $entity, $roleId)) {
|
||||||
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId);
|
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId);
|
||||||
|
|
||||||
return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess);
|
return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess);
|
||||||
|
@ -358,13 +348,14 @@ class JointPermissionBuilder
|
||||||
// For chapters and pages, Check if explicit permissions are set on the Book.
|
// For chapters and pages, Check if explicit permissions are set on the Book.
|
||||||
$book = $this->getBook($entity->book_id);
|
$book = $this->getBook($entity->book_id);
|
||||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId);
|
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId);
|
||||||
$hasPermissiveAccessToParents = !$book->restricted;
|
$hasPermissiveAccessToParents = !$this->entityPermissionsActiveForRole($permissionMap, $book, $roleId);
|
||||||
|
|
||||||
// For pages with a chapter, Check if explicit permissions are set on the Chapter
|
// For pages with a chapter, Check if explicit permissions are set on the Chapter
|
||||||
if ($entity->type === 'page' && $entity->chapter_id !== 0) {
|
if ($entity->type === 'page' && $entity->chapter_id !== 0) {
|
||||||
$chapter = $this->getChapter($entity->chapter_id);
|
$chapter = $this->getChapter($entity->chapter_id);
|
||||||
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
|
$chapterRestricted = $this->entityPermissionsActiveForRole($permissionMap, $chapter, $roleId);
|
||||||
if ($chapter->restricted) {
|
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapterRestricted;
|
||||||
|
if ($chapterRestricted) {
|
||||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
|
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -377,14 +368,25 @@ class JointPermissionBuilder
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if entity permissions are defined within the given map, for the given entity and role.
|
||||||
|
* Checks for the default `role_id=0` backup option as a fallback.
|
||||||
|
*/
|
||||||
|
protected function entityPermissionsActiveForRole(array $permissionMap, SimpleEntityData $entity, int $roleId): bool
|
||||||
|
{
|
||||||
|
$keyPrefix = $entity->type . ':' . $entity->id . ':';
|
||||||
|
return isset($permissionMap[$keyPrefix . $roleId]) || isset($permissionMap[$keyPrefix . '0']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for an active restriction in an entity map.
|
* Check for an active restriction in an entity map.
|
||||||
*/
|
*/
|
||||||
protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
|
protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
|
||||||
{
|
{
|
||||||
$key = $entity->type . ':' . $entity->id . ':' . $roleId;
|
$roleKey = $entity->type . ':' . $entity->id . ':' . $roleId;
|
||||||
|
$defaultKey = $entity->type . ':' . $entity->id . ':0';
|
||||||
|
|
||||||
return $entityMap[$key] ?? false;
|
return $entityMap[$roleKey] ?? $entityMap[$defaultKey] ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -59,11 +59,15 @@ class PermissionApplicator
|
||||||
*/
|
*/
|
||||||
protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
|
protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
|
||||||
{
|
{
|
||||||
|
$this->ensureValidEntityAction($action);
|
||||||
|
|
||||||
$adminRoleId = Role::getSystemRole('admin')->id;
|
$adminRoleId = Role::getSystemRole('admin')->id;
|
||||||
if (in_array($adminRoleId, $userRoleIds)) {
|
if (in_array($adminRoleId, $userRoleIds)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The chain order here is very important due to the fact we walk up the chain
|
||||||
|
// in the loop below. Earlier items in the chain have higher priority.
|
||||||
$chain = [$entity];
|
$chain = [$entity];
|
||||||
if ($entity instanceof Page && $entity->chapter_id) {
|
if ($entity instanceof Page && $entity->chapter_id) {
|
||||||
$chain[] = $entity->chapter;
|
$chain[] = $entity->chapter;
|
||||||
|
@ -74,16 +78,26 @@ class PermissionApplicator
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($chain as $currentEntity) {
|
foreach ($chain as $currentEntity) {
|
||||||
if (is_null($currentEntity->restricted)) {
|
$allowedByRoleId = $currentEntity->permissions()
|
||||||
throw new InvalidArgumentException('Entity restricted field used but has not been loaded');
|
->whereIn('role_id', [0, ...$userRoleIds])
|
||||||
|
->pluck($action, 'role_id');
|
||||||
|
|
||||||
|
// Continue up the chain if no applicable entity permission overrides.
|
||||||
|
if ($allowedByRoleId->isEmpty()) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($currentEntity->restricted) {
|
// If we have user-role-specific permissions set, allow if any of those
|
||||||
return $currentEntity->permissions()
|
// role permissions allow access.
|
||||||
->whereIn('role_id', $userRoleIds)
|
$hasDefault = $allowedByRoleId->has(0);
|
||||||
->where('action', '=', $action)
|
if (!$hasDefault || $allowedByRoleId->count() > 1) {
|
||||||
->count() > 0;
|
return $allowedByRoleId->search(function (bool $allowed, int $roleId) {
|
||||||
|
return $roleId !== 0 && $allowed;
|
||||||
|
}) !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Otherwise, return the default "Other roles" fallback value.
|
||||||
|
return $allowedByRoleId->get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
@ -95,18 +109,16 @@ class PermissionApplicator
|
||||||
*/
|
*/
|
||||||
public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
|
public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
|
||||||
{
|
{
|
||||||
if (strpos($action, '-') !== false) {
|
$this->ensureValidEntityAction($action);
|
||||||
throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
|
|
||||||
}
|
|
||||||
|
|
||||||
$permissionQuery = EntityPermission::query()
|
$permissionQuery = EntityPermission::query()
|
||||||
->where('action', '=', $action)
|
->where($action, '=', true)
|
||||||
->whereIn('role_id', $this->getCurrentUserRoleIds());
|
->whereIn('role_id', $this->getCurrentUserRoleIds());
|
||||||
|
|
||||||
if (!empty($entityClass)) {
|
if (!empty($entityClass)) {
|
||||||
/** @var Entity $entityInstance */
|
/** @var Entity $entityInstance */
|
||||||
$entityInstance = app()->make($entityClass);
|
$entityInstance = app()->make($entityClass);
|
||||||
$permissionQuery = $permissionQuery->where('restrictable_type', '=', $entityInstance->getMorphClass());
|
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasPermission = $permissionQuery->count() > 0;
|
$hasPermission = $permissionQuery->count() > 0;
|
||||||
|
@ -255,4 +267,16 @@ class PermissionApplicator
|
||||||
|
|
||||||
return $this->currentUser()->roles->pluck('id')->values()->all();
|
return $this->currentUser()->roles->pluck('id')->values()->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the given action is a valid and expected entity action.
|
||||||
|
* Throws an exception if invalid otherwise does nothing.
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
protected function ensureValidEntityAction(string $action): void
|
||||||
|
{
|
||||||
|
if (!in_array($action, EntityPermission::PERMISSIONS)) {
|
||||||
|
throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Auth\Permissions;
|
||||||
|
|
||||||
|
use BookStack\Auth\Role;
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
|
|
||||||
|
class PermissionFormData
|
||||||
|
{
|
||||||
|
protected Entity $entity;
|
||||||
|
|
||||||
|
public function __construct(Entity $entity)
|
||||||
|
{
|
||||||
|
$this->entity = $entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the permissions with assigned roles.
|
||||||
|
*/
|
||||||
|
public function permissionsWithRoles(): array
|
||||||
|
{
|
||||||
|
return $this->entity->permissions()
|
||||||
|
->with('role')
|
||||||
|
->where('role_id', '!=', 0)
|
||||||
|
->get()
|
||||||
|
->sortBy('role.display_name')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the roles that don't yet have specific permissions for the
|
||||||
|
* entity we're managing permissions for.
|
||||||
|
*/
|
||||||
|
public function rolesNotAssigned(): array
|
||||||
|
{
|
||||||
|
$assigned = $this->entity->permissions()->pluck('role_id');
|
||||||
|
return Role::query()
|
||||||
|
->where('system_name', '!=', 'admin')
|
||||||
|
->whereNotIn('id', $assigned)
|
||||||
|
->orderBy('display_name', 'asc')
|
||||||
|
->get()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the entity permission for the "Everyone Else" option.
|
||||||
|
*/
|
||||||
|
public function everyoneElseEntityPermission(): EntityPermission
|
||||||
|
{
|
||||||
|
/** @var ?EntityPermission $permission */
|
||||||
|
$permission = $this->entity->permissions()
|
||||||
|
->where('role_id', '=', 0)
|
||||||
|
->first();
|
||||||
|
return $permission ?? (new EntityPermission());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the "Everyone Else" role entry.
|
||||||
|
*/
|
||||||
|
public function everyoneElseRole(): Role
|
||||||
|
{
|
||||||
|
return (new Role())->forceFill([
|
||||||
|
'id' => 0,
|
||||||
|
'display_name' => trans('entities.permissions_role_everyone_else'),
|
||||||
|
'description' => trans('entities.permissions_role_everyone_else_desc'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -139,6 +139,7 @@ class PermissionsRepo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$role->entityPermissions()->delete();
|
||||||
$role->jointPermissions()->delete();
|
$role->jointPermissions()->delete();
|
||||||
Activity::add(ActivityType::ROLE_DELETE, $role);
|
Activity::add(ActivityType::ROLE_DELETE, $role);
|
||||||
$role->delete();
|
$role->delete();
|
||||||
|
|
|
@ -6,7 +6,6 @@ class SimpleEntityData
|
||||||
{
|
{
|
||||||
public int $id;
|
public int $id;
|
||||||
public string $type;
|
public string $type;
|
||||||
public bool $restricted;
|
|
||||||
public int $owned_by;
|
public int $owned_by;
|
||||||
public ?int $book_id;
|
public ?int $book_id;
|
||||||
public ?int $chapter_id;
|
public ?int $chapter_id;
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace BookStack\Auth;
|
namespace BookStack\Auth;
|
||||||
|
|
||||||
|
use BookStack\Auth\Permissions\EntityPermission;
|
||||||
use BookStack\Auth\Permissions\JointPermission;
|
use BookStack\Auth\Permissions\JointPermission;
|
||||||
use BookStack\Auth\Permissions\RolePermission;
|
use BookStack\Auth\Permissions\RolePermission;
|
||||||
use BookStack\Interfaces\Loggable;
|
use BookStack\Interfaces\Loggable;
|
||||||
|
@ -54,6 +55,14 @@ class Role extends Model implements Loggable
|
||||||
return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
|
return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the entity permissions assigned to this role.
|
||||||
|
*/
|
||||||
|
public function entityPermissions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(EntityPermission::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if this role has a permission.
|
* Check if this role has a permission.
|
||||||
*/
|
*/
|
||||||
|
@ -109,17 +118,6 @@ class Role extends Model implements Loggable
|
||||||
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
|
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the roles that can be restricted.
|
|
||||||
*/
|
|
||||||
public static function restrictable(): Collection
|
|
||||||
{
|
|
||||||
return static::query()
|
|
||||||
->where('system_name', '!=', 'admin')
|
|
||||||
->orderBy('display_name', 'asc')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -10,6 +10,7 @@ use BookStack\Exceptions\UserUpdateException;
|
||||||
use BookStack\Facades\Activity;
|
use BookStack\Facades\Activity;
|
||||||
use BookStack\Uploads\UserAvatars;
|
use BookStack\Uploads\UserAvatars;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
@ -61,7 +62,7 @@ class UserRepo
|
||||||
$user = new User();
|
$user = new User();
|
||||||
$user->name = $data['name'];
|
$user->name = $data['name'];
|
||||||
$user->email = $data['email'];
|
$user->email = $data['email'];
|
||||||
$user->password = bcrypt(empty($data['password']) ? Str::random(32) : $data['password']);
|
$user->password = Hash::make(empty($data['password']) ? Str::random(32) : $data['password']);
|
||||||
$user->email_confirmed = $emailConfirmed;
|
$user->email_confirmed = $emailConfirmed;
|
||||||
$user->external_auth_id = $data['external_auth_id'] ?? '';
|
$user->external_auth_id = $data['external_auth_id'] ?? '';
|
||||||
|
|
||||||
|
@ -126,7 +127,7 @@ class UserRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($data['password'])) {
|
if (!empty($data['password'])) {
|
||||||
$user->password = bcrypt($data['password']);
|
$user->password = Hash::make($data['password']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($data['language'])) {
|
if (!empty($data['language'])) {
|
||||||
|
|
|
@ -75,7 +75,7 @@ return [
|
||||||
'locale' => env('APP_LANG', 'en'),
|
'locale' => env('APP_LANG', 'en'),
|
||||||
|
|
||||||
// Locales available
|
// Locales available
|
||||||
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
|
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
|
||||||
|
|
||||||
// Application Fallback Locale
|
// Application Fallback Locale
|
||||||
'fallback_locale' => 'en',
|
'fallback_locale' => 'en',
|
||||||
|
@ -114,6 +114,8 @@ return [
|
||||||
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
|
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
|
||||||
Illuminate\Hashing\HashServiceProvider::class,
|
Illuminate\Hashing\HashServiceProvider::class,
|
||||||
Illuminate\Mail\MailServiceProvider::class,
|
Illuminate\Mail\MailServiceProvider::class,
|
||||||
|
Illuminate\Notifications\NotificationServiceProvider::class,
|
||||||
|
Illuminate\Pagination\PaginationServiceProvider::class,
|
||||||
Illuminate\Pipeline\PipelineServiceProvider::class,
|
Illuminate\Pipeline\PipelineServiceProvider::class,
|
||||||
Illuminate\Queue\QueueServiceProvider::class,
|
Illuminate\Queue\QueueServiceProvider::class,
|
||||||
Illuminate\Redis\RedisServiceProvider::class,
|
Illuminate\Redis\RedisServiceProvider::class,
|
||||||
|
@ -121,27 +123,22 @@ return [
|
||||||
Illuminate\Session\SessionServiceProvider::class,
|
Illuminate\Session\SessionServiceProvider::class,
|
||||||
Illuminate\Validation\ValidationServiceProvider::class,
|
Illuminate\Validation\ValidationServiceProvider::class,
|
||||||
Illuminate\View\ViewServiceProvider::class,
|
Illuminate\View\ViewServiceProvider::class,
|
||||||
Illuminate\Notifications\NotificationServiceProvider::class,
|
|
||||||
SocialiteProviders\Manager\ServiceProvider::class,
|
|
||||||
|
|
||||||
// Third party service providers
|
// Third party service providers
|
||||||
Intervention\Image\ImageServiceProvider::class,
|
|
||||||
Barryvdh\DomPDF\ServiceProvider::class,
|
Barryvdh\DomPDF\ServiceProvider::class,
|
||||||
Barryvdh\Snappy\ServiceProvider::class,
|
Barryvdh\Snappy\ServiceProvider::class,
|
||||||
|
Intervention\Image\ImageServiceProvider::class,
|
||||||
// BookStack replacement service providers (Extends Laravel)
|
SocialiteProviders\Manager\ServiceProvider::class,
|
||||||
BookStack\Providers\PaginationServiceProvider::class,
|
|
||||||
BookStack\Providers\TranslationServiceProvider::class,
|
|
||||||
|
|
||||||
// BookStack custom service providers
|
// BookStack custom service providers
|
||||||
BookStack\Providers\ThemeServiceProvider::class,
|
BookStack\Providers\ThemeServiceProvider::class,
|
||||||
BookStack\Providers\AuthServiceProvider::class,
|
|
||||||
BookStack\Providers\AppServiceProvider::class,
|
BookStack\Providers\AppServiceProvider::class,
|
||||||
BookStack\Providers\BroadcastServiceProvider::class,
|
BookStack\Providers\AuthServiceProvider::class,
|
||||||
BookStack\Providers\EventServiceProvider::class,
|
BookStack\Providers\EventServiceProvider::class,
|
||||||
BookStack\Providers\RouteServiceProvider::class,
|
BookStack\Providers\RouteServiceProvider::class,
|
||||||
BookStack\Providers\CustomFacadeProvider::class,
|
BookStack\Providers\TranslationServiceProvider::class,
|
||||||
BookStack\Providers\CustomValidationServiceProvider::class,
|
BookStack\Providers\ValidationRuleServiceProvider::class,
|
||||||
|
BookStack\Providers\ViewTweaksServiceProvider::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
namespace BookStack\Console\Commands;
|
namespace BookStack\Console\Commands;
|
||||||
|
|
||||||
use BookStack\Entities\Models\Bookshelf;
|
use BookStack\Entities\Models\Bookshelf;
|
||||||
use BookStack\Entities\Repos\BookshelfRepo;
|
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class CopyShelfPermissions extends Command
|
class CopyShelfPermissions extends Command
|
||||||
|
@ -25,19 +25,16 @@ class CopyShelfPermissions extends Command
|
||||||
*/
|
*/
|
||||||
protected $description = 'Copy shelf permissions to all child books';
|
protected $description = 'Copy shelf permissions to all child books';
|
||||||
|
|
||||||
/**
|
protected PermissionsUpdater $permissionsUpdater;
|
||||||
* @var BookshelfRepo
|
|
||||||
*/
|
|
||||||
protected $bookshelfRepo;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new command instance.
|
* Create a new command instance.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function __construct(BookshelfRepo $repo)
|
public function __construct(PermissionsUpdater $permissionsUpdater)
|
||||||
{
|
{
|
||||||
$this->bookshelfRepo = $repo;
|
$this->permissionsUpdater = $permissionsUpdater;
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,18 +66,18 @@ class CopyShelfPermissions extends Command
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$shelves = Bookshelf::query()->get(['id', 'restricted']);
|
$shelves = Bookshelf::query()->get(['id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($shelfSlug) {
|
if ($shelfSlug) {
|
||||||
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id', 'restricted']);
|
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id']);
|
||||||
if ($shelves->count() === 0) {
|
if ($shelves->count() === 0) {
|
||||||
$this->info('No shelves found with the given slug.');
|
$this->info('No shelves found with the given slug.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($shelves as $shelf) {
|
foreach ($shelves as $shelf) {
|
||||||
$this->bookshelfRepo->copyDownPermissions($shelf, false);
|
$this->permissionsUpdater->updateBookPermissionsFromShelf($shelf, false);
|
||||||
$this->info('Copied permissions for shelf [' . $shelf->id . ']');
|
$this->info('Copied permissions for shelf [' . $shelf->id . ']');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@ use Illuminate\Support\Collection;
|
||||||
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
||||||
* @property \Illuminate\Database\Eloquent\Collection $pages
|
* @property \Illuminate\Database\Eloquent\Collection $pages
|
||||||
* @property \Illuminate\Database\Eloquent\Collection $directPages
|
* @property \Illuminate\Database\Eloquent\Collection $directPages
|
||||||
|
* @property \Illuminate\Database\Eloquent\Collection $shelves
|
||||||
*/
|
*/
|
||||||
class Book extends Entity implements HasCoverImage
|
class Book extends Entity implements HasCoverImage
|
||||||
{
|
{
|
||||||
|
@ -27,7 +28,7 @@ class Book extends Entity implements HasCoverImage
|
||||||
public $searchFactor = 1.2;
|
public $searchFactor = 1.2;
|
||||||
|
|
||||||
protected $fillable = ['name', 'description'];
|
protected $fillable = ['name', 'description'];
|
||||||
protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];
|
protected $hidden = ['pivot', 'image_id', 'deleted_at'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the url for this book.
|
* Get the url for this book.
|
||||||
|
@ -119,4 +120,13 @@ class Book extends Entity implements HasCoverImage
|
||||||
|
|
||||||
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
|
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a visible book by its slug.
|
||||||
|
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||||
|
*/
|
||||||
|
public static function getBySlug(string $slug): self
|
||||||
|
{
|
||||||
|
return static::visible()->where('slug', '=', $slug)->firstOrFail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||||
|
|
||||||
protected $fillable = ['name', 'description', 'image_id'];
|
protected $fillable = ['name', 'description', 'image_id'];
|
||||||
|
|
||||||
protected $hidden = ['restricted', 'image_id', 'deleted_at'];
|
protected $hidden = ['image_id', 'deleted_at'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the books in this shelf.
|
* Get the books in this shelf.
|
||||||
|
@ -109,4 +109,13 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||||
$maxOrder = $this->books()->max('order');
|
$maxOrder = $this->books()->max('order');
|
||||||
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
|
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a visible shelf by its slug.
|
||||||
|
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||||
|
*/
|
||||||
|
public static function getBySlug(string $slug): self
|
||||||
|
{
|
||||||
|
return static::visible()->where('slug', '=', $slug)->firstOrFail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ class Chapter extends BookChild
|
||||||
public $searchFactor = 1.2;
|
public $searchFactor = 1.2;
|
||||||
|
|
||||||
protected $fillable = ['name', 'description', 'priority'];
|
protected $fillable = ['name', 'description', 'priority'];
|
||||||
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
|
protected $hidden = ['pivot', 'deleted_at'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the pages that this chapter contains.
|
* Get the pages that this chapter contains.
|
||||||
|
@ -58,4 +58,13 @@ class Chapter extends BookChild
|
||||||
->orderBy('priority', 'asc')
|
->orderBy('priority', 'asc')
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a visible chapter by its book and page slugs.
|
||||||
|
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||||
|
*/
|
||||||
|
public static function getBySlugs(string $bookSlug, string $chapterSlug): self
|
||||||
|
{
|
||||||
|
return static::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,7 +42,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
* @property Carbon $deleted_at
|
* @property Carbon $deleted_at
|
||||||
* @property int $created_by
|
* @property int $created_by
|
||||||
* @property int $updated_by
|
* @property int $updated_by
|
||||||
* @property bool $restricted
|
|
||||||
* @property Collection $tags
|
* @property Collection $tags
|
||||||
*
|
*
|
||||||
* @method static Entity|Builder visible()
|
* @method static Entity|Builder visible()
|
||||||
|
@ -176,16 +175,15 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||||
*/
|
*/
|
||||||
public function permissions(): MorphMany
|
public function permissions(): MorphMany
|
||||||
{
|
{
|
||||||
return $this->morphMany(EntityPermission::class, 'restrictable');
|
return $this->morphMany(EntityPermission::class, 'entity');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if this entity has a specific restriction set against it.
|
* Check if this entity has a specific restriction set against it.
|
||||||
*/
|
*/
|
||||||
public function hasRestriction(int $role_id, string $action): bool
|
public function hasPermissions(): bool
|
||||||
{
|
{
|
||||||
return $this->permissions()->where('role_id', '=', $role_id)
|
return $this->permissions()->count() > 0;
|
||||||
->where('action', '=', $action)->count() > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -39,7 +39,7 @@ class Page extends BookChild
|
||||||
|
|
||||||
public $textField = 'text';
|
public $textField = 'text';
|
||||||
|
|
||||||
protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot', 'deleted_at'];
|
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'draft' => 'boolean',
|
'draft' => 'boolean',
|
||||||
|
@ -145,4 +145,13 @@ class Page extends BookChild
|
||||||
|
|
||||||
return $refreshed;
|
return $refreshed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a visible page by its book and page slugs.
|
||||||
|
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||||
|
*/
|
||||||
|
public static function getBySlugs(string $bookSlug, string $pageSlug): self
|
||||||
|
{
|
||||||
|
return static::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
class PageRevision extends Model implements Loggable
|
class PageRevision extends Model implements Loggable
|
||||||
{
|
{
|
||||||
protected $fillable = ['name', 'text', 'summary'];
|
protected $fillable = ['name', 'text', 'summary'];
|
||||||
protected $hidden = ['html', 'markdown', 'restricted', 'text'];
|
protected $hidden = ['html', 'markdown', 'text'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the user that created the page revision.
|
* Get the user that created the page revision.
|
||||||
|
|
|
@ -134,31 +134,6 @@ class BookshelfRepo
|
||||||
$shelf->books()->sync($syncData);
|
$shelf->books()->sync($syncData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy down the permissions of the given shelf to all child books.
|
|
||||||
*/
|
|
||||||
public function copyDownPermissions(Bookshelf $shelf, $checkUserPermissions = true): int
|
|
||||||
{
|
|
||||||
$shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
|
|
||||||
$shelfBooks = $shelf->books()->get(['id', 'restricted', 'owned_by']);
|
|
||||||
$updatedBookCount = 0;
|
|
||||||
|
|
||||||
/** @var Book $book */
|
|
||||||
foreach ($shelfBooks as $book) {
|
|
||||||
if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$book->permissions()->delete();
|
|
||||||
$book->restricted = $shelf->restricted;
|
|
||||||
$book->permissions()->createMany($shelfPermissions);
|
|
||||||
$book->save();
|
|
||||||
$book->rebuildPermissions();
|
|
||||||
$updatedBookCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $updatedBookCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a bookshelf from the system.
|
* Remove a bookshelf from the system.
|
||||||
*
|
*
|
||||||
|
|
|
@ -11,22 +11,15 @@ use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class BookContents
|
class BookContents
|
||||||
{
|
{
|
||||||
/**
|
protected Book $book;
|
||||||
* @var Book
|
|
||||||
*/
|
|
||||||
protected $book;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BookContents constructor.
|
|
||||||
*/
|
|
||||||
public function __construct(Book $book)
|
public function __construct(Book $book)
|
||||||
{
|
{
|
||||||
$this->book = $book;
|
$this->book = $book;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current priority of the last item
|
* Get the current priority of the last item at the top-level of the book.
|
||||||
* at the top-level of the book.
|
|
||||||
*/
|
*/
|
||||||
public function getLastPriority(): int
|
public function getLastPriority(): int
|
||||||
{
|
{
|
||||||
|
|
|
@ -4,6 +4,7 @@ namespace BookStack\Entities\Tools;
|
||||||
|
|
||||||
use BookStack\Actions\Tag;
|
use BookStack\Actions\Tag;
|
||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
|
use BookStack\Entities\Models\Bookshelf;
|
||||||
use BookStack\Entities\Models\Chapter;
|
use BookStack\Entities\Models\Chapter;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
|
@ -71,8 +72,10 @@ class Cloner
|
||||||
$bookDetails = $this->entityToInputData($original);
|
$bookDetails = $this->entityToInputData($original);
|
||||||
$bookDetails['name'] = $newName;
|
$bookDetails['name'] = $newName;
|
||||||
|
|
||||||
|
// Clone book
|
||||||
$copyBook = $this->bookRepo->create($bookDetails);
|
$copyBook = $this->bookRepo->create($bookDetails);
|
||||||
|
|
||||||
|
// Clone contents
|
||||||
$directChildren = $original->getDirectChildren();
|
$directChildren = $original->getDirectChildren();
|
||||||
foreach ($directChildren as $child) {
|
foreach ($directChildren as $child) {
|
||||||
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
|
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
|
||||||
|
@ -84,6 +87,14 @@ class Cloner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clone bookshelf relationships
|
||||||
|
/** @var Bookshelf $shelf */
|
||||||
|
foreach ($original->shelves as $shelf) {
|
||||||
|
if (userCan('bookshelf-update', $shelf)) {
|
||||||
|
$shelf->appendBook($copyBook);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $copyBook;
|
return $copyBook;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,8 +122,7 @@ class Cloner
|
||||||
*/
|
*/
|
||||||
public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void
|
public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void
|
||||||
{
|
{
|
||||||
$targetEntity->restricted = $sourceEntity->restricted;
|
$permissions = $sourceEntity->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray();
|
||||||
$permissions = $sourceEntity->permissions()->get(['role_id', 'action'])->toArray();
|
|
||||||
$targetEntity->permissions()->delete();
|
$targetEntity->permissions()->delete();
|
||||||
$targetEntity->permissions()->createMany($permissions);
|
$targetEntity->permissions()->createMany($permissions);
|
||||||
$targetEntity->rebuildPermissions();
|
$targetEntity->rebuildPermissions();
|
||||||
|
|
|
@ -65,7 +65,7 @@ class HierarchyTransformer
|
||||||
foreach ($book->chapters as $index => $chapter) {
|
foreach ($book->chapters as $index => $chapter) {
|
||||||
$newBook = $this->transformChapterToBook($chapter);
|
$newBook = $this->transformChapterToBook($chapter);
|
||||||
$shelfBookSyncData[$newBook->id] = ['order' => $index];
|
$shelfBookSyncData[$newBook->id] = ['order' => $index];
|
||||||
if (!$newBook->restricted) {
|
if (!$newBook->hasPermissions()) {
|
||||||
$this->cloner->copyEntityPermissions($shelf, $newBook);
|
$this->cloner->copyEntityPermissions($shelf, $newBook);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,10 @@
|
||||||
namespace BookStack\Entities\Tools;
|
namespace BookStack\Entities\Tools;
|
||||||
|
|
||||||
use BookStack\Actions\ActivityType;
|
use BookStack\Actions\ActivityType;
|
||||||
|
use BookStack\Auth\Permissions\EntityPermission;
|
||||||
use BookStack\Auth\User;
|
use BookStack\Auth\User;
|
||||||
|
use BookStack\Entities\Models\Book;
|
||||||
|
use BookStack\Entities\Models\Bookshelf;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Facades\Activity;
|
use BookStack\Facades\Activity;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
@ -16,11 +19,9 @@ class PermissionsUpdater
|
||||||
*/
|
*/
|
||||||
public function updateFromPermissionsForm(Entity $entity, Request $request)
|
public function updateFromPermissionsForm(Entity $entity, Request $request)
|
||||||
{
|
{
|
||||||
$restricted = $request->get('restricted') === 'true';
|
$permissions = $request->get('permissions', null);
|
||||||
$permissions = $request->get('restrictions', null);
|
|
||||||
$ownerId = $request->get('owned_by', null);
|
$ownerId = $request->get('owned_by', null);
|
||||||
|
|
||||||
$entity->restricted = $restricted;
|
|
||||||
$entity->permissions()->delete();
|
$entity->permissions()->delete();
|
||||||
|
|
||||||
if (!is_null($permissions)) {
|
if (!is_null($permissions)) {
|
||||||
|
@ -52,18 +53,43 @@ class PermissionsUpdater
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format permissions provided from a permission form to be
|
* Format permissions provided from a permission form to be EntityPermission data.
|
||||||
* EntityPermission data.
|
|
||||||
*/
|
*/
|
||||||
protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): Collection
|
protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): array
|
||||||
{
|
{
|
||||||
return collect($permissions)->flatMap(function ($restrictions, $roleId) {
|
$formatted = [];
|
||||||
return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
|
|
||||||
return [
|
foreach ($permissions as $roleId => $info) {
|
||||||
'role_id' => $roleId,
|
$entityPermissionData = ['role_id' => $roleId];
|
||||||
'action' => strtolower($action),
|
foreach (EntityPermission::PERMISSIONS as $permission) {
|
||||||
];
|
$entityPermissionData[$permission] = (($info[$permission] ?? false) === "true");
|
||||||
});
|
}
|
||||||
});
|
$formatted[] = $entityPermissionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy down the permissions of the given shelf to all child books.
|
||||||
|
*/
|
||||||
|
public function updateBookPermissionsFromShelf(Bookshelf $shelf, $checkUserPermissions = true): int
|
||||||
|
{
|
||||||
|
$shelfPermissions = $shelf->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray();
|
||||||
|
$shelfBooks = $shelf->books()->get(['id', 'owned_by']);
|
||||||
|
$updatedBookCount = 0;
|
||||||
|
|
||||||
|
/** @var Book $book */
|
||||||
|
foreach ($shelfBooks as $book) {
|
||||||
|
if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$book->permissions()->delete();
|
||||||
|
$book->permissions()->createMany($shelfPermissions);
|
||||||
|
$book->rebuildPermissions();
|
||||||
|
$updatedBookCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updatedBookCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,14 +2,18 @@
|
||||||
|
|
||||||
namespace BookStack\Http\Controllers\Api;
|
namespace BookStack\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use BookStack\Api\ApiEntityListFormatter;
|
||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
|
use BookStack\Entities\Models\Chapter;
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Repos\BookRepo;
|
use BookStack\Entities\Repos\BookRepo;
|
||||||
|
use BookStack\Entities\Tools\BookContents;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class BookApiController extends ApiController
|
class BookApiController extends ApiController
|
||||||
{
|
{
|
||||||
protected $bookRepo;
|
protected BookRepo $bookRepo;
|
||||||
|
|
||||||
public function __construct(BookRepo $bookRepo)
|
public function __construct(BookRepo $bookRepo)
|
||||||
{
|
{
|
||||||
|
@ -47,11 +51,25 @@ class BookApiController extends ApiController
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View the details of a single book.
|
* View the details of a single book.
|
||||||
|
* The response data will contain 'content' property listing the chapter and pages directly within, in
|
||||||
|
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
|
||||||
|
* contents will have a 'type' property to distinguish between pages & chapters.
|
||||||
*/
|
*/
|
||||||
public function read(string $id)
|
public function read(string $id)
|
||||||
{
|
{
|
||||||
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
|
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
|
||||||
|
|
||||||
|
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||||
|
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||||
|
->withType()
|
||||||
|
->withField('pages', function (Entity $entity) {
|
||||||
|
if ($entity instanceof Chapter) {
|
||||||
|
return (new ApiEntityListFormatter($entity->pages->all()))->format();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})->format();
|
||||||
|
$book->setAttribute('contents', $contentsApiData);
|
||||||
|
|
||||||
return response()->json($book);
|
return response()->json($book);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,9 +13,6 @@ class BookshelfApiController extends ApiController
|
||||||
{
|
{
|
||||||
protected BookshelfRepo $bookshelfRepo;
|
protected BookshelfRepo $bookshelfRepo;
|
||||||
|
|
||||||
/**
|
|
||||||
* BookshelfApiController constructor.
|
|
||||||
*/
|
|
||||||
public function __construct(BookshelfRepo $bookshelfRepo)
|
public function __construct(BookshelfRepo $bookshelfRepo)
|
||||||
{
|
{
|
||||||
$this->bookshelfRepo = $bookshelfRepo;
|
$this->bookshelfRepo = $bookshelfRepo;
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace BookStack\Http\Controllers\Api;
|
namespace BookStack\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use BookStack\Api\ApiEntityListFormatter;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Search\SearchOptions;
|
use BookStack\Search\SearchOptions;
|
||||||
use BookStack\Search\SearchResultsFormatter;
|
use BookStack\Search\SearchResultsFormatter;
|
||||||
|
@ -10,8 +11,8 @@ use Illuminate\Http\Request;
|
||||||
|
|
||||||
class SearchApiController extends ApiController
|
class SearchApiController extends ApiController
|
||||||
{
|
{
|
||||||
protected $searchRunner;
|
protected SearchRunner $searchRunner;
|
||||||
protected $resultsFormatter;
|
protected SearchResultsFormatter $resultsFormatter;
|
||||||
|
|
||||||
protected $rules = [
|
protected $rules = [
|
||||||
'all' => [
|
'all' => [
|
||||||
|
@ -50,24 +51,17 @@ class SearchApiController extends ApiController
|
||||||
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
|
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
|
||||||
$this->resultsFormatter->format($results['results']->all(), $options);
|
$this->resultsFormatter->format($results['results']->all(), $options);
|
||||||
|
|
||||||
/** @var Entity $result */
|
$data = (new ApiEntityListFormatter($results['results']->all()))
|
||||||
foreach ($results['results'] as $result) {
|
->withType()->withTags()
|
||||||
$result->setVisible([
|
->withField('preview_html', function (Entity $entity) {
|
||||||
'id', 'name', 'slug', 'book_id',
|
return [
|
||||||
'chapter_id', 'draft', 'template',
|
'name' => (string) $entity->getAttribute('preview_name'),
|
||||||
'created_at', 'updated_at',
|
'content' => (string) $entity->getAttribute('preview_content'),
|
||||||
'tags', 'type', 'preview_html', 'url',
|
];
|
||||||
]);
|
})->format();
|
||||||
$result->setAttribute('type', $result->getType());
|
|
||||||
$result->setAttribute('url', $result->getUrl());
|
|
||||||
$result->setAttribute('preview_html', [
|
|
||||||
'name' => (string) $result->getAttribute('preview_name'),
|
|
||||||
'content' => (string) $result->getAttribute('preview_content'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $results['results'],
|
'data' => $data,
|
||||||
'total' => $results['total'],
|
'total' => $results['total'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,9 +14,9 @@ use Illuminate\Http\Request;
|
||||||
|
|
||||||
class ConfirmEmailController extends Controller
|
class ConfirmEmailController extends Controller
|
||||||
{
|
{
|
||||||
protected $emailConfirmationService;
|
protected EmailConfirmationService $emailConfirmationService;
|
||||||
protected $loginService;
|
protected LoginService $loginService;
|
||||||
protected $userRepo;
|
protected UserRepo $userRepo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new controller instance.
|
* Create a new controller instance.
|
||||||
|
|
|
@ -4,24 +4,11 @@ namespace BookStack\Http\Controllers\Auth;
|
||||||
|
|
||||||
use BookStack\Actions\ActivityType;
|
use BookStack\Actions\ActivityType;
|
||||||
use BookStack\Http\Controllers\Controller;
|
use BookStack\Http\Controllers\Controller;
|
||||||
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Password;
|
use Illuminate\Support\Facades\Password;
|
||||||
|
|
||||||
class ForgotPasswordController extends Controller
|
class ForgotPasswordController extends Controller
|
||||||
{
|
{
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Password Reset Controller
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This controller is responsible for handling password reset emails and
|
|
||||||
| includes a trait which assists in sending these notifications from
|
|
||||||
| your application to your users. Feel free to explore this trait.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
use SendsPasswordResetEmails;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new controller instance.
|
* Create a new controller instance.
|
||||||
*
|
*
|
||||||
|
@ -33,6 +20,14 @@ class ForgotPasswordController extends Controller
|
||||||
$this->middleware('guard:standard');
|
$this->middleware('guard:standard');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the form to request a password reset link.
|
||||||
|
*/
|
||||||
|
public function showLinkRequestForm()
|
||||||
|
{
|
||||||
|
return view('auth.passwords.email');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a reset link to the given user.
|
* Send a reset link to the given user.
|
||||||
*
|
*
|
||||||
|
@ -49,7 +44,7 @@ class ForgotPasswordController extends Controller
|
||||||
// We will send the password reset link to this user. Once we have attempted
|
// We will send the password reset link to this user. Once we have attempted
|
||||||
// to send the link, we will examine the response then see the message we
|
// to send the link, we will examine the response then see the message we
|
||||||
// need to show to the user. Finally, we'll send out a proper response.
|
// need to show to the user. Finally, we'll send out a proper response.
|
||||||
$response = $this->broker()->sendResetLink(
|
$response = Password::broker()->sendResetLink(
|
||||||
$request->only('email')
|
$request->only('email')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -8,31 +8,14 @@ use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||||
use BookStack\Exceptions\LoginAttemptException;
|
use BookStack\Exceptions\LoginAttemptException;
|
||||||
use BookStack\Facades\Activity;
|
use BookStack\Facades\Activity;
|
||||||
use BookStack\Http\Controllers\Controller;
|
use BookStack\Http\Controllers\Controller;
|
||||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class LoginController extends Controller
|
class LoginController extends Controller
|
||||||
{
|
{
|
||||||
/*
|
use ThrottlesLogins;
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Login Controller
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This controller handles authenticating users for the application and
|
|
||||||
| redirecting them to your home screen. The controller uses a trait
|
|
||||||
| to conveniently provide its functionality to your applications.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
use AuthenticatesUsers {
|
|
||||||
logout as traitLogout;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirection paths.
|
|
||||||
*/
|
|
||||||
protected $redirectTo = '/';
|
|
||||||
protected $redirectPath = '/';
|
|
||||||
|
|
||||||
protected SocialAuthService $socialAuthService;
|
protected SocialAuthService $socialAuthService;
|
||||||
protected LoginService $loginService;
|
protected LoginService $loginService;
|
||||||
|
@ -48,21 +31,6 @@ class LoginController extends Controller
|
||||||
|
|
||||||
$this->socialAuthService = $socialAuthService;
|
$this->socialAuthService = $socialAuthService;
|
||||||
$this->loginService = $loginService;
|
$this->loginService = $loginService;
|
||||||
|
|
||||||
$this->redirectPath = url('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function username()
|
|
||||||
{
|
|
||||||
return config('auth.method') === 'standard' ? 'email' : 'username';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the needed authorization credentials from the request.
|
|
||||||
*/
|
|
||||||
protected function credentials(Request $request)
|
|
||||||
{
|
|
||||||
return $request->only('username', 'email', 'password');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -98,29 +66,15 @@ class LoginController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a login request to the application.
|
* Handle a login request to the application.
|
||||||
*
|
|
||||||
* @param \Illuminate\Http\Request $request
|
|
||||||
*
|
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
|
||||||
*
|
|
||||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
|
|
||||||
*/
|
*/
|
||||||
public function login(Request $request)
|
public function login(Request $request)
|
||||||
{
|
{
|
||||||
$this->validateLogin($request);
|
$this->validateLogin($request);
|
||||||
$username = $request->get($this->username());
|
$username = $request->get($this->username());
|
||||||
|
|
||||||
// If the class is using the ThrottlesLogins trait, we can automatically throttle
|
// Check login throttling attempts to see if they've gone over the limit
|
||||||
// the login attempts for this application. We'll key this by the username and
|
if ($this->hasTooManyLoginAttempts($request)) {
|
||||||
// the IP address of the client making these requests into this application.
|
|
||||||
if (
|
|
||||||
method_exists($this, 'hasTooManyLoginAttempts') &&
|
|
||||||
$this->hasTooManyLoginAttempts($request)
|
|
||||||
) {
|
|
||||||
$this->fireLockoutEvent($request);
|
|
||||||
|
|
||||||
Activity::logFailedLogin($username);
|
Activity::logFailedLogin($username);
|
||||||
|
|
||||||
return $this->sendLockoutResponse($request);
|
return $this->sendLockoutResponse($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,24 +88,62 @@ class LoginController extends Controller
|
||||||
return $this->sendLoginAttemptExceptionResponse($exception, $request);
|
return $this->sendLoginAttemptExceptionResponse($exception, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the login attempt was unsuccessful we will increment the number of attempts
|
// On unsuccessful login attempt, Increment login attempts for throttling and log failed login.
|
||||||
// to login and redirect the user back to the login form. Of course, when this
|
|
||||||
// user surpasses their maximum number of attempts they will get locked out.
|
|
||||||
$this->incrementLoginAttempts($request);
|
$this->incrementLoginAttempts($request);
|
||||||
|
|
||||||
Activity::logFailedLogin($username);
|
Activity::logFailedLogin($username);
|
||||||
|
|
||||||
return $this->sendFailedLoginResponse($request);
|
// Throw validation failure for failed login
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$this->username() => [trans('auth.failed')],
|
||||||
|
])->redirectTo('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout user and perform subsequent redirect.
|
||||||
|
*/
|
||||||
|
public function logout(Request $request)
|
||||||
|
{
|
||||||
|
Auth::guard()->logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
|
||||||
|
|
||||||
|
return redirect($redirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the expected username input based upon the current auth method.
|
||||||
|
*/
|
||||||
|
protected function username(): string
|
||||||
|
{
|
||||||
|
return config('auth.method') === 'standard' ? 'email' : 'username';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the needed authorization credentials from the request.
|
||||||
|
*/
|
||||||
|
protected function credentials(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->only('username', 'email', 'password');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the response after the user was authenticated.
|
||||||
|
* @return RedirectResponse
|
||||||
|
*/
|
||||||
|
protected function sendLoginResponse(Request $request)
|
||||||
|
{
|
||||||
|
$request->session()->regenerate();
|
||||||
|
$this->clearLoginAttempts($request);
|
||||||
|
|
||||||
|
return redirect()->intended('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to log the user into the application.
|
* Attempt to log the user into the application.
|
||||||
*
|
|
||||||
* @param \Illuminate\Http\Request $request
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
protected function attemptLogin(Request $request)
|
protected function attemptLogin(Request $request): bool
|
||||||
{
|
{
|
||||||
return $this->loginService->attempt(
|
return $this->loginService->attempt(
|
||||||
$this->credentials($request),
|
$this->credentials($request),
|
||||||
|
@ -160,29 +152,12 @@ class LoginController extends Controller
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The user has been authenticated.
|
|
||||||
*
|
|
||||||
* @param \Illuminate\Http\Request $request
|
|
||||||
* @param mixed $user
|
|
||||||
*
|
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
protected function authenticated(Request $request, $user)
|
|
||||||
{
|
|
||||||
return redirect()->intended($this->redirectPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the user login request.
|
* Validate the user login request.
|
||||||
*
|
* @throws ValidationException
|
||||||
* @param \Illuminate\Http\Request $request
|
|
||||||
*
|
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
protected function validateLogin(Request $request)
|
protected function validateLogin(Request $request): void
|
||||||
{
|
{
|
||||||
$rules = ['password' => ['required', 'string']];
|
$rules = ['password' => ['required', 'string']];
|
||||||
$authMethod = config('auth.method');
|
$authMethod = config('auth.method');
|
||||||
|
@ -216,22 +191,6 @@ class LoginController extends Controller
|
||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the failed login response instance.
|
|
||||||
*
|
|
||||||
* @param \Illuminate\Http\Request $request
|
|
||||||
*
|
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
|
||||||
*
|
|
||||||
* @return \Symfony\Component\HttpFoundation\Response
|
|
||||||
*/
|
|
||||||
protected function sendFailedLoginResponse(Request $request)
|
|
||||||
{
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
$this->username() => [trans('auth.failed')],
|
|
||||||
])->redirectTo('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the intended URL location from their previous URL.
|
* Update the intended URL location from their previous URL.
|
||||||
* Ignores if not from the current app instance or if from certain
|
* Ignores if not from the current app instance or if from certain
|
||||||
|
@ -271,20 +230,4 @@ class LoginController extends Controller
|
||||||
|
|
||||||
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
|
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Logout user and perform subsequent redirect.
|
|
||||||
*
|
|
||||||
* @param \Illuminate\Http\Request $request
|
|
||||||
*
|
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
public function logout(Request $request)
|
|
||||||
{
|
|
||||||
$this->traitLogout($request);
|
|
||||||
|
|
||||||
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
|
|
||||||
|
|
||||||
return redirect($redirectUri);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,42 +5,20 @@ namespace BookStack\Http\Controllers\Auth;
|
||||||
use BookStack\Auth\Access\LoginService;
|
use BookStack\Auth\Access\LoginService;
|
||||||
use BookStack\Auth\Access\RegistrationService;
|
use BookStack\Auth\Access\RegistrationService;
|
||||||
use BookStack\Auth\Access\SocialAuthService;
|
use BookStack\Auth\Access\SocialAuthService;
|
||||||
use BookStack\Auth\User;
|
|
||||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||||
use BookStack\Exceptions\UserRegistrationException;
|
use BookStack\Exceptions\UserRegistrationException;
|
||||||
use BookStack\Http\Controllers\Controller;
|
use BookStack\Http\Controllers\Controller;
|
||||||
use Illuminate\Foundation\Auth\RegistersUsers;
|
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
class RegisterController extends Controller
|
class RegisterController extends Controller
|
||||||
{
|
{
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Register Controller
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This controller handles the registration of new users as well as their
|
|
||||||
| validation and creation. By default this controller uses a trait to
|
|
||||||
| provide this functionality without requiring any additional code.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
use RegistersUsers;
|
|
||||||
|
|
||||||
protected SocialAuthService $socialAuthService;
|
protected SocialAuthService $socialAuthService;
|
||||||
protected RegistrationService $registrationService;
|
protected RegistrationService $registrationService;
|
||||||
protected LoginService $loginService;
|
protected LoginService $loginService;
|
||||||
|
|
||||||
/**
|
|
||||||
* Where to redirect users after login / registration.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $redirectTo = '/';
|
|
||||||
protected $redirectPath = '/';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new controller instance.
|
* Create a new controller instance.
|
||||||
*/
|
*/
|
||||||
|
@ -55,23 +33,6 @@ class RegisterController extends Controller
|
||||||
$this->socialAuthService = $socialAuthService;
|
$this->socialAuthService = $socialAuthService;
|
||||||
$this->registrationService = $registrationService;
|
$this->registrationService = $registrationService;
|
||||||
$this->loginService = $loginService;
|
$this->loginService = $loginService;
|
||||||
|
|
||||||
$this->redirectTo = url('/');
|
|
||||||
$this->redirectPath = url('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a validator for an incoming registration request.
|
|
||||||
*
|
|
||||||
* @return \Illuminate\Contracts\Validation\Validator
|
|
||||||
*/
|
|
||||||
protected function validator(array $data)
|
|
||||||
{
|
|
||||||
return Validator::make($data, [
|
|
||||||
'name' => ['required', 'min:2', 'max:100'],
|
|
||||||
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
|
||||||
'password' => ['required', Password::default()],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -114,22 +75,18 @@ class RegisterController extends Controller
|
||||||
|
|
||||||
$this->showSuccessNotification(trans('auth.register_success'));
|
$this->showSuccessNotification(trans('auth.register_success'));
|
||||||
|
|
||||||
return redirect($this->redirectPath());
|
return redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new user instance after a valid registration.
|
* Get a validator for an incoming registration request.
|
||||||
*
|
|
||||||
* @param array $data
|
|
||||||
*
|
|
||||||
* @return User
|
|
||||||
*/
|
*/
|
||||||
protected function create(array $data)
|
protected function validator(array $data): ValidatorContract
|
||||||
{
|
{
|
||||||
return User::create([
|
return Validator::make($data, [
|
||||||
'name' => $data['name'],
|
'name' => ['required', 'min:2', 'max:100'],
|
||||||
'email' => $data['email'],
|
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
||||||
'password' => Hash::make($data['password']),
|
'password' => ['required', Password::default()],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,65 +3,87 @@
|
||||||
namespace BookStack\Http\Controllers\Auth;
|
namespace BookStack\Http\Controllers\Auth;
|
||||||
|
|
||||||
use BookStack\Actions\ActivityType;
|
use BookStack\Actions\ActivityType;
|
||||||
|
use BookStack\Auth\Access\LoginService;
|
||||||
|
use BookStack\Auth\User;
|
||||||
use BookStack\Http\Controllers\Controller;
|
use BookStack\Http\Controllers\Controller;
|
||||||
use Illuminate\Foundation\Auth\ResetsPasswords;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Password;
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||||
|
|
||||||
class ResetPasswordController extends Controller
|
class ResetPasswordController extends Controller
|
||||||
{
|
{
|
||||||
/*
|
protected LoginService $loginService;
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Password Reset Controller
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This controller is responsible for handling password reset requests
|
|
||||||
| and uses a simple trait to include this behavior. You're free to
|
|
||||||
| explore this trait and override any methods you wish to tweak.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
use ResetsPasswords;
|
|
||||||
|
|
||||||
protected $redirectTo = '/';
|
public function __construct(LoginService $loginService)
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new controller instance.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function __construct()
|
|
||||||
{
|
{
|
||||||
$this->middleware('guest');
|
$this->middleware('guest');
|
||||||
$this->middleware('guard:standard');
|
$this->middleware('guard:standard');
|
||||||
|
|
||||||
|
$this->loginService = $loginService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the password reset view for the given token.
|
||||||
|
* If no token is present, display the link request form.
|
||||||
|
*/
|
||||||
|
public function showResetForm(Request $request)
|
||||||
|
{
|
||||||
|
$token = $request->route()->parameter('token');
|
||||||
|
|
||||||
|
return view('auth.passwords.reset')->with(
|
||||||
|
['token' => $token, 'email' => $request->email]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the given user's password.
|
||||||
|
*/
|
||||||
|
public function reset(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'token' => 'required',
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => ['required', 'confirmed', PasswordRule::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Here we will attempt to reset the user's password. If it is successful we
|
||||||
|
// will update the password on an actual user model and persist it to the
|
||||||
|
// database. Otherwise we will parse the error and return the response.
|
||||||
|
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
|
||||||
|
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
|
||||||
|
$user->password = Hash::make($password);
|
||||||
|
$user->setRememberToken(Str::random(60));
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
$this->loginService->login($user, auth()->getDefaultDriver());
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the password was successfully reset, we will redirect the user back to
|
||||||
|
// the application's home authenticated view. If there is an error we can
|
||||||
|
// redirect them back to where they came from with their error message.
|
||||||
|
return $response === Password::PASSWORD_RESET
|
||||||
|
? $this->sendResetResponse()
|
||||||
|
: $this->sendResetFailedResponse($request, $response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the response for a successful password reset.
|
* Get the response for a successful password reset.
|
||||||
*
|
|
||||||
* @param Request $request
|
|
||||||
* @param string $response
|
|
||||||
*
|
|
||||||
* @return \Illuminate\Http\Response
|
|
||||||
*/
|
*/
|
||||||
protected function sendResetResponse(Request $request, $response)
|
protected function sendResetResponse(): RedirectResponse
|
||||||
{
|
{
|
||||||
$message = trans('auth.reset_password_success');
|
$this->showSuccessNotification(trans('auth.reset_password_success'));
|
||||||
$this->showSuccessNotification($message);
|
|
||||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user());
|
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user());
|
||||||
|
|
||||||
return redirect($this->redirectPath())
|
return redirect('/');
|
||||||
->with('status', trans($response));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the response for a failed password reset.
|
* Get the response for a failed password reset.
|
||||||
*
|
|
||||||
* @param \Illuminate\Http\Request $request
|
|
||||||
* @param string $response
|
|
||||||
*
|
|
||||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
|
||||||
*/
|
*/
|
||||||
protected function sendResetFailedResponse(Request $request, $response)
|
protected function sendResetFailedResponse(Request $request, string $response): RedirectResponse
|
||||||
{
|
{
|
||||||
// We show invalid users as invalid tokens as to not leak what
|
// We show invalid users as invalid tokens as to not leak what
|
||||||
// users may exist in the system.
|
// users may exist in the system.
|
||||||
|
|
|
@ -9,7 +9,7 @@ use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Saml2Controller extends Controller
|
class Saml2Controller extends Controller
|
||||||
{
|
{
|
||||||
protected $samlService;
|
protected Saml2Service $samlService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saml2Controller constructor.
|
* Saml2Controller constructor.
|
||||||
|
|
|
@ -16,9 +16,9 @@ use Laravel\Socialite\Contracts\User as SocialUser;
|
||||||
|
|
||||||
class SocialController extends Controller
|
class SocialController extends Controller
|
||||||
{
|
{
|
||||||
protected $socialAuthService;
|
protected SocialAuthService $socialAuthService;
|
||||||
protected $registrationService;
|
protected RegistrationService $registrationService;
|
||||||
protected $loginService;
|
protected LoginService $loginService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SocialController constructor.
|
* SocialController constructor.
|
||||||
|
@ -28,7 +28,7 @@ class SocialController extends Controller
|
||||||
RegistrationService $registrationService,
|
RegistrationService $registrationService,
|
||||||
LoginService $loginService
|
LoginService $loginService
|
||||||
) {
|
) {
|
||||||
$this->middleware('guest')->only(['getRegister', 'postRegister']);
|
$this->middleware('guest')->only(['register']);
|
||||||
$this->socialAuthService = $socialAuthService;
|
$this->socialAuthService = $socialAuthService;
|
||||||
$this->registrationService = $registrationService;
|
$this->registrationService = $registrationService;
|
||||||
$this->loginService = $loginService;
|
$this->loginService = $loginService;
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Cache\RateLimiter;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
trait ThrottlesLogins
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user has too many failed login attempts.
|
||||||
|
*/
|
||||||
|
protected function hasTooManyLoginAttempts(Request $request): bool
|
||||||
|
{
|
||||||
|
return $this->limiter()->tooManyAttempts(
|
||||||
|
$this->throttleKey($request),
|
||||||
|
$this->maxAttempts()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the login attempts for the user.
|
||||||
|
*/
|
||||||
|
protected function incrementLoginAttempts(Request $request): void
|
||||||
|
{
|
||||||
|
$this->limiter()->hit(
|
||||||
|
$this->throttleKey($request),
|
||||||
|
$this->decayMinutes() * 60
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect the user after determining they are locked out.
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
protected function sendLockoutResponse(Request $request): \Symfony\Component\HttpFoundation\Response
|
||||||
|
{
|
||||||
|
$seconds = $this->limiter()->availableIn(
|
||||||
|
$this->throttleKey($request)
|
||||||
|
);
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$this->username() => [trans('auth.throttle', [
|
||||||
|
'seconds' => $seconds,
|
||||||
|
'minutes' => ceil($seconds / 60),
|
||||||
|
])],
|
||||||
|
])->status(Response::HTTP_TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the login locks for the given user credentials.
|
||||||
|
*/
|
||||||
|
protected function clearLoginAttempts(Request $request): void
|
||||||
|
{
|
||||||
|
$this->limiter()->clear($this->throttleKey($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the throttle key for the given request.
|
||||||
|
*/
|
||||||
|
protected function throttleKey(Request $request): string
|
||||||
|
{
|
||||||
|
return Str::transliterate(Str::lower($request->input($this->username())) . '|' . $request->ip());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rate limiter instance.
|
||||||
|
*/
|
||||||
|
protected function limiter(): RateLimiter
|
||||||
|
{
|
||||||
|
return app(RateLimiter::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the maximum number of attempts to allow.
|
||||||
|
*/
|
||||||
|
public function maxAttempts(): int
|
||||||
|
{
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of minutes to throttle for.
|
||||||
|
*/
|
||||||
|
public function decayMinutes(): int
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,12 +11,13 @@ use Exception;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Redirector;
|
use Illuminate\Routing\Redirector;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
class UserInviteController extends Controller
|
class UserInviteController extends Controller
|
||||||
{
|
{
|
||||||
protected $inviteService;
|
protected UserInviteService $inviteService;
|
||||||
protected $userRepo;
|
protected UserRepo $userRepo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new controller instance.
|
* Create a new controller instance.
|
||||||
|
@ -66,7 +67,7 @@ class UserInviteController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $this->userRepo->getById($userId);
|
$user = $this->userRepo->getById($userId);
|
||||||
$user->password = bcrypt($request->get('password'));
|
$user->password = Hash::make($request->get('password'));
|
||||||
$user->email_confirmed = true;
|
$user->email_confirmed = true;
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,6 @@ use BookStack\Entities\Repos\BookRepo;
|
||||||
use BookStack\Entities\Tools\BookContents;
|
use BookStack\Entities\Tools\BookContents;
|
||||||
use BookStack\Entities\Tools\Cloner;
|
use BookStack\Entities\Tools\Cloner;
|
||||||
use BookStack\Entities\Tools\HierarchyTransformer;
|
use BookStack\Entities\Tools\HierarchyTransformer;
|
||||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
|
||||||
use BookStack\Entities\Tools\ShelfContext;
|
use BookStack\Entities\Tools\ShelfContext;
|
||||||
use BookStack\Exceptions\ImageUploadException;
|
use BookStack\Exceptions\ImageUploadException;
|
||||||
use BookStack\Exceptions\NotFoundException;
|
use BookStack\Exceptions\NotFoundException;
|
||||||
|
@ -209,36 +208,6 @@ class BookController extends Controller
|
||||||
return redirect('/books');
|
return redirect('/books');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the permissions view.
|
|
||||||
*/
|
|
||||||
public function showPermissions(string $bookSlug)
|
|
||||||
{
|
|
||||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
|
||||||
$this->checkOwnablePermission('restrictions-manage', $book);
|
|
||||||
|
|
||||||
return view('books.permissions', [
|
|
||||||
'book' => $book,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the restrictions for this book.
|
|
||||||
*
|
|
||||||
* @throws Throwable
|
|
||||||
*/
|
|
||||||
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug)
|
|
||||||
{
|
|
||||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
|
||||||
$this->checkOwnablePermission('restrictions-manage', $book);
|
|
||||||
|
|
||||||
$permissionsUpdater->updateFromPermissionsForm($book, $request);
|
|
||||||
|
|
||||||
$this->showSuccessNotification(trans('entities.books_permissions_updated'));
|
|
||||||
|
|
||||||
return redirect($book->getUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the view to copy a book.
|
* Show the view to copy a book.
|
||||||
*
|
*
|
||||||
|
|
|
@ -6,7 +6,6 @@ use BookStack\Actions\ActivityQueries;
|
||||||
use BookStack\Actions\View;
|
use BookStack\Actions\View;
|
||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
use BookStack\Entities\Repos\BookshelfRepo;
|
use BookStack\Entities\Repos\BookshelfRepo;
|
||||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
|
||||||
use BookStack\Entities\Tools\ShelfContext;
|
use BookStack\Entities\Tools\ShelfContext;
|
||||||
use BookStack\Exceptions\ImageUploadException;
|
use BookStack\Exceptions\ImageUploadException;
|
||||||
use BookStack\Exceptions\NotFoundException;
|
use BookStack\Exceptions\NotFoundException;
|
||||||
|
@ -207,46 +206,4 @@ class BookshelfController extends Controller
|
||||||
|
|
||||||
return redirect('/shelves');
|
return redirect('/shelves');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the permissions view.
|
|
||||||
*/
|
|
||||||
public function showPermissions(string $slug)
|
|
||||||
{
|
|
||||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
|
||||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
|
||||||
|
|
||||||
return view('shelves.permissions', [
|
|
||||||
'shelf' => $shelf,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the permissions for this bookshelf.
|
|
||||||
*/
|
|
||||||
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $slug)
|
|
||||||
{
|
|
||||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
|
||||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
|
||||||
|
|
||||||
$permissionsUpdater->updateFromPermissionsForm($shelf, $request);
|
|
||||||
|
|
||||||
$this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
|
|
||||||
|
|
||||||
return redirect($shelf->getUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy the permissions of a bookshelf to the child books.
|
|
||||||
*/
|
|
||||||
public function copyPermissions(string $slug)
|
|
||||||
{
|
|
||||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
|
||||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
|
||||||
|
|
||||||
$updateCount = $this->shelfRepo->copyDownPermissions($shelf);
|
|
||||||
$this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
|
|
||||||
|
|
||||||
return redirect($shelf->getUrl());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,6 @@ use BookStack\Entities\Tools\BookContents;
|
||||||
use BookStack\Entities\Tools\Cloner;
|
use BookStack\Entities\Tools\Cloner;
|
||||||
use BookStack\Entities\Tools\HierarchyTransformer;
|
use BookStack\Entities\Tools\HierarchyTransformer;
|
||||||
use BookStack\Entities\Tools\NextPreviousContentLocator;
|
use BookStack\Entities\Tools\NextPreviousContentLocator;
|
||||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
|
||||||
use BookStack\Exceptions\MoveOperationException;
|
use BookStack\Exceptions\MoveOperationException;
|
||||||
use BookStack\Exceptions\NotFoundException;
|
use BookStack\Exceptions\NotFoundException;
|
||||||
use BookStack\Exceptions\PermissionsException;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
|
@ -243,38 +242,6 @@ class ChapterController extends Controller
|
||||||
return redirect($chapterCopy->getUrl());
|
return redirect($chapterCopy->getUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the Restrictions view.
|
|
||||||
*
|
|
||||||
* @throws NotFoundException
|
|
||||||
*/
|
|
||||||
public function showPermissions(string $bookSlug, string $chapterSlug)
|
|
||||||
{
|
|
||||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
|
||||||
$this->checkOwnablePermission('restrictions-manage', $chapter);
|
|
||||||
|
|
||||||
return view('chapters.permissions', [
|
|
||||||
'chapter' => $chapter,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the restrictions for this chapter.
|
|
||||||
*
|
|
||||||
* @throws NotFoundException
|
|
||||||
*/
|
|
||||||
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $chapterSlug)
|
|
||||||
{
|
|
||||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
|
||||||
$this->checkOwnablePermission('restrictions-manage', $chapter);
|
|
||||||
|
|
||||||
$permissionsUpdater->updateFromPermissionsForm($chapter, $request);
|
|
||||||
|
|
||||||
$this->showSuccessNotification(trans('entities.chapters_permissions_success'));
|
|
||||||
|
|
||||||
return redirect($chapter->getUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert the chapter to a book.
|
* Convert the chapter to a book.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -87,7 +87,7 @@ class FavouriteController extends Controller
|
||||||
|
|
||||||
$modelInstance = $model->newQuery()
|
$modelInstance = $model->newQuery()
|
||||||
->where('id', '=', $modelInfo['id'])
|
->where('id', '=', $modelInfo['id'])
|
||||||
->first(['id', 'name', 'restricted', 'owned_by']);
|
->first(['id', 'name', 'owned_by']);
|
||||||
|
|
||||||
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
|
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
|
||||||
if (is_null($modelInstance) || $inaccessibleEntity) {
|
if (is_null($modelInstance) || $inaccessibleEntity) {
|
||||||
|
|
|
@ -11,7 +11,6 @@ use BookStack\Entities\Tools\NextPreviousContentLocator;
|
||||||
use BookStack\Entities\Tools\PageContent;
|
use BookStack\Entities\Tools\PageContent;
|
||||||
use BookStack\Entities\Tools\PageEditActivity;
|
use BookStack\Entities\Tools\PageEditActivity;
|
||||||
use BookStack\Entities\Tools\PageEditorData;
|
use BookStack\Entities\Tools\PageEditorData;
|
||||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
|
||||||
use BookStack\Exceptions\NotFoundException;
|
use BookStack\Exceptions\NotFoundException;
|
||||||
use BookStack\Exceptions\PermissionsException;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
use BookStack\References\ReferenceFetcher;
|
use BookStack\References\ReferenceFetcher;
|
||||||
|
@ -452,37 +451,4 @@ class PageController extends Controller
|
||||||
|
|
||||||
return redirect($pageCopy->getUrl());
|
return redirect($pageCopy->getUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the Permissions view.
|
|
||||||
*
|
|
||||||
* @throws NotFoundException
|
|
||||||
*/
|
|
||||||
public function showPermissions(string $bookSlug, string $pageSlug)
|
|
||||||
{
|
|
||||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
|
||||||
$this->checkOwnablePermission('restrictions-manage', $page);
|
|
||||||
|
|
||||||
return view('pages.permissions', [
|
|
||||||
'page' => $page,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the permissions for this page.
|
|
||||||
*
|
|
||||||
* @throws NotFoundException
|
|
||||||
* @throws Throwable
|
|
||||||
*/
|
|
||||||
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $pageSlug)
|
|
||||||
{
|
|
||||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
|
||||||
$this->checkOwnablePermission('restrictions-manage', $page);
|
|
||||||
|
|
||||||
$permissionsUpdater->updateFromPermissionsForm($page, $request);
|
|
||||||
|
|
||||||
$this->showSuccessNotification(trans('entities.pages_permissions_success'));
|
|
||||||
|
|
||||||
return redirect($page->getUrl());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,174 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Http\Controllers;
|
||||||
|
|
||||||
|
use BookStack\Auth\Permissions\EntityPermission;
|
||||||
|
use BookStack\Auth\Permissions\PermissionFormData;
|
||||||
|
use BookStack\Auth\Role;
|
||||||
|
use BookStack\Entities\Models\Book;
|
||||||
|
use BookStack\Entities\Models\Bookshelf;
|
||||||
|
use BookStack\Entities\Models\Chapter;
|
||||||
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PermissionsController extends Controller
|
||||||
|
{
|
||||||
|
protected PermissionsUpdater $permissionsUpdater;
|
||||||
|
|
||||||
|
public function __construct(PermissionsUpdater $permissionsUpdater)
|
||||||
|
{
|
||||||
|
$this->permissionsUpdater = $permissionsUpdater;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the Permissions view for a page.
|
||||||
|
*/
|
||||||
|
public function showForPage(string $bookSlug, string $pageSlug)
|
||||||
|
{
|
||||||
|
$page = Page::getBySlugs($bookSlug, $pageSlug);
|
||||||
|
$this->checkOwnablePermission('restrictions-manage', $page);
|
||||||
|
|
||||||
|
$this->setPageTitle(trans('entities.pages_permissions'));
|
||||||
|
return view('pages.permissions', [
|
||||||
|
'page' => $page,
|
||||||
|
'data' => new PermissionFormData($page),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the permissions for a page.
|
||||||
|
*/
|
||||||
|
public function updateForPage(Request $request, string $bookSlug, string $pageSlug)
|
||||||
|
{
|
||||||
|
$page = Page::getBySlugs($bookSlug, $pageSlug);
|
||||||
|
$this->checkOwnablePermission('restrictions-manage', $page);
|
||||||
|
|
||||||
|
$this->permissionsUpdater->updateFromPermissionsForm($page, $request);
|
||||||
|
|
||||||
|
$this->showSuccessNotification(trans('entities.pages_permissions_success'));
|
||||||
|
|
||||||
|
return redirect($page->getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the Restrictions view for a chapter.
|
||||||
|
*/
|
||||||
|
public function showForChapter(string $bookSlug, string $chapterSlug)
|
||||||
|
{
|
||||||
|
$chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
|
||||||
|
$this->checkOwnablePermission('restrictions-manage', $chapter);
|
||||||
|
|
||||||
|
$this->setPageTitle(trans('entities.chapters_permissions'));
|
||||||
|
return view('chapters.permissions', [
|
||||||
|
'chapter' => $chapter,
|
||||||
|
'data' => new PermissionFormData($chapter),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the restrictions for a chapter.
|
||||||
|
*/
|
||||||
|
public function updateForChapter(Request $request, string $bookSlug, string $chapterSlug)
|
||||||
|
{
|
||||||
|
$chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
|
||||||
|
$this->checkOwnablePermission('restrictions-manage', $chapter);
|
||||||
|
|
||||||
|
$this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
|
||||||
|
|
||||||
|
$this->showSuccessNotification(trans('entities.chapters_permissions_success'));
|
||||||
|
|
||||||
|
return redirect($chapter->getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the permissions view for a book.
|
||||||
|
*/
|
||||||
|
public function showForBook(string $slug)
|
||||||
|
{
|
||||||
|
$book = Book::getBySlug($slug);
|
||||||
|
$this->checkOwnablePermission('restrictions-manage', $book);
|
||||||
|
|
||||||
|
$this->setPageTitle(trans('entities.books_permissions'));
|
||||||
|
return view('books.permissions', [
|
||||||
|
'book' => $book,
|
||||||
|
'data' => new PermissionFormData($book),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the restrictions for a book.
|
||||||
|
*/
|
||||||
|
public function updateForBook(Request $request, string $slug)
|
||||||
|
{
|
||||||
|
$book = Book::getBySlug($slug);
|
||||||
|
$this->checkOwnablePermission('restrictions-manage', $book);
|
||||||
|
|
||||||
|
$this->permissionsUpdater->updateFromPermissionsForm($book, $request);
|
||||||
|
|
||||||
|
$this->showSuccessNotification(trans('entities.books_permissions_updated'));
|
||||||
|
|
||||||
|
return redirect($book->getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the permissions view for a shelf.
|
||||||
|
*/
|
||||||
|
public function showForShelf(string $slug)
|
||||||
|
{
|
||||||
|
$shelf = Bookshelf::getBySlug($slug);
|
||||||
|
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||||
|
|
||||||
|
$this->setPageTitle(trans('entities.shelves_permissions'));
|
||||||
|
return view('shelves.permissions', [
|
||||||
|
'shelf' => $shelf,
|
||||||
|
'data' => new PermissionFormData($shelf),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the permissions for a shelf.
|
||||||
|
*/
|
||||||
|
public function updateForShelf(Request $request, string $slug)
|
||||||
|
{
|
||||||
|
$shelf = Bookshelf::getBySlug($slug);
|
||||||
|
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||||
|
|
||||||
|
$this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
|
||||||
|
|
||||||
|
$this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
|
||||||
|
|
||||||
|
return redirect($shelf->getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the permissions of a bookshelf to the child books.
|
||||||
|
*/
|
||||||
|
public function copyShelfPermissionsToBooks(string $slug)
|
||||||
|
{
|
||||||
|
$shelf = Bookshelf::getBySlug($slug);
|
||||||
|
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||||
|
|
||||||
|
$updateCount = $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf);
|
||||||
|
$this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
|
||||||
|
|
||||||
|
return redirect($shelf->getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an empty entity permissions form row for the given role.
|
||||||
|
*/
|
||||||
|
public function formRowForRole(string $entityType, string $roleId)
|
||||||
|
{
|
||||||
|
$this->checkPermissionOr('restrictions-manage-all', fn() => userCan('restrictions-manage-own'));
|
||||||
|
|
||||||
|
$role = Role::query()->findOrFail($roleId);
|
||||||
|
|
||||||
|
return view('form.entity-permissions-row', [
|
||||||
|
'role' => $role,
|
||||||
|
'permission' => new EntityPermission(),
|
||||||
|
'entityType' => $entityType,
|
||||||
|
'inheriting' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,8 +22,7 @@ class ReferenceController extends Controller
|
||||||
*/
|
*/
|
||||||
public function page(string $bookSlug, string $pageSlug)
|
public function page(string $bookSlug, string $pageSlug)
|
||||||
{
|
{
|
||||||
/** @var Page $page */
|
$page = Page::getBySlugs($bookSlug, $pageSlug);
|
||||||
$page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail();
|
|
||||||
$references = $this->referenceFetcher->getPageReferencesToEntity($page);
|
$references = $this->referenceFetcher->getPageReferencesToEntity($page);
|
||||||
|
|
||||||
return view('pages.references', [
|
return view('pages.references', [
|
||||||
|
@ -37,8 +36,7 @@ class ReferenceController extends Controller
|
||||||
*/
|
*/
|
||||||
public function chapter(string $bookSlug, string $chapterSlug)
|
public function chapter(string $bookSlug, string $chapterSlug)
|
||||||
{
|
{
|
||||||
/** @var Chapter $chapter */
|
$chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
|
||||||
$chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
|
||||||
$references = $this->referenceFetcher->getPageReferencesToEntity($chapter);
|
$references = $this->referenceFetcher->getPageReferencesToEntity($chapter);
|
||||||
|
|
||||||
return view('chapters.references', [
|
return view('chapters.references', [
|
||||||
|
@ -52,7 +50,7 @@ class ReferenceController extends Controller
|
||||||
*/
|
*/
|
||||||
public function book(string $slug)
|
public function book(string $slug)
|
||||||
{
|
{
|
||||||
$book = Book::visible()->where('slug', '=', $slug)->firstOrFail();
|
$book = Book::getBySlug($slug);
|
||||||
$references = $this->referenceFetcher->getPageReferencesToEntity($book);
|
$references = $this->referenceFetcher->getPageReferencesToEntity($book);
|
||||||
|
|
||||||
return view('books.references', [
|
return view('books.references', [
|
||||||
|
@ -66,7 +64,7 @@ class ReferenceController extends Controller
|
||||||
*/
|
*/
|
||||||
public function shelf(string $slug)
|
public function shelf(string $slug)
|
||||||
{
|
{
|
||||||
$shelf = Bookshelf::visible()->where('slug', '=', $slug)->firstOrFail();
|
$shelf = Bookshelf::getBySlug($slug);
|
||||||
$references = $this->referenceFetcher->getPageReferencesToEntity($shelf);
|
$references = $this->referenceFetcher->getPageReferencesToEntity($shelf);
|
||||||
|
|
||||||
return view('shelves.references', [
|
return view('shelves.references', [
|
||||||
|
|
|
@ -7,11 +7,8 @@ use Illuminate\Http\Request;
|
||||||
|
|
||||||
class TagController extends Controller
|
class TagController extends Controller
|
||||||
{
|
{
|
||||||
protected $tagRepo;
|
protected TagRepo $tagRepo;
|
||||||
|
|
||||||
/**
|
|
||||||
* TagController constructor.
|
|
||||||
*/
|
|
||||||
public function __construct(TagRepo $tagRepo)
|
public function __construct(TagRepo $tagRepo)
|
||||||
{
|
{
|
||||||
$this->tagRepo = $tagRepo;
|
$this->tagRepo = $tagRepo;
|
||||||
|
@ -46,7 +43,7 @@ class TagController extends Controller
|
||||||
*/
|
*/
|
||||||
public function getNameSuggestions(Request $request)
|
public function getNameSuggestions(Request $request)
|
||||||
{
|
{
|
||||||
$searchTerm = $request->get('search', null);
|
$searchTerm = $request->get('search', '');
|
||||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
||||||
|
|
||||||
return response()->json($suggestions);
|
return response()->json($suggestions);
|
||||||
|
@ -57,8 +54,8 @@ class TagController extends Controller
|
||||||
*/
|
*/
|
||||||
public function getValueSuggestions(Request $request)
|
public function getValueSuggestions(Request $request)
|
||||||
{
|
{
|
||||||
$searchTerm = $request->get('search', null);
|
$searchTerm = $request->get('search', '');
|
||||||
$tagName = $request->get('name', null);
|
$tagName = $request->get('name', '');
|
||||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
||||||
|
|
||||||
return response()->json($suggestions);
|
return response()->json($suggestions);
|
||||||
|
|
|
@ -2,32 +2,44 @@
|
||||||
|
|
||||||
namespace BookStack\Providers;
|
namespace BookStack\Providers;
|
||||||
|
|
||||||
use BookStack\Auth\Access\LoginService;
|
use BookStack\Actions\ActivityLogger;
|
||||||
use BookStack\Auth\Access\SocialAuthService;
|
use BookStack\Auth\Access\SocialAuthService;
|
||||||
use BookStack\Entities\BreadcrumbsViewComposer;
|
|
||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
use BookStack\Entities\Models\Bookshelf;
|
use BookStack\Entities\Models\Bookshelf;
|
||||||
use BookStack\Entities\Models\Chapter;
|
use BookStack\Entities\Models\Chapter;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
use BookStack\Exceptions\WhoopsBookStackPrettyHandler;
|
use BookStack\Exceptions\WhoopsBookStackPrettyHandler;
|
||||||
use BookStack\Settings\Setting;
|
|
||||||
use BookStack\Settings\SettingService;
|
use BookStack\Settings\SettingService;
|
||||||
use BookStack\Util\CspService;
|
use BookStack\Util\CspService;
|
||||||
use GuzzleHttp\Client;
|
use GuzzleHttp\Client;
|
||||||
use Illuminate\Contracts\Cache\Repository;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Pagination\Paginator;
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Facades\View;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
|
|
||||||
use Psr\Http\Client\ClientInterface as HttpClientInterface;
|
use Psr\Http\Client\ClientInterface as HttpClientInterface;
|
||||||
use Whoops\Handler\HandlerInterface;
|
use Whoops\Handler\HandlerInterface;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Custom container bindings to register.
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
public $bindings = [
|
||||||
|
HandlerInterface::class => WhoopsBookStackPrettyHandler::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom singleton bindings to register.
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
public $singletons = [
|
||||||
|
'activity' => ActivityLogger::class,
|
||||||
|
SettingService::class => SettingService::class,
|
||||||
|
SocialAuthService::class => SocialAuthService::class,
|
||||||
|
CspService::class => CspService::class,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstrap any application services.
|
* Bootstrap any application services.
|
||||||
*
|
*
|
||||||
|
@ -43,11 +55,6 @@ class AppServiceProvider extends ServiceProvider
|
||||||
URL::forceScheme($isHttps ? 'https' : 'http');
|
URL::forceScheme($isHttps ? 'https' : 'http');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom blade view directives
|
|
||||||
Blade::directive('icon', function ($expression) {
|
|
||||||
return "<?php echo icon($expression); ?>";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Allow longer string lengths after upgrade to utf8mb4
|
// Allow longer string lengths after upgrade to utf8mb4
|
||||||
Schema::defaultStringLength(191);
|
Schema::defaultStringLength(191);
|
||||||
|
|
||||||
|
@ -58,12 +65,6 @@ class AppServiceProvider extends ServiceProvider
|
||||||
'chapter' => Chapter::class,
|
'chapter' => Chapter::class,
|
||||||
'page' => Page::class,
|
'page' => Page::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// View Composers
|
|
||||||
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
|
|
||||||
|
|
||||||
// Set paginator to use bootstrap-style pagination
|
|
||||||
Paginator::useBootstrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -73,22 +74,6 @@ class AppServiceProvider extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function register()
|
public function register()
|
||||||
{
|
{
|
||||||
$this->app->bind(HandlerInterface::class, function ($app) {
|
|
||||||
return $app->make(WhoopsBookStackPrettyHandler::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->app->singleton(SettingService::class, function ($app) {
|
|
||||||
return new SettingService($app->make(Setting::class), $app->make(Repository::class));
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->app->singleton(SocialAuthService::class, function ($app) {
|
|
||||||
return new SocialAuthService($app->make(SocialiteFactory::class), $app->make(LoginService::class));
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->app->singleton(CspService::class, function ($app) {
|
|
||||||
return new CspService();
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->app->bind(HttpClientInterface::class, function ($app) {
|
$this->app->bind(HttpClientInterface::class, function ($app) {
|
||||||
return new Client([
|
return new Client([
|
||||||
'timeout' => 3,
|
'timeout' => 3,
|
||||||
|
|
|
@ -24,9 +24,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
// Password Configuration
|
// Password Configuration
|
||||||
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
|
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
|
||||||
Password::defaults(function () {
|
Password::defaults(fn () => Password::min(8));
|
||||||
return Password::min(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Custom guards
|
// Custom guards
|
||||||
Auth::extend('api-token', function ($app, $name, array $config) {
|
Auth::extend('api-token', function ($app, $name, array $config) {
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Providers;
|
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
|
|
||||||
class BroadcastServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Bootstrap any application services.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function boot()
|
|
||||||
{
|
|
||||||
// Broadcast::routes();
|
|
||||||
//
|
|
||||||
// /*
|
|
||||||
// * Authenticate the user's personal channel...
|
|
||||||
// */
|
|
||||||
// Broadcast::channel('BookStack.User.*', function ($user, $userId) {
|
|
||||||
// return (int) $user->id === (int) $userId;
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Providers;
|
|
||||||
|
|
||||||
use BookStack\Actions\ActivityLogger;
|
|
||||||
use BookStack\Theming\ThemeService;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
|
|
||||||
class CustomFacadeProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Bootstrap the application services.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function boot()
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register the application services.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function register()
|
|
||||||
{
|
|
||||||
$this->app->singleton('activity', function () {
|
|
||||||
return $this->app->make(ActivityLogger::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->app->singleton('theme', function () {
|
|
||||||
return $this->app->make(ThemeService::class);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -10,7 +10,7 @@ class EventServiceProvider extends ServiceProvider
|
||||||
/**
|
/**
|
||||||
* The event listener mappings for the application.
|
* The event listener mappings for the application.
|
||||||
*
|
*
|
||||||
* @var array
|
* @var array<class-string, array<int, class-string>>
|
||||||
*/
|
*/
|
||||||
protected $listen = [
|
protected $listen = [
|
||||||
SocialiteWasCalled::class => [
|
SocialiteWasCalled::class => [
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Providers;
|
|
||||||
|
|
||||||
use Illuminate\Pagination\PaginationServiceProvider as IlluminatePaginationServiceProvider;
|
|
||||||
use Illuminate\Pagination\Paginator;
|
|
||||||
|
|
||||||
class PaginationServiceProvider extends IlluminatePaginationServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Register the service provider.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function register()
|
|
||||||
{
|
|
||||||
Paginator::viewFactoryResolver(function () {
|
|
||||||
return $this->app['view'];
|
|
||||||
});
|
|
||||||
|
|
||||||
Paginator::currentPathResolver(function () {
|
|
||||||
return url($this->app['request']->path());
|
|
||||||
});
|
|
||||||
|
|
||||||
Paginator::currentPageResolver(function ($pageName = 'page') {
|
|
||||||
$page = $this->app['request']->input($pageName);
|
|
||||||
|
|
||||||
if (filter_var($page, FILTER_VALIDATE_INT) !== false && (int) $page >= 1) {
|
|
||||||
return $page;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -15,9 +15,8 @@ class ThemeServiceProvider extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function register()
|
public function register()
|
||||||
{
|
{
|
||||||
$this->app->singleton(ThemeService::class, function ($app) {
|
// Register the ThemeService as a singleton
|
||||||
return new ThemeService();
|
$this->app->singleton(ThemeService::class, fn ($app) => new ThemeService());
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -27,6 +26,7 @@ class ThemeServiceProvider extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function boot()
|
public function boot()
|
||||||
{
|
{
|
||||||
|
// Boot up the theme system
|
||||||
$themeService = $this->app->make(ThemeService::class);
|
$themeService = $this->app->make(ThemeService::class);
|
||||||
$themeService->readThemeActions();
|
$themeService->readThemeActions();
|
||||||
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
|
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
|
||||||
|
|
|
@ -6,7 +6,7 @@ use BookStack\Uploads\ImageService;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class CustomValidationServiceProvider extends ServiceProvider
|
class ValidationRuleServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Register our custom validation rules when the application boots.
|
* Register our custom validation rules when the application boots.
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Providers;
|
||||||
|
|
||||||
|
use BookStack\Entities\BreadcrumbsViewComposer;
|
||||||
|
use Illuminate\Pagination\Paginator;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class ViewTweaksServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Bootstrap services.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function boot()
|
||||||
|
{
|
||||||
|
// Set paginator to use bootstrap-style pagination
|
||||||
|
Paginator::useBootstrap();
|
||||||
|
|
||||||
|
// View Composers
|
||||||
|
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
|
||||||
|
|
||||||
|
// Custom blade view directives
|
||||||
|
Blade::directive('icon', function ($expression) {
|
||||||
|
return "<?php echo icon($expression); ?>";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -162,7 +162,7 @@ class SearchRunner
|
||||||
$entityQuery = $entityModelInstance->newQuery()->scopes('visible');
|
$entityQuery = $entityModelInstance->newQuery()->scopes('visible');
|
||||||
|
|
||||||
if ($entityModelInstance instanceof Page) {
|
if ($entityModelInstance instanceof Page) {
|
||||||
$entityQuery->select(array_merge($entityModelInstance::$listAttributes, ['restricted', 'owned_by']));
|
$entityQuery->select(array_merge($entityModelInstance::$listAttributes, ['owned_by']));
|
||||||
} else {
|
} else {
|
||||||
$entityQuery->select(['*']);
|
$entityQuery->select(['*']);
|
||||||
}
|
}
|
||||||
|
@ -447,7 +447,7 @@ class SearchRunner
|
||||||
|
|
||||||
protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input)
|
protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input)
|
||||||
{
|
{
|
||||||
$query->where('restricted', '=', true);
|
$query->whereHas('permissions');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function filterViewedByMe(EloquentBuilder $query, Entity $model, $input)
|
protected function filterViewedByMe(EloquentBuilder $query, Entity $model, $input)
|
||||||
|
|
|
@ -28,6 +28,7 @@ class LanguageManager
|
||||||
'de' => ['iso' => 'de_DE', 'windows' => 'German'],
|
'de' => ['iso' => 'de_DE', 'windows' => 'German'],
|
||||||
'de_informal' => ['iso' => 'de_DE', 'windows' => 'German'],
|
'de_informal' => ['iso' => 'de_DE', 'windows' => 'German'],
|
||||||
'en' => ['iso' => 'en_GB', 'windows' => 'English'],
|
'en' => ['iso' => 'en_GB', 'windows' => 'English'],
|
||||||
|
'el' => ['iso' => 'el_GR', 'windows' => 'Greek'],
|
||||||
'es' => ['iso' => 'es_ES', 'windows' => 'Spanish'],
|
'es' => ['iso' => 'es_ES', 'windows' => 'Spanish'],
|
||||||
'es_AR' => ['iso' => 'es_AR', 'windows' => 'Spanish'],
|
'es_AR' => ['iso' => 'es_AR', 'windows' => 'Spanish'],
|
||||||
'et' => ['iso' => 'et_EE', 'windows' => 'Estonian'],
|
'et' => ['iso' => 'et_EE', 'windows' => 'Estonian'],
|
||||||
|
|
|
@ -26,7 +26,6 @@
|
||||||
"laravel/framework": "^8.68",
|
"laravel/framework": "^8.68",
|
||||||
"laravel/socialite": "^5.2",
|
"laravel/socialite": "^5.2",
|
||||||
"laravel/tinker": "^2.6",
|
"laravel/tinker": "^2.6",
|
||||||
"laravel/ui": "^3.3",
|
|
||||||
"league/commonmark": "^1.6",
|
"league/commonmark": "^1.6",
|
||||||
"league/flysystem-aws-s3-v3": "^1.0.29",
|
"league/flysystem-aws-s3-v3": "^1.0.29",
|
||||||
"league/html-to-markdown": "^5.0.0",
|
"league/html-to-markdown": "^5.0.0",
|
||||||
|
@ -44,6 +43,7 @@
|
||||||
"ssddanbrown/htmldiff": "^1.0.2"
|
"ssddanbrown/htmldiff": "^1.0.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"brianium/paratest": "^6.6",
|
||||||
"fakerphp/faker": "^1.16",
|
"fakerphp/faker": "^1.16",
|
||||||
"itsgoingd/clockwork": "^5.1",
|
"itsgoingd/clockwork": "^5.1",
|
||||||
"mockery/mockery": "^1.4",
|
"mockery/mockery": "^1.4",
|
||||||
|
@ -73,6 +73,8 @@
|
||||||
"format": "phpcbf",
|
"format": "phpcbf",
|
||||||
"lint": "phpcs",
|
"lint": "phpcs",
|
||||||
"test": "phpunit",
|
"test": "phpunit",
|
||||||
|
"t": "@php artisan test --parallel",
|
||||||
|
"t-reset": "@php artisan test --recreate-databases",
|
||||||
"post-autoload-dump": [
|
"post-autoload-dump": [
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
"@php artisan package:discover --ansi"
|
"@php artisan package:discover --ansi"
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Query\Builder;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class FlattenEntityPermissionsTable extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
// Remove entries for non-existing roles (Caused by previous lack of deletion handling)
|
||||||
|
$roleIds = DB::table('roles')->pluck('id');
|
||||||
|
DB::table('entity_permissions')->whereNotIn('role_id', $roleIds)->delete();
|
||||||
|
|
||||||
|
// Create new table structure for entity_permissions
|
||||||
|
Schema::create('new_entity_permissions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedInteger('entity_id');
|
||||||
|
$table->string('entity_type', 25);
|
||||||
|
$table->unsignedInteger('role_id')->index();
|
||||||
|
$table->boolean('view')->default(0);
|
||||||
|
$table->boolean('create')->default(0);
|
||||||
|
$table->boolean('update')->default(0);
|
||||||
|
$table->boolean('delete')->default(0);
|
||||||
|
|
||||||
|
$table->index(['entity_id', 'entity_type']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migrate existing entity_permission data into new table structure
|
||||||
|
|
||||||
|
$subSelect = function (Builder $query, string $action, string $subAlias) {
|
||||||
|
$sub = $query->newQuery()->select('action')->from('entity_permissions', $subAlias)
|
||||||
|
->whereColumn('a.restrictable_id', '=', $subAlias . '.restrictable_id')
|
||||||
|
->whereColumn('a.restrictable_type', '=', $subAlias . '.restrictable_type')
|
||||||
|
->whereColumn('a.role_id', '=', $subAlias . '.role_id')
|
||||||
|
->where($subAlias . '.action', '=', $action);
|
||||||
|
return $query->selectRaw("EXISTS({$sub->toSql()})", $sub->getBindings());
|
||||||
|
};
|
||||||
|
|
||||||
|
$query = DB::table('entity_permissions', 'a')->select([
|
||||||
|
'restrictable_id as entity_id',
|
||||||
|
'restrictable_type as entity_type',
|
||||||
|
'role_id',
|
||||||
|
'view' => fn(Builder $query) => $subSelect($query, 'view', 'b'),
|
||||||
|
'create' => fn(Builder $query) => $subSelect($query, 'create', 'c'),
|
||||||
|
'update' => fn(Builder $query) => $subSelect($query, 'update', 'd'),
|
||||||
|
'delete' => fn(Builder $query) => $subSelect($query, 'delete', 'e'),
|
||||||
|
])->groupBy('restrictable_id', 'restrictable_type', 'role_id');
|
||||||
|
|
||||||
|
DB::table('new_entity_permissions')->insertUsing(['entity_id', 'entity_type', 'role_id', 'view', 'create', 'update', 'delete'], $query);
|
||||||
|
|
||||||
|
// Drop old entity_permissions table and replace with new structure
|
||||||
|
Schema::dropIfExists('entity_permissions');
|
||||||
|
Schema::rename('new_entity_permissions', 'entity_permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
// Create old table structure for entity_permissions
|
||||||
|
Schema::create('old_entity_permissions', function (Blueprint $table) {
|
||||||
|
$table->increments('id');
|
||||||
|
$table->integer('restrictable_id');
|
||||||
|
$table->string('restrictable_type', 191);
|
||||||
|
$table->integer('role_id')->index();
|
||||||
|
$table->string('action', 191)->index();
|
||||||
|
|
||||||
|
$table->index(['restrictable_id', 'restrictable_type']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert newer data format to old data format, and insert into old database
|
||||||
|
|
||||||
|
$actionQuery = function (Builder $query, string $action) {
|
||||||
|
return $query->select([
|
||||||
|
'entity_id as restrictable_id',
|
||||||
|
'entity_type as restrictable_type',
|
||||||
|
'role_id',
|
||||||
|
])->selectRaw("? as action", [$action])
|
||||||
|
->from('entity_permissions')
|
||||||
|
->where($action, '=', true);
|
||||||
|
};
|
||||||
|
|
||||||
|
$query = $actionQuery(DB::query(), 'view')
|
||||||
|
->union(fn(Builder $query) => $actionQuery($query, 'create'))
|
||||||
|
->union(fn(Builder $query) => $actionQuery($query, 'update'))
|
||||||
|
->union(fn(Builder $query) => $actionQuery($query, 'delete'));
|
||||||
|
|
||||||
|
DB::table('old_entity_permissions')->insertUsing(['restrictable_id', 'restrictable_type', 'role_id', 'action'], $query);
|
||||||
|
|
||||||
|
// Drop new entity_permissions table and replace with old structure
|
||||||
|
Schema::dropIfExists('entity_permissions');
|
||||||
|
Schema::rename('old_entity_permissions', 'entity_permissions');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,93 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Query\Builder;
|
||||||
|
use Illuminate\Database\Query\JoinClause;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class DropEntityRestrictedField extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
// Remove entity-permissions on non-restricted entities
|
||||||
|
$deleteInactiveEntityPermissions = function (string $table, string $morphClass) {
|
||||||
|
$permissionIds = DB::table('entity_permissions')->select('entity_permissions.id as id')
|
||||||
|
->join($table, function (JoinClause $join) use ($table, $morphClass) {
|
||||||
|
return $join->where($table . '.restricted', '=', 0)
|
||||||
|
->on($table . '.id', '=', 'entity_permissions.entity_id');
|
||||||
|
})->where('entity_type', '=', $morphClass)
|
||||||
|
->pluck('id');
|
||||||
|
DB::table('entity_permissions')->whereIn('id', $permissionIds)->delete();
|
||||||
|
};
|
||||||
|
$deleteInactiveEntityPermissions('pages', 'page');
|
||||||
|
$deleteInactiveEntityPermissions('chapters', 'chapter');
|
||||||
|
$deleteInactiveEntityPermissions('books', 'book');
|
||||||
|
$deleteInactiveEntityPermissions('bookshelves', 'bookshelf');
|
||||||
|
|
||||||
|
// Migrate restricted=1 entries to new entity_permissions (role_id=0) entries
|
||||||
|
$defaultEntityPermissionGenQuery = function (Builder $query, string $table, string $morphClass) {
|
||||||
|
return $query->select(['id as entity_id'])
|
||||||
|
->selectRaw('? as entity_type', [$morphClass])
|
||||||
|
->selectRaw('? as `role_id`', [0])
|
||||||
|
->selectRaw('? as `view`', [0])
|
||||||
|
->selectRaw('? as `create`', [0])
|
||||||
|
->selectRaw('? as `update`', [0])
|
||||||
|
->selectRaw('? as `delete`', [0])
|
||||||
|
->from($table)
|
||||||
|
->where('restricted', '=', 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
$query = $defaultEntityPermissionGenQuery(DB::query(), 'pages', 'page')
|
||||||
|
->union(fn(Builder $query) => $defaultEntityPermissionGenQuery($query, 'books', 'book'))
|
||||||
|
->union(fn(Builder $query) => $defaultEntityPermissionGenQuery($query, 'chapters', 'chapter'))
|
||||||
|
->union(fn(Builder $query) => $defaultEntityPermissionGenQuery($query, 'bookshelves', 'bookshelf'));
|
||||||
|
|
||||||
|
DB::table('entity_permissions')->insertUsing(['entity_id', 'entity_type', 'role_id', 'view', 'create', 'update', 'delete'], $query);
|
||||||
|
|
||||||
|
// Drop restricted columns
|
||||||
|
$dropRestrictedColumn = fn(Blueprint $table) => $table->dropColumn('restricted');
|
||||||
|
Schema::table('pages', $dropRestrictedColumn);
|
||||||
|
Schema::table('chapters', $dropRestrictedColumn);
|
||||||
|
Schema::table('books', $dropRestrictedColumn);
|
||||||
|
Schema::table('bookshelves', $dropRestrictedColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
// Create restricted columns
|
||||||
|
$createRestrictedColumn = fn(Blueprint $table) => $table->boolean('restricted')->index()->default(0);
|
||||||
|
Schema::table('pages', $createRestrictedColumn);
|
||||||
|
Schema::table('chapters', $createRestrictedColumn);
|
||||||
|
Schema::table('books', $createRestrictedColumn);
|
||||||
|
Schema::table('bookshelves', $createRestrictedColumn);
|
||||||
|
|
||||||
|
// Set restrictions for entities that have a default entity permission assigned
|
||||||
|
// Note: Possible loss of data where default entity permissions have been configured
|
||||||
|
$restrictEntities = function (string $table, string $morphClass) {
|
||||||
|
$toRestrictIds = DB::table('entity_permissions')
|
||||||
|
->where('role_id', '=', 0)
|
||||||
|
->where('entity_type', '=', $morphClass)
|
||||||
|
->pluck('entity_id');
|
||||||
|
DB::table($table)->whereIn('id', $toRestrictIds)->update(['restricted' => true]);
|
||||||
|
};
|
||||||
|
$restrictEntities('pages', 'page');
|
||||||
|
$restrictEntities('chapters', 'chapter');
|
||||||
|
$restrictEntities('books', 'book');
|
||||||
|
$restrictEntities('bookshelves', 'bookshelf');
|
||||||
|
|
||||||
|
// Delete default entity permissions
|
||||||
|
DB::table('entity_permissions')->where('role_id', '=', 0)->delete();
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,6 +17,44 @@
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Admin"
|
"name": "Admin"
|
||||||
},
|
},
|
||||||
|
"contents": [
|
||||||
|
{
|
||||||
|
"id": 50,
|
||||||
|
"name": "Bridge Structures",
|
||||||
|
"slug": "bridge-structures",
|
||||||
|
"book_id": 16,
|
||||||
|
"created_at": "2021-12-19T15:22:11.000000Z",
|
||||||
|
"updated_at": "2021-12-21T19:42:29.000000Z",
|
||||||
|
"url": "https://example.com/books/my-own-book/chapter/bridge-structures",
|
||||||
|
"type": "chapter",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"name": "Building Bridges",
|
||||||
|
"slug": "building-bridges",
|
||||||
|
"book_id": 16,
|
||||||
|
"chapter_id": 50,
|
||||||
|
"draft": false,
|
||||||
|
"template": false,
|
||||||
|
"created_at": "2021-12-19T15:22:11.000000Z",
|
||||||
|
"updated_at": "2022-09-29T13:44:15.000000Z",
|
||||||
|
"url": "https://example.com/books/my-own-book/page/building-bridges"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 43,
|
||||||
|
"name": "Cool Animals",
|
||||||
|
"slug": "cool-animals",
|
||||||
|
"book_id": 16,
|
||||||
|
"chapter_id": 0,
|
||||||
|
"draft": false,
|
||||||
|
"template": false,
|
||||||
|
"created_at": "2021-12-19T18:22:11.000000Z",
|
||||||
|
"updated_at": "2022-07-29T13:44:15.000000Z",
|
||||||
|
"url": "https://example.com/books/my-own-book/page/cool-animals"
|
||||||
|
}
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
{
|
{
|
||||||
"id": 13,
|
"id": 13,
|
||||||
|
@ -28,12 +66,12 @@
|
||||||
"cover": {
|
"cover": {
|
||||||
"id": 452,
|
"id": 452,
|
||||||
"name": "sjovall_m117hUWMu40.jpg",
|
"name": "sjovall_m117hUWMu40.jpg",
|
||||||
"url": "http:\/\/bookstack.local\/uploads\/images\/cover_book\/2020-01\/sjovall_m117hUWMu40.jpg",
|
"url": "https://example.com/uploads/images/cover_book/2020-01/sjovall_m117hUWMu40.jpg",
|
||||||
"created_at": "2020-01-12T14:11:51.000000Z",
|
"created_at": "2020-01-12T14:11:51.000000Z",
|
||||||
"updated_at": "2020-01-12T14:11:51.000000Z",
|
"updated_at": "2020-01-12T14:11:51.000000Z",
|
||||||
"created_by": 1,
|
"created_by": 1,
|
||||||
"updated_by": 1,
|
"updated_by": 1,
|
||||||
"path": "\/uploads\/images\/cover_book\/2020-01\/sjovall_m117hUWMu40.jpg",
|
"path": "/uploads/images/cover_book/2020-01/sjovall_m117hUWMu40.jpg",
|
||||||
"type": "cover_book",
|
"type": "cover_book",
|
||||||
"uploaded_to": 16
|
"uploaded_to": 16
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,99 +0,0 @@
|
||||||
# JavaScript Components
|
|
||||||
|
|
||||||
This document details the format for JavaScript components in BookStack. This is a really simple class-based setup with a few helpers provided.
|
|
||||||
|
|
||||||
#### Defining a Component in JS
|
|
||||||
|
|
||||||
```js
|
|
||||||
class Dropdown {
|
|
||||||
setup() {
|
|
||||||
this.toggle = this.$refs.toggle;
|
|
||||||
this.menu = this.$refs.menu;
|
|
||||||
|
|
||||||
this.speed = parseInt(this.$opts.speed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
All usage of $refs, $manyRefs and $opts should be done at the top of the `setup` function so any requirements can be easily seen.
|
|
||||||
|
|
||||||
#### Using a Component in HTML
|
|
||||||
|
|
||||||
A component is used like so:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div component="dropdown"></div>
|
|
||||||
|
|
||||||
<!-- or, for multiple -->
|
|
||||||
|
|
||||||
<div components="dropdown image-picker"></div>
|
|
||||||
```
|
|
||||||
|
|
||||||
The names will be parsed and new component instance will be created if a matching name is found in the `components/index.js` componentMapping.
|
|
||||||
|
|
||||||
#### Element References
|
|
||||||
|
|
||||||
Within a component you'll often need to refer to other element instances. This can be done like so:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div component="dropdown">
|
|
||||||
<span refs="dropdown@toggle othercomponent@handle">View more</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
You can then access the span element as `this.$refs.toggle` in your component.
|
|
||||||
|
|
||||||
#### Component Options
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div component="dropdown"
|
|
||||||
option:dropdown:delay="500"
|
|
||||||
option:dropdown:show>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Will result with `this.$opts` being:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"delay": "500",
|
|
||||||
"show": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Global Helpers
|
|
||||||
|
|
||||||
There are various global helper libraries which can be used in components:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// HTTP service
|
|
||||||
window.$http.get(url, params);
|
|
||||||
window.$http.post(url, data);
|
|
||||||
window.$http.put(url, data);
|
|
||||||
window.$http.delete(url, data);
|
|
||||||
window.$http.patch(url, data);
|
|
||||||
|
|
||||||
// Global event system
|
|
||||||
// Emit a global event
|
|
||||||
window.$events.emit(eventName, eventData);
|
|
||||||
// Listen to a global event
|
|
||||||
window.$events.listen(eventName, callback);
|
|
||||||
// Show a success message
|
|
||||||
window.$events.success(message);
|
|
||||||
// Show an error message
|
|
||||||
window.$events.error(message);
|
|
||||||
// Show validation errors, if existing, as an error notification
|
|
||||||
window.$events.showValidationErrors(error);
|
|
||||||
|
|
||||||
// Translator
|
|
||||||
// Take the given plural text and count to decide on what plural option
|
|
||||||
// to use, Similar to laravel's trans_choice function but instead
|
|
||||||
// takes the direction directly instead of a translation key.
|
|
||||||
window.trans_plural(translationString, count, replacements);
|
|
||||||
|
|
||||||
// Component System
|
|
||||||
// Parse and initialise any components from the given root el down.
|
|
||||||
window.components.init(rootEl);
|
|
||||||
// Get the first active component of the given name
|
|
||||||
window.components.first(name);
|
|
||||||
```
|
|
|
@ -0,0 +1,98 @@
|
||||||
|
# Development & Testing
|
||||||
|
|
||||||
|
All development on BookStack is currently done on the `development` branch.
|
||||||
|
When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
|
||||||
|
|
||||||
|
* [Node.js](https://nodejs.org/en/) v16.0+
|
||||||
|
|
||||||
|
## Building CSS & JavaScript Assets
|
||||||
|
|
||||||
|
This project uses SASS for CSS development and this is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
# Install NPM Dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build assets for development
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Build and minify assets for production
|
||||||
|
npm run production
|
||||||
|
|
||||||
|
# Build for dev (With sourcemaps) and watch for changes
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
BookStack has many integration tests that use Laravel's built-in testing capabilities which makes use of PHPUnit. There is a `mysql_testing` database defined within the app config which is what is used by PHPUnit. This database is set with the database name, username and password all defined as `bookstack-test`. You will have to create that database and that set of credentials before testing.
|
||||||
|
|
||||||
|
The testing database will also need migrating and seeding beforehand. This can be done by running `composer refresh-test-database`.
|
||||||
|
|
||||||
|
Once done you can run `composer test` in the application root directory to run all tests. Tests can be ran in parallel by running them via `composer t`. This will use Laravel's built-in parallel testing functionality, and attempt to create and seed a database instance for each testing thread. If required these parallel testing instances can be reset, before testing again, by running `composer t-reset`.
|
||||||
|
|
||||||
|
## Code Standards
|
||||||
|
|
||||||
|
PHP code standards are managed by [using PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer).
|
||||||
|
Static analysis is in place using [PHPStan](https://phpstan.org/) & [Larastan](https://github.com/nunomaduro/larastan).
|
||||||
|
The below commands can be used to utilise these tools:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run code linting using PHP_CodeSniffer
|
||||||
|
composer lint
|
||||||
|
|
||||||
|
# As above, but show rule names in output
|
||||||
|
composer lint -- -s
|
||||||
|
|
||||||
|
# Auto-fix formatting & lint issues via PHP_CodeSniffer phpcbf
|
||||||
|
composer format
|
||||||
|
|
||||||
|
# Run static analysis via larastan/phpstan
|
||||||
|
composer check-static
|
||||||
|
```
|
||||||
|
|
||||||
|
If submitting a PR, formatting as per our project standards would help for clarity but don't worry too much about using/understanding these tools as we can always address issues at a later stage when they're picked up by our automated tools.
|
||||||
|
|
||||||
|
## Development using Docker
|
||||||
|
|
||||||
|
This repository ships with a Docker Compose configuration intended for development purposes. It'll build a PHP image with all needed extensions installed and start up a MySQL server and a Node image watching the UI assets.
|
||||||
|
|
||||||
|
To get started, make sure you meet the following requirements:
|
||||||
|
|
||||||
|
- Docker and Docker Compose are installed
|
||||||
|
- Your user is part of the `docker` group
|
||||||
|
|
||||||
|
If all the conditions are met, you can proceed with the following steps:
|
||||||
|
|
||||||
|
1. **Copy `.env.example` to `.env`**, change `APP_KEY` to a random 32 char string and set `APP_ENV` to `local`.
|
||||||
|
2. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
|
||||||
|
3. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
|
||||||
|
4. **Run `docker-compose up`** and wait until the image is built and all database migrations have been done.
|
||||||
|
5. You can now login with `admin@admin.com` and `password` as password on `localhost:8080` (or another port if specified).
|
||||||
|
|
||||||
|
If needed, You'll be able to run any artisan commands via docker-compose like so:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose run app php artisan list
|
||||||
|
```
|
||||||
|
|
||||||
|
The docker-compose setup runs an instance of [MailHog](https://github.com/mailhog/MailHog) and sets environment variables to redirect any BookStack-sent emails to MailHog. You can view this mail via the MailHog web interface on `localhost:8025`. You can change the port MailHog is accessible on by setting a `DEV_MAIL_PORT` environment variable.
|
||||||
|
|
||||||
|
### Running tests
|
||||||
|
|
||||||
|
After starting the general development Docker, migrate & seed the testing database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# This only needs to be done once
|
||||||
|
docker-compose run app php artisan migrate --database=mysql_testing
|
||||||
|
docker-compose run app php artisan db:seed --class=DummyContentSeeder --database=mysql_testing
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the database has been migrated & seeded, you can run the tests like so:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose run app php vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
The docker-compose setup ships with Xdebug, which you can listen to on port 9090.
|
||||||
|
NB : For some editors like Visual Studio Code, you might need to map your workspace folder to the /app folder within the docker container for this to work.
|
|
@ -0,0 +1,138 @@
|
||||||
|
# BookStack JavaScript Code
|
||||||
|
|
||||||
|
BookStack is primarily server-side-rendered, but it uses JavaScript sparingly to drive any required dynamic elements. Most JavaScript is applied via a custom, and very thin, component interface to keep code organised and somewhat reusable.
|
||||||
|
|
||||||
|
JavaScript source code can be found in the `resources/js` directory. This gets bundled and transformed by `esbuild`, ending up in the `public/dist` folder for browser use. Read the [Development > "Building CSS & JavaScript Assets"](development.md#building-css-&-javascript-assets) documentation for details on this process.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
This section details the format for JavaScript components in BookStack. This is a really simple class-based setup with a few helpers provided.
|
||||||
|
|
||||||
|
### Defining a Component in JS
|
||||||
|
|
||||||
|
```js
|
||||||
|
class Dropdown {
|
||||||
|
setup() {
|
||||||
|
this.container = this.$el;
|
||||||
|
this.menu = this.$refs.menu;
|
||||||
|
this.toggles = this.$manyRefs.toggle;
|
||||||
|
|
||||||
|
this.speed = parseInt(this.$opts.speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All usage of $refs, $manyRefs and $opts should be done at the top of the `setup` function so any requirements can be easily seen.
|
||||||
|
|
||||||
|
Once defined, the component has to be registered for use. This is done in the `resources/js/components/index.js` file. You'll need to import the component class then add it to `componentMapping` object, following the pattern of other components.
|
||||||
|
|
||||||
|
### Using a Component in HTML
|
||||||
|
|
||||||
|
A component is used like so:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div component="dropdown"></div>
|
||||||
|
|
||||||
|
<!-- or, for multiple -->
|
||||||
|
|
||||||
|
<div components="dropdown image-picker"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The names will be parsed and new component instance will be created if a matching name is found in the `components/index.js` componentMapping.
|
||||||
|
|
||||||
|
### Element References
|
||||||
|
|
||||||
|
Within a component you'll often need to refer to other element instances. This can be done like so:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div component="dropdown">
|
||||||
|
<span refs="dropdown@toggle othercomponent@handle">View more</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
You can then access the span element as `this.$refs.toggle` in your component.
|
||||||
|
|
||||||
|
Multiple elements of the same reference name can be accessed via a `this.$manyRefs` property within your component. For example, all the buttons in the below example could be accessed via `this.$manyRefs.buttons`.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div component="list">
|
||||||
|
<button refs="list@button">Click here</button>
|
||||||
|
<button refs="list@button">No, Click here</button>
|
||||||
|
<button refs="list@button">This button is better</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Options
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div component="dropdown"
|
||||||
|
option:dropdown:delay="500"
|
||||||
|
option:dropdown:show>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Will result with `this.$opts` being:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"delay": "500",
|
||||||
|
"show": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Component Properties
|
||||||
|
|
||||||
|
A component has the below shown properties available for use. As mentioned above, most of these should be used within the `setup()` function to make the requirements/dependencies of the component clear.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// The root element that the compontent has been applied to.
|
||||||
|
this.$el
|
||||||
|
|
||||||
|
// A map of defined element references within the compontent.
|
||||||
|
// See "Element References" above.
|
||||||
|
this.$refs
|
||||||
|
|
||||||
|
// A map of defined multi-element references within the compontent.
|
||||||
|
// See "Element References" above.
|
||||||
|
this.$manyRefs
|
||||||
|
|
||||||
|
// Options defined for the compontent.
|
||||||
|
this.$opts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Global JavaScript Helpers
|
||||||
|
|
||||||
|
There are various global helper libraries in BookStack which can be accessed via the `window`. The below provides an overview of what's available.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// HTTP service
|
||||||
|
window.$http.get(url, params);
|
||||||
|
window.$http.post(url, data);
|
||||||
|
window.$http.put(url, data);
|
||||||
|
window.$http.delete(url, data);
|
||||||
|
window.$http.patch(url, data);
|
||||||
|
|
||||||
|
// Global event system
|
||||||
|
// Emit a global event
|
||||||
|
window.$events.emit(eventName, eventData);
|
||||||
|
// Listen to a global event
|
||||||
|
window.$events.listen(eventName, callback);
|
||||||
|
// Show a success message
|
||||||
|
window.$events.success(message);
|
||||||
|
// Show an error message
|
||||||
|
window.$events.error(message);
|
||||||
|
// Show validation errors, if existing, as an error notification
|
||||||
|
window.$events.showValidationErrors(error);
|
||||||
|
|
||||||
|
// Translator
|
||||||
|
// Take the given plural text and count to decide on what plural option
|
||||||
|
// to use, Similar to laravel's trans_choice function but instead
|
||||||
|
// takes the direction directly instead of a translation key.
|
||||||
|
window.trans_plural(translationString, count, replacements);
|
||||||
|
|
||||||
|
// Component System
|
||||||
|
// Parse and initialise any components from the given root el down.
|
||||||
|
window.components.init(rootEl);
|
||||||
|
// Get the first active component of the given name
|
||||||
|
window.components.first(name);
|
||||||
|
```
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Release Versioning & Process
|
||||||
|
|
||||||
|
### BookStack Version Number Scheme
|
||||||
|
|
||||||
|
BookStack releases are each assigned a date-based version number in the format `v<year>.<month>[.<optional_patch_number>]`. For example:
|
||||||
|
|
||||||
|
- `v20.12` - New feature released launched during December 2020.
|
||||||
|
- `v21.06.2` - Second patch release upon the June 2021 feature release.
|
||||||
|
|
||||||
|
Patch releases are generally fairly minor, primarily intended for fixes and therefore are fairly unlikely to cause breakages upon update.
|
||||||
|
Feature releases are generally larger, bringing new features in addition to fixes and enhancements. These releases have a greater chance of introducing breaking changes upon update, so it's worth checking for any notes in the [update guide](https://www.bookstackapp.com/docs/admin/updates/).
|
||||||
|
|
||||||
|
### Release Planning Process
|
||||||
|
|
||||||
|
Each BookStack release will have a [milestone](https://github.com/BookStackApp/BookStack/milestones) created with issues & pull requests assigned to it to define what will be in that release. Milestones are built up then worked through until complete at which point, after some testing and documentation updates, the release will be deployed.
|
||||||
|
|
||||||
|
### Release Announcements
|
||||||
|
|
||||||
|
Feature releases, and some patch releases, will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blog posts (once per week maximum) [at this link](https://updates.bookstackapp.com/signup/bookstack-news-and-updates).
|
||||||
|
|
||||||
|
### Release Technical Process
|
||||||
|
|
||||||
|
Deploying a release, at a high level, simply involves merging the development branch into the release branch before then building & committing any release-only assets.
|
||||||
|
A helper script [can be found in our](https://github.com/BookStackApp/devops/blob/main/meta-scripts/bookstack-release-steps) devops repo which provides the steps and commands for deploying a new release.
|
127
readme.md
127
readme.md
|
@ -59,131 +59,20 @@ Note: Listed services are not tested, vetted nor supported by the official BookS
|
||||||
|
|
||||||
## 🛣️ Road Map
|
## 🛣️ Road Map
|
||||||
|
|
||||||
Below is a high-level road map view for BookStack to provide a sense of direction of where the project is going. This can change at any point and does not reflect many features and improvements that will also be included as part of the journey along this road map. For more granular detail of what will be included in upcoming releases you can review the project milestones as defined in the "Release Process" section below.
|
Below is a high-level road map view for BookStack to provide a sense of direction of where the project is going. This can change at any point and does not reflect many features and improvements that will also be included as part of the journey along this road map. For more granular detail of what will be included in upcoming releases you can review the project milestones as defined in our [Release Process](dev/docs/release-process.md) documentation.
|
||||||
|
|
||||||
- **Platform REST API** - *(Most actions implemented, maturing)*
|
- **Platform REST API** - *(Most actions implemented, maturing)*
|
||||||
- *A REST API covering, at minimum, control of core content models (Books, Chapters, Pages) for automation and platform extension.*
|
- *A REST API covering, at minimum, control of core content models (Books, Chapters, Pages) for automation and platform extension.*
|
||||||
- **Editor Alignment & Review** - *(Done)*
|
|
||||||
- *Review the page editors with the goal of achieving increased interoperability & feature parity while also considering collaborative editing potential.*
|
|
||||||
- **Permission System Review** - *(In Progress)*
|
- **Permission System Review** - *(In Progress)*
|
||||||
- *Improvement in how permissions are applied and a review of the efficiency of the permission & roles system.*
|
- *Improvement in how permissions are applied and a review of the efficiency of the permission & roles system.*
|
||||||
- **Installation & Deployment Process Revamp**
|
|
||||||
- *Creation of a streamlined & secure process for users to deploy & update BookStack with reduced development requirements (No git or composer requirement).*
|
|
||||||
|
|
||||||
## 🚀 Release Versioning & Process
|
|
||||||
|
|
||||||
BookStack releases are each assigned a date-based version number in the format `v<year>.<month>[.<optional_patch_number>]`. For example:
|
|
||||||
|
|
||||||
- `v20.12` - New feature released launched during December 2020.
|
|
||||||
- `v21.06.2` - Second patch release upon the June 2021 feature release.
|
|
||||||
|
|
||||||
Patch releases are generally fairly minor, primarily intended for fixes and therefore are fairly unlikely to cause breakages upon update.
|
|
||||||
Feature releases are generally larger, bringing new features in addition to fixes and enhancements. These releases have a greater chance of introducing breaking changes upon update, so it's worth checking for any notes in the [update guide](https://www.bookstackapp.com/docs/admin/updates/).
|
|
||||||
|
|
||||||
Each BookStack release will have a [milestone](https://github.com/BookStackApp/BookStack/milestones) created with issues & pull requests assigned to it to define what will be in that release. Milestones are built up then worked through until complete at which point, after some testing and documentation updates, the release will be deployed.
|
|
||||||
|
|
||||||
Feature releases, and some patch releases, will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blog posts (once per week maximum) [at this link](https://updates.bookstackapp.com/signup/bookstack-news-and-updates).
|
|
||||||
|
|
||||||
## 🛠️ Development & Testing
|
## 🛠️ Development & Testing
|
||||||
|
|
||||||
All development on BookStack is currently done on the `development` branch. When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
|
Please see our [development docs](dev/docs/development.md) for full details regarding work on the BookStack source code.
|
||||||
|
|
||||||
* [Node.js](https://nodejs.org/en/) v14.0+
|
If you're just looking to customize or extend your own BookStack instance, take a look at our [Hacking BookStack documentation page](https://www.bookstackapp.com/docs/admin/hacking-bookstack/) for details on various options to achieve this without altering the BookStack source code.
|
||||||
|
|
||||||
This project uses SASS for CSS development and this is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks:
|
Details about BookStack's versioning scheme and the general release process [can be found here](dev/docs/release-process.md).
|
||||||
|
|
||||||
``` bash
|
|
||||||
# Install NPM Dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Build assets for development
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Build and minify assets for production
|
|
||||||
npm run production
|
|
||||||
|
|
||||||
# Build for dev (With sourcemaps) and watch for changes
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
BookStack has many integration tests that use Laravel's built-in testing capabilities which makes use of PHPUnit. There is a `mysql_testing` database defined within the app config which is what is used by PHPUnit. This database is set with the database name, user name and password all defined as `bookstack-test`. You will have to create that database and that set of credentials before testing.
|
|
||||||
|
|
||||||
The testing database will also need migrating and seeding beforehand. This can be done with the following commands:
|
|
||||||
|
|
||||||
``` bash
|
|
||||||
php artisan migrate --database=mysql_testing
|
|
||||||
php artisan db:seed --class=DummyContentSeeder --database=mysql_testing
|
|
||||||
```
|
|
||||||
|
|
||||||
Once done you can run `composer test` in the application root directory to run all tests.
|
|
||||||
|
|
||||||
### 📜 Code Standards
|
|
||||||
|
|
||||||
PHP code standards are managed by [using PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer).
|
|
||||||
Static analysis is in place using [PHPStan](https://phpstan.org/) & [Larastan](https://github.com/nunomaduro/larastan).
|
|
||||||
The below commands can be used to utilise these tools:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run code linting using PHP_CodeSniffer
|
|
||||||
composer lint
|
|
||||||
|
|
||||||
# As above, but show rule names in output
|
|
||||||
composer lint -- -s
|
|
||||||
|
|
||||||
# Auto-fix formatting & lint issues via PHP_CodeSniffer phpcbf
|
|
||||||
composer format
|
|
||||||
|
|
||||||
# Run static analysis via larastan/phpstan
|
|
||||||
composer check-static
|
|
||||||
```
|
|
||||||
|
|
||||||
If submitting a PR, formatting as per our project standards would help for clarity but don't worry too much about using/understanding these tools as we can always address issues at a later stage when they're picked up by our automated tools.
|
|
||||||
|
|
||||||
### 🐋 Development using Docker
|
|
||||||
|
|
||||||
This repository ships with a Docker Compose configuration intended for development purposes. It'll build a PHP image with all needed extensions installed and start up a MySQL server and a Node image watching the UI assets.
|
|
||||||
|
|
||||||
To get started, make sure you meet the following requirements:
|
|
||||||
|
|
||||||
- Docker and Docker Compose are installed
|
|
||||||
- Your user is part of the `docker` group
|
|
||||||
|
|
||||||
If all the conditions are met, you can proceed with the following steps:
|
|
||||||
|
|
||||||
1. **Copy `.env.example` to `.env`**, change `APP_KEY` to a random 32 char string and set `APP_ENV` to `local`.
|
|
||||||
2. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
|
|
||||||
3. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
|
|
||||||
4. **Run `docker-compose up`** and wait until the image is built and all database migrations have been done.
|
|
||||||
5. You can now login with `admin@admin.com` and `password` as password on `localhost:8080` (or another port if specified).
|
|
||||||
|
|
||||||
If needed, You'll be able to run any artisan commands via docker-compose like so:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose run app php artisan list
|
|
||||||
```
|
|
||||||
|
|
||||||
The docker-compose setup runs an instance of [MailHog](https://github.com/mailhog/MailHog) and sets environment variables to redirect any BookStack-sent emails to MailHog. You can view this mail via the MailHog web interface on `localhost:8025`. You can change the port MailHog is accessible on by setting a `DEV_MAIL_PORT` environment variable.
|
|
||||||
|
|
||||||
#### Running tests
|
|
||||||
|
|
||||||
After starting the general development Docker, migrate & seed the testing database:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# This only needs to be done once
|
|
||||||
docker-compose run app php artisan migrate --database=mysql_testing
|
|
||||||
docker-compose run app php artisan db:seed --class=DummyContentSeeder --database=mysql_testing
|
|
||||||
```
|
|
||||||
|
|
||||||
Once the database has been migrated & seeded, you can run the tests like so:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose run app php vendor/bin/phpunit
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Debugging
|
|
||||||
|
|
||||||
The docker-compose setup ships with Xdebug, which you can listen to on port 9090.
|
|
||||||
NB : For some editors like Visual Studio Code, you might need to map your workspace folder to the /app folder within the docker container for this to work.
|
|
||||||
|
|
||||||
## 🌎 Translations
|
## 🌎 Translations
|
||||||
|
|
||||||
|
@ -217,20 +106,18 @@ We want BookStack to remain accessible to as many people as possible. We aim for
|
||||||
|
|
||||||
## 🖥️ Website, Docs & Blog
|
## 🖥️ Website, Docs & Blog
|
||||||
|
|
||||||
The website which contains the project docs & Blog can be found in the [BookStackApp/website](https://github.com/BookStackApp/website) repo.
|
The website which contains the project docs & blog can be found in the [BookStackApp/website](https://github.com/BookStackApp/website) repo.
|
||||||
|
|
||||||
## ⚖️ License
|
## ⚖️ License
|
||||||
|
|
||||||
The BookStack source is provided under the MIT License.
|
The BookStack source is provided under the [MIT License](https://github.com/BookStackApp/BookStack/blob/development/LICENSE).
|
||||||
|
|
||||||
The libraries used by, and included with, BookStack are provided under their own licenses and copyright.
|
The libraries used by, and included with, BookStack are provided under their own licenses and copyright.
|
||||||
The licenses for many of our core dependencies can be found in the attribution list below but this is not an exhaustive list of all projects used within BookStack.
|
The licenses for many of our core dependencies can be found in the attribution list below but this is not an exhaustive list of all projects used within BookStack.
|
||||||
|
|
||||||
## 👪 Attribution
|
## 👪 Attribution
|
||||||
|
|
||||||
The great people that have worked to build and improve BookStack can [be seen here](https://github.com/BookStackApp/BookStack/graphs/contributors).
|
The great people that have worked to build and improve BookStack can [be seen here](https://github.com/BookStackApp/BookStack/graphs/contributors). The wonderful people that have provided translations, either through GitHub or via Crowdin [can be seen here](https://github.com/BookStackApp/BookStack/blob/development/.github/translators.txt).
|
||||||
|
|
||||||
The wonderful people that have provided translations, either through GitHub or via Crowdin [can be seen here](https://github.com/BookStackApp/BookStack/blob/development/.github/translators.txt).
|
|
||||||
|
|
||||||
Below are the great open-source projects used to help build BookStack.
|
Below are the great open-source projects used to help build BookStack.
|
||||||
Note: This is not an exhaustive list of all libraries and projects that would be used in an active BookStack instance.
|
Note: This is not an exhaustive list of all libraries and projects that would be used in an active BookStack instance.
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px"><g><path d="M12,12.75c1.63,0,3.07,0.39,4.24,0.9c1.08,0.48,1.76,1.56,1.76,2.73L18,17c0,0.55-0.45,1-1,1H7c-0.55,0-1-0.45-1-1l0-0.61 c0-1.18,0.68-2.26,1.76-2.73C8.93,13.14,10.37,12.75,12,12.75z M4,13c1.1,0,2-0.9,2-2c0-1.1-0.9-2-2-2s-2,0.9-2,2 C2,12.1,2.9,13,4,13z M5.13,14.1C4.76,14.04,4.39,14,4,14c-0.99,0-1.93,0.21-2.78,0.58C0.48,14.9,0,15.62,0,16.43L0,17 c0,0.55,0.45,1,1,1l3.5,0v-1.61C4.5,15.56,4.73,14.78,5.13,14.1z M20,13c1.1,0,2-0.9,2-2c0-1.1-0.9-2-2-2s-2,0.9-2,2 C18,12.1,18.9,13,20,13z M24,16.43c0-0.81-0.48-1.53-1.22-1.85C21.93,14.21,20.99,14,20,14c-0.39,0-0.76,0.04-1.13,0.1 c0.4,0.68,0.63,1.46,0.63,2.29V18l3.5,0c0.55,0,1-0.45,1-1L24,16.43z M12,6c1.66,0,3,1.34,3,3c0,1.66-1.34,3-3,3s-3-1.34-3-3 C9,7.34,10.34,6,12,6z"/></g></svg>
|
After Width: | Height: | Size: 811 B |
|
@ -0,0 +1,4 @@
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 0h24v24H0z" fill="none"/>
|
||||||
|
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 415 B |
|
@ -15,6 +15,7 @@ import 'codemirror/mode/lua/lua';
|
||||||
import 'codemirror/mode/markdown/markdown';
|
import 'codemirror/mode/markdown/markdown';
|
||||||
import 'codemirror/mode/mllike/mllike';
|
import 'codemirror/mode/mllike/mllike';
|
||||||
import 'codemirror/mode/nginx/nginx';
|
import 'codemirror/mode/nginx/nginx';
|
||||||
|
import 'codemirror/mode/octave/octave';
|
||||||
import 'codemirror/mode/perl/perl';
|
import 'codemirror/mode/perl/perl';
|
||||||
import 'codemirror/mode/pascal/pascal';
|
import 'codemirror/mode/pascal/pascal';
|
||||||
import 'codemirror/mode/php/php';
|
import 'codemirror/mode/php/php';
|
||||||
|
@ -65,11 +66,13 @@ const modeMap = {
|
||||||
julia: 'text/x-julia',
|
julia: 'text/x-julia',
|
||||||
latex: 'text/x-stex',
|
latex: 'text/x-stex',
|
||||||
lua: 'lua',
|
lua: 'lua',
|
||||||
|
matlab: 'text/x-octave',
|
||||||
md: 'markdown',
|
md: 'markdown',
|
||||||
mdown: 'markdown',
|
mdown: 'markdown',
|
||||||
markdown: 'markdown',
|
markdown: 'markdown',
|
||||||
ml: 'mllike',
|
ml: 'mllike',
|
||||||
nginx: 'nginx',
|
nginx: 'nginx',
|
||||||
|
octave: 'text/x-octave',
|
||||||
perl: 'perl',
|
perl: 'perl',
|
||||||
pl: 'perl',
|
pl: 'perl',
|
||||||
powershell: 'powershell',
|
powershell: 'powershell',
|
||||||
|
|
|
@ -88,14 +88,12 @@ class AutoSuggest {
|
||||||
}
|
}
|
||||||
|
|
||||||
const nameFilter = this.getNameFilterIfNeeded();
|
const nameFilter = this.getNameFilterIfNeeded();
|
||||||
const search = this.input.value.slice(0, 3).toLowerCase();
|
const search = this.input.value.toLowerCase();
|
||||||
const suggestions = await this.loadSuggestions(search, nameFilter);
|
const suggestions = await this.loadSuggestions(search, nameFilter);
|
||||||
let toShow = suggestions.slice(0, 6);
|
|
||||||
if (search.length > 0) {
|
const toShow = suggestions.filter(val => {
|
||||||
toShow = suggestions.filter(val => {
|
return search === '' || val.toLowerCase().startsWith(search);
|
||||||
return val.toLowerCase().includes(search);
|
}).slice(0, 10);
|
||||||
}).slice(0, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.displaySuggestions(toShow);
|
this.displaySuggestions(toShow);
|
||||||
}
|
}
|
||||||
|
@ -111,6 +109,9 @@ class AutoSuggest {
|
||||||
* @returns {Promise<Object|String|*>}
|
* @returns {Promise<Object|String|*>}
|
||||||
*/
|
*/
|
||||||
async loadSuggestions(search, nameFilter = null) {
|
async loadSuggestions(search, nameFilter = null) {
|
||||||
|
// Truncate search to prevent over numerous lookups
|
||||||
|
search = search.slice(0, 4);
|
||||||
|
|
||||||
const params = {search, name: nameFilter};
|
const params = {search, name: nameFilter};
|
||||||
const cacheKey = `${this.url}:${JSON.stringify(params)}`;
|
const cacheKey = `${this.url}:${JSON.stringify(params)}`;
|
||||||
|
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
|
|
||||||
class EntityPermissionsEditor {
|
|
||||||
|
|
||||||
constructor(elem) {
|
|
||||||
this.permissionsTable = elem.querySelector('[permissions-table]');
|
|
||||||
|
|
||||||
// Handle toggle all event
|
|
||||||
this.restrictedCheckbox = elem.querySelector('[name=restricted]');
|
|
||||||
this.restrictedCheckbox.addEventListener('change', this.updateTableVisibility.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTableVisibility() {
|
|
||||||
this.permissionsTable.style.display =
|
|
||||||
this.restrictedCheckbox.checked
|
|
||||||
? null
|
|
||||||
: 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EntityPermissionsEditor;
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* @extends {Component}
|
||||||
|
*/
|
||||||
|
import {htmlToDom} from "../services/dom";
|
||||||
|
|
||||||
|
class EntityPermissions {
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.container = this.$el;
|
||||||
|
this.entityType = this.$opts.entityType;
|
||||||
|
|
||||||
|
this.everyoneInheritToggle = this.$refs.everyoneInherit;
|
||||||
|
this.roleSelect = this.$refs.roleSelect;
|
||||||
|
this.roleContainer = this.$refs.roleContainer;
|
||||||
|
|
||||||
|
this.setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupListeners() {
|
||||||
|
// "Everyone Else" inherit toggle
|
||||||
|
this.everyoneInheritToggle.addEventListener('change', event => {
|
||||||
|
const inherit = event.target.checked;
|
||||||
|
const permissions = document.querySelectorAll('input[name^="permissions[0]["]');
|
||||||
|
for (const permission of permissions) {
|
||||||
|
permission.disabled = inherit;
|
||||||
|
permission.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove role row button click
|
||||||
|
this.container.addEventListener('click', event => {
|
||||||
|
const button = event.target.closest('button');
|
||||||
|
if (button && button.dataset.roleId) {
|
||||||
|
this.removeRowOnButtonClick(button)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Role select change
|
||||||
|
this.roleSelect.addEventListener('change', event => {
|
||||||
|
const roleId = this.roleSelect.value;
|
||||||
|
if (roleId) {
|
||||||
|
this.addRoleRow(roleId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addRoleRow(roleId) {
|
||||||
|
this.roleSelect.disabled = true;
|
||||||
|
|
||||||
|
// Remove option from select
|
||||||
|
const option = this.roleSelect.querySelector(`option[value="${roleId}"]`);
|
||||||
|
if (option) {
|
||||||
|
option.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get and insert new row
|
||||||
|
const resp = await window.$http.get(`/permissions/form-row/${this.entityType}/${roleId}`);
|
||||||
|
const row = htmlToDom(resp.data);
|
||||||
|
this.roleContainer.append(row);
|
||||||
|
|
||||||
|
this.roleSelect.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRowOnButtonClick(button) {
|
||||||
|
const row = button.closest('.content-permissions-row');
|
||||||
|
const roleId = button.dataset.roleId;
|
||||||
|
const roleName = button.dataset.roleName;
|
||||||
|
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = roleId;
|
||||||
|
option.textContent = roleName;
|
||||||
|
|
||||||
|
this.roleSelect.append(option);
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EntityPermissions;
|
|
@ -18,7 +18,7 @@ import dropdown from "./dropdown.js"
|
||||||
import dropdownSearch from "./dropdown-search.js"
|
import dropdownSearch from "./dropdown-search.js"
|
||||||
import dropzone from "./dropzone.js"
|
import dropzone from "./dropzone.js"
|
||||||
import editorToolbox from "./editor-toolbox.js"
|
import editorToolbox from "./editor-toolbox.js"
|
||||||
import entityPermissionsEditor from "./entity-permissions-editor.js"
|
import entityPermissions from "./entity-permissions";
|
||||||
import entitySearch from "./entity-search.js"
|
import entitySearch from "./entity-search.js"
|
||||||
import entitySelector from "./entity-selector.js"
|
import entitySelector from "./entity-selector.js"
|
||||||
import entitySelectorPopup from "./entity-selector-popup.js"
|
import entitySelectorPopup from "./entity-selector-popup.js"
|
||||||
|
@ -38,6 +38,7 @@ import pageDisplay from "./page-display.js"
|
||||||
import pageEditor from "./page-editor.js"
|
import pageEditor from "./page-editor.js"
|
||||||
import pagePicker from "./page-picker.js"
|
import pagePicker from "./page-picker.js"
|
||||||
import permissionsTable from "./permissions-table.js"
|
import permissionsTable from "./permissions-table.js"
|
||||||
|
import pointer from "./pointer.js";
|
||||||
import popup from "./popup.js"
|
import popup from "./popup.js"
|
||||||
import settingAppColorPicker from "./setting-app-color-picker.js"
|
import settingAppColorPicker from "./setting-app-color-picker.js"
|
||||||
import settingColorPicker from "./setting-color-picker.js"
|
import settingColorPicker from "./setting-color-picker.js"
|
||||||
|
@ -75,7 +76,7 @@ const componentMapping = {
|
||||||
"dropdown-search": dropdownSearch,
|
"dropdown-search": dropdownSearch,
|
||||||
"dropzone": dropzone,
|
"dropzone": dropzone,
|
||||||
"editor-toolbox": editorToolbox,
|
"editor-toolbox": editorToolbox,
|
||||||
"entity-permissions-editor": entityPermissionsEditor,
|
"entity-permissions": entityPermissions,
|
||||||
"entity-search": entitySearch,
|
"entity-search": entitySearch,
|
||||||
"entity-selector": entitySelector,
|
"entity-selector": entitySelector,
|
||||||
"entity-selector-popup": entitySelectorPopup,
|
"entity-selector-popup": entitySelectorPopup,
|
||||||
|
@ -95,6 +96,7 @@ const componentMapping = {
|
||||||
"page-editor": pageEditor,
|
"page-editor": pageEditor,
|
||||||
"page-picker": pagePicker,
|
"page-picker": pagePicker,
|
||||||
"permissions-table": permissionsTable,
|
"permissions-table": permissionsTable,
|
||||||
|
"pointer": pointer,
|
||||||
"popup": popup,
|
"popup": popup,
|
||||||
"setting-app-color-picker": settingAppColorPicker,
|
"setting-app-color-picker": settingAppColorPicker,
|
||||||
"setting-color-picker": settingColorPicker,
|
"setting-color-picker": settingColorPicker,
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import Clipboard from "clipboard/dist/clipboard.min";
|
|
||||||
import * as DOM from "../services/dom";
|
import * as DOM from "../services/dom";
|
||||||
import {scrollAndHighlightElement} from "../services/util";
|
import {scrollAndHighlightElement} from "../services/util";
|
||||||
|
|
||||||
|
@ -9,7 +8,6 @@ class PageDisplay {
|
||||||
this.pageId = elem.getAttribute('page-display');
|
this.pageId = elem.getAttribute('page-display');
|
||||||
|
|
||||||
window.importVersioned('code').then(Code => Code.highlight());
|
window.importVersioned('code').then(Code => Code.highlight());
|
||||||
this.setupPointer();
|
|
||||||
this.setupNavHighlighting();
|
this.setupNavHighlighting();
|
||||||
this.setupDetailsCodeBlockRefresh();
|
this.setupDetailsCodeBlockRefresh();
|
||||||
|
|
||||||
|
@ -50,108 +48,6 @@ class PageDisplay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupPointer() {
|
|
||||||
let pointer = document.getElementById('pointer');
|
|
||||||
if (!pointer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up pointer
|
|
||||||
pointer = pointer.parentNode.removeChild(pointer);
|
|
||||||
const pointerInner = pointer.querySelector('div.pointer');
|
|
||||||
|
|
||||||
// Instance variables
|
|
||||||
let pointerShowing = false;
|
|
||||||
let isSelection = false;
|
|
||||||
let pointerModeLink = true;
|
|
||||||
let pointerSectionId = '';
|
|
||||||
|
|
||||||
// Select all contents on input click
|
|
||||||
DOM.onChildEvent(pointer, 'input', 'click', (event, input) => {
|
|
||||||
input.select();
|
|
||||||
event.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent closing pointer when clicked or focused
|
|
||||||
DOM.onEvents(pointer, ['click', 'focus'], event => {
|
|
||||||
event.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pointer mode toggle
|
|
||||||
DOM.onChildEvent(pointer, 'span.icon', 'click', (event, icon) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
pointerModeLink = !pointerModeLink;
|
|
||||||
icon.querySelector('[data-icon="include"]').style.display = (!pointerModeLink) ? 'inline' : 'none';
|
|
||||||
icon.querySelector('[data-icon="link"]').style.display = (pointerModeLink) ? 'inline' : 'none';
|
|
||||||
updatePointerContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up clipboard
|
|
||||||
new Clipboard(pointer.querySelector('button'));
|
|
||||||
|
|
||||||
// Hide pointer when clicking away
|
|
||||||
DOM.onEvents(document.body, ['click', 'focus'], event => {
|
|
||||||
if (!pointerShowing || isSelection) return;
|
|
||||||
pointer = pointer.parentElement.removeChild(pointer);
|
|
||||||
pointerShowing = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
let updatePointerContent = (element) => {
|
|
||||||
let inputText = pointerModeLink ? window.baseUrl(`/link/${this.pageId}#${pointerSectionId}`) : `{{@${this.pageId}#${pointerSectionId}}}`;
|
|
||||||
if (pointerModeLink && !inputText.startsWith('http')) {
|
|
||||||
inputText = window.location.protocol + "//" + window.location.host + inputText;
|
|
||||||
}
|
|
||||||
|
|
||||||
pointer.querySelector('input').value = inputText;
|
|
||||||
|
|
||||||
// Update anchor if present
|
|
||||||
const editAnchor = pointer.querySelector('#pointer-edit');
|
|
||||||
if (editAnchor && element) {
|
|
||||||
const editHref = editAnchor.dataset.editHref;
|
|
||||||
const elementId = element.id;
|
|
||||||
|
|
||||||
// get the first 50 characters.
|
|
||||||
const queryContent = element.textContent && element.textContent.substring(0, 50);
|
|
||||||
editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show pointer when selecting a single block of tagged content
|
|
||||||
DOM.forEach('.page-content [id^="bkmrk"]', bookMarkElem => {
|
|
||||||
DOM.onEvents(bookMarkElem, ['mouseup', 'keyup'], event => {
|
|
||||||
event.stopPropagation();
|
|
||||||
let selection = window.getSelection();
|
|
||||||
if (selection.toString().length === 0) return;
|
|
||||||
|
|
||||||
// Show pointer and set link
|
|
||||||
pointerSectionId = bookMarkElem.id;
|
|
||||||
updatePointerContent(bookMarkElem);
|
|
||||||
|
|
||||||
bookMarkElem.parentNode.insertBefore(pointer, bookMarkElem);
|
|
||||||
pointer.style.display = 'block';
|
|
||||||
pointerShowing = true;
|
|
||||||
isSelection = true;
|
|
||||||
|
|
||||||
// Set pointer to sit near mouse-up position
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const bookMarkBounds = bookMarkElem.getBoundingClientRect();
|
|
||||||
let pointerLeftOffset = (event.pageX - bookMarkBounds.left - 164);
|
|
||||||
if (pointerLeftOffset < 0) {
|
|
||||||
pointerLeftOffset = 0
|
|
||||||
}
|
|
||||||
const pointerLeftOffsetPercent = (pointerLeftOffset / bookMarkBounds.width) * 100;
|
|
||||||
|
|
||||||
pointerInner.style.left = pointerLeftOffsetPercent + '%';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
isSelection = false;
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setupNavHighlighting() {
|
setupNavHighlighting() {
|
||||||
// Check if support is present for IntersectionObserver
|
// Check if support is present for IntersectionObserver
|
||||||
if (!('IntersectionObserver' in window) ||
|
if (!('IntersectionObserver' in window) ||
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
|
|
||||||
class PermissionsTable {
|
class PermissionsTable {
|
||||||
|
|
||||||
constructor(elem) {
|
setup() {
|
||||||
this.container = elem;
|
this.container = this.$el;
|
||||||
|
|
||||||
// Handle toggle all event
|
// Handle toggle all event
|
||||||
const toggleAll = elem.querySelector('[permissions-table-toggle-all]');
|
for (const toggleAllElem of (this.$manyRefs.toggleAll || [])) {
|
||||||
toggleAll.addEventListener('click', this.toggleAllClick.bind(this));
|
toggleAllElem.addEventListener('click', this.toggleAllClick.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
// Handle toggle row event
|
// Handle toggle row event
|
||||||
const toggleRowElems = elem.querySelectorAll('[permissions-table-toggle-all-in-row]');
|
for (const toggleRowElem of (this.$manyRefs.toggleRow || [])) {
|
||||||
for (let toggleRowElem of toggleRowElems) {
|
|
||||||
toggleRowElem.addEventListener('click', this.toggleRowClick.bind(this));
|
toggleRowElem.addEventListener('click', this.toggleRowClick.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle toggle column event
|
// Handle toggle column event
|
||||||
const toggleColumnElems = elem.querySelectorAll('[permissions-table-toggle-all-in-column]');
|
for (const toggleColElem of (this.$manyRefs.toggleColumn || [])) {
|
||||||
for (let toggleColElem of toggleColumnElems) {
|
|
||||||
toggleColElem.addEventListener('click', this.toggleColumnClick.bind(this));
|
toggleColElem.addEventListener('click', this.toggleColumnClick.bind(this));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,131 @@
|
||||||
|
import * as DOM from "../services/dom";
|
||||||
|
import Clipboard from "clipboard/dist/clipboard.min";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Component
|
||||||
|
*/
|
||||||
|
class Pointer {
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.container = this.$el;
|
||||||
|
this.pageId = this.$opts.pageId;
|
||||||
|
|
||||||
|
// Instance variables
|
||||||
|
this.showing = false;
|
||||||
|
this.isSelection = false;
|
||||||
|
this.pointerModeLink = true;
|
||||||
|
this.pointerSectionId = '';
|
||||||
|
|
||||||
|
this.setupListeners();
|
||||||
|
|
||||||
|
// Set up clipboard
|
||||||
|
new Clipboard(this.container.querySelector('button'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setupListeners() {
|
||||||
|
// Select all contents on input click
|
||||||
|
DOM.onChildEvent(this.container, 'input', 'click', (event, input) => {
|
||||||
|
input.select();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent closing pointer when clicked or focused
|
||||||
|
DOM.onEvents(this.container, ['click', 'focus'], event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pointer mode toggle
|
||||||
|
DOM.onChildEvent(this.container, 'span.icon', 'click', (event, icon) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.pointerModeLink = !this.pointerModeLink;
|
||||||
|
icon.querySelector('[data-icon="include"]').style.display = (!this.pointerModeLink) ? 'inline' : 'none';
|
||||||
|
icon.querySelector('[data-icon="link"]').style.display = (this.pointerModeLink) ? 'inline' : 'none';
|
||||||
|
this.updateForTarget();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide pointer when clicking away
|
||||||
|
DOM.onEvents(document.body, ['click', 'focus'], event => {
|
||||||
|
if (!this.showing || this.isSelection) return;
|
||||||
|
this.hidePointer();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show pointer when selecting a single block of tagged content
|
||||||
|
const pageContent = document.querySelector('.page-content');
|
||||||
|
DOM.onEvents(pageContent, ['mouseup', 'keyup'], event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const targetEl = event.target.closest('[id^="bkmrk"]');
|
||||||
|
if (targetEl) {
|
||||||
|
this.showPointerAtTarget(targetEl, event.pageX);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hidePointer() {
|
||||||
|
this.container.style.display = null;
|
||||||
|
this.showing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move and display the pointer at the given element, targeting the given screen x-position if possible.
|
||||||
|
* @param {Element} element
|
||||||
|
* @param {Number} xPosition
|
||||||
|
*/
|
||||||
|
showPointerAtTarget(element, xPosition) {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection.toString().length === 0) return;
|
||||||
|
|
||||||
|
// Show pointer and set link
|
||||||
|
this.pointerSectionId = element.id;
|
||||||
|
this.updateForTarget(element);
|
||||||
|
|
||||||
|
this.container.style.display = 'block';
|
||||||
|
const targetBounds = element.getBoundingClientRect();
|
||||||
|
const pointerBounds = this.container.getBoundingClientRect();
|
||||||
|
|
||||||
|
const xTarget = Math.min(Math.max(xPosition, targetBounds.left), targetBounds.right);
|
||||||
|
const xOffset = xTarget - (pointerBounds.width / 2);
|
||||||
|
const yOffset = (targetBounds.top - pointerBounds.height) - 16;
|
||||||
|
|
||||||
|
this.container.style.left = `${xOffset}px`;
|
||||||
|
this.container.style.top = `${yOffset}px`;
|
||||||
|
|
||||||
|
this.showing = true;
|
||||||
|
this.isSelection = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isSelection = false;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
const scrollListener = () => {
|
||||||
|
this.hidePointer();
|
||||||
|
window.removeEventListener('scroll', scrollListener, {passive: true});
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', scrollListener, {passive: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the pointer inputs/content for the given target element.
|
||||||
|
* @param {?Element} element
|
||||||
|
*/
|
||||||
|
updateForTarget(element) {
|
||||||
|
let inputText = this.pointerModeLink ? window.baseUrl(`/link/${this.pageId}#${this.pointerSectionId}`) : `{{@${this.pageId}#${this.pointerSectionId}}}`;
|
||||||
|
if (this.pointerModeLink && !inputText.startsWith('http')) {
|
||||||
|
inputText = window.location.protocol + "//" + window.location.host + inputText;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container.querySelector('input').value = inputText;
|
||||||
|
|
||||||
|
// Update anchor if present
|
||||||
|
const editAnchor = this.container.querySelector('#pointer-edit');
|
||||||
|
if (editAnchor && element) {
|
||||||
|
const editHref = editAnchor.dataset.editHref;
|
||||||
|
const elementId = element.id;
|
||||||
|
|
||||||
|
// get the first 50 characters.
|
||||||
|
const queryContent = element.textContent && element.textContent.substring(0, 50);
|
||||||
|
editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pointer;
|
|
@ -19,6 +19,7 @@ class ShelfSort {
|
||||||
new Sortable(scrollBox, {
|
new Sortable(scrollBox, {
|
||||||
group: 'shelf-books',
|
group: 'shelf-books',
|
||||||
ghostClass: 'primary-background-light',
|
ghostClass: 'primary-background-light',
|
||||||
|
handle: '.handle',
|
||||||
animation: 150,
|
animation: 150,
|
||||||
onSort: this.onChange.bind(this),
|
onSort: this.onChange.bind(this),
|
||||||
});
|
});
|
||||||
|
|
|
@ -117,4 +117,17 @@ export function removeLoading(element) {
|
||||||
for (const el of loadingEls) {
|
for (const el of loadingEls) {
|
||||||
el.remove();
|
el.remove();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the given html data into a live DOM element.
|
||||||
|
* Initiates any components defined in the data.
|
||||||
|
* @param {String} html
|
||||||
|
* @returns {Element}
|
||||||
|
*/
|
||||||
|
export function htmlToDom(html) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.innerHTML = html;
|
||||||
|
window.components.init(wrap);
|
||||||
|
return wrap.children[0];
|
||||||
}
|
}
|
|
@ -3,6 +3,7 @@ import {listen as listenForCommonEvents} from "./common-events";
|
||||||
import {scrollToQueryString} from "./scrolling";
|
import {scrollToQueryString} from "./scrolling";
|
||||||
import {listenForDragAndPaste} from "./drop-paste-handling";
|
import {listenForDragAndPaste} from "./drop-paste-handling";
|
||||||
import {getPrimaryToolbar, registerAdditionalToolbars} from "./toolbars";
|
import {getPrimaryToolbar, registerAdditionalToolbars} from "./toolbars";
|
||||||
|
import {registerCustomIcons} from "./icons";
|
||||||
|
|
||||||
import {getPlugin as getCodeeditorPlugin} from "./plugin-codeeditor";
|
import {getPlugin as getCodeeditorPlugin} from "./plugin-codeeditor";
|
||||||
import {getPlugin as getDrawioPlugin} from "./plugin-drawio";
|
import {getPlugin as getDrawioPlugin} from "./plugin-drawio";
|
||||||
|
@ -255,7 +256,7 @@ export function build(options) {
|
||||||
statusbar: false,
|
statusbar: false,
|
||||||
menubar: false,
|
menubar: false,
|
||||||
paste_data_images: false,
|
paste_data_images: false,
|
||||||
extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*],div[*],li[class|checked]',
|
extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*],div[*],li[class|checked|style]',
|
||||||
automatic_uploads: false,
|
automatic_uploads: false,
|
||||||
custom_elements: 'doc-root,code-block',
|
custom_elements: 'doc-root,code-block',
|
||||||
valid_children: [
|
valid_children: [
|
||||||
|
@ -291,6 +292,7 @@ export function build(options) {
|
||||||
head.innerHTML += fetchCustomHeadContent();
|
head.innerHTML += fetchCustomHeadContent();
|
||||||
},
|
},
|
||||||
setup(editor) {
|
setup(editor) {
|
||||||
|
registerCustomIcons(editor);
|
||||||
registerAdditionalToolbars(editor, options);
|
registerAdditionalToolbars(editor, options);
|
||||||
getSetupCallback(options)(editor);
|
getSetupCallback(options)(editor);
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
const icons = {
|
||||||
|
'table-delete-column': '<svg width="24" height="24"><path d="M21 19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14c1.1 0 2 .9 2 2zm-2 0V5h-4v2.2h-2V5h-2v2.2H9V5H5v14h4v-2.1h2V19h2v-2.1h2V19Z"/><path d="M14.829 10.585 13.415 12l1.414 1.414c.943.943-.472 2.357-1.414 1.414L12 13.414l-1.414 1.414c-.944.944-2.358-.47-1.414-1.414L10.586 12l-1.414-1.415c-.943-.942.471-2.357 1.414-1.414L12 10.585l1.344-1.343c1.111-1.112 2.2.627 1.485 1.343z" style="fill-rule:nonzero"/></svg>',
|
||||||
|
'table-delete-row': '<svg width="24" height="24"><path d="M5 21a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14c0 1.1-.9 2-2 2zm0-2h14v-4h-2.2v-2H19v-2h-2.2V9H19V5H5v4h2.1v2H5v2h2.1v2H5Z"/><path d="M13.415 14.829 12 13.415l-1.414 1.414c-.943.943-2.357-.472-1.414-1.414L10.586 12l-1.414-1.414c-.944-.944.47-2.358 1.414-1.414L12 10.586l1.415-1.414c.942-.943 2.357.471 1.414 1.414L13.415 12l1.343 1.344c1.112 1.111-.627 2.2-1.343 1.485z" style="fill-rule:nonzero"/></svg>',
|
||||||
|
'table-insert-column-after': '<svg width="24" height="24"><path d="M16 5h-5v14h5c1.235 0 1.234 2 0 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11c1.229 0 1.236 2 0 2zm-7 6V5H5v6zm0 8v-6H5v6zm11.076-6h-2v2c0 1.333-2 1.333-2 0v-2h-2c-1.335 0-1.335-2 0-2h2V9c0-1.333 2-1.333 2 0v2h1.9c1.572 0 1.113 2 .1 2z"/></svg>',
|
||||||
|
'table-insert-column-before': '<svg width="24" height="24"><path d="M8 19h5V5H8C6.764 5 6.766 3 8 3h11a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H8c-1.229 0-1.236-2 0-2zm7-6v6h4v-6zm0-8v6h4V5ZM3.924 11h2V9c0-1.333 2-1.333 2 0v2h2c1.335 0 1.335 2 0 2h-2v2c0 1.333-2 1.333-2 0v-2h-1.9c-1.572 0-1.113-2-.1-2z"/></svg>',
|
||||||
|
'table-insert-row-above': '<svg width="24" height="24"><path d="M5 8v5h14V8c0-1.235 2-1.234 2 0v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8C3 6.77 5 6.764 5 8zm6 7H5v4h6zm8 0h-6v4h6zM13 3.924v2h2c1.333 0 1.333 2 0 2h-2v2c0 1.335-2 1.335-2 0v-2H9c-1.333 0-1.333-2 0-2h2v-1.9c0-1.572 2-1.113 2-.1z"/></svg>',
|
||||||
|
'table-insert-row-after': '<svg width="24" height="24"><path d="M19 16v-5H5v5c0 1.235-2 1.234-2 0V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v11c0 1.229-2 1.236-2 0zm-6-7h6V5h-6zM5 9h6V5H5Zm6 11.076v-2H9c-1.333 0-1.333-2 0-2h2v-2c0-1.335 2-1.335 2 0v2h2c1.333 0 1.333 2 0 2h-2v1.9c0 1.572-2 1.113-2 .1z"/></svg>',
|
||||||
|
'table': '<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg"><path d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2ZM5 14v5h6v-5zm14 0h-6v5h6zm0-7h-6v5h6zM5 12h6V7H5Z"/></svg>',
|
||||||
|
'table-delete-table': '<svg width="24" height="24"><path d="M5 21a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14c0 1.1-.9 2-2 2zm0-2h14V5H5v14z"/><path d="m13.711 15.423-1.71-1.712-1.712 1.712c-1.14 1.14-2.852-.57-1.71-1.712l1.71-1.71-1.71-1.712c-1.143-1.142.568-2.853 1.71-1.71L12 10.288l1.711-1.71c1.141-1.142 2.852.57 1.712 1.71L13.71 12l1.626 1.626c1.345 1.345-.76 2.663-1.626 1.797z" style="fill-rule:nonzero;stroke-width:1.20992"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Editor} editor
|
||||||
|
*/
|
||||||
|
export function registerCustomIcons(editor) {
|
||||||
|
|
||||||
|
for (const [name, svg] of Object.entries(icons)) {
|
||||||
|
editor.ui.registry.addIcon(name, svg);
|
||||||
|
}
|
||||||
|
}
|
|
@ -39,16 +39,16 @@ function defineCodeBlockCustomElement(editor) {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({mode: 'open'});
|
this.attachShadow({mode: 'open'});
|
||||||
const linkElem = document.createElement('link');
|
|
||||||
linkElem.setAttribute('rel', 'stylesheet');
|
const stylesToCopy = document.querySelectorAll('link[rel="stylesheet"]:not([media="print"])');
|
||||||
linkElem.setAttribute('href', window.baseUrl('/dist/styles.css'));
|
const copiedStyles = Array.from(stylesToCopy).map(styleEl => styleEl.cloneNode(false));
|
||||||
|
|
||||||
const cmContainer = document.createElement('div');
|
const cmContainer = document.createElement('div');
|
||||||
cmContainer.style.pointerEvents = 'none';
|
cmContainer.style.pointerEvents = 'none';
|
||||||
cmContainer.contentEditable = 'false';
|
cmContainer.contentEditable = 'false';
|
||||||
cmContainer.classList.add('CodeMirrorContainer');
|
cmContainer.classList.add('CodeMirrorContainer');
|
||||||
|
|
||||||
this.shadowRoot.append(linkElem, cmContainer);
|
this.shadowRoot.append(...copiedStyles, cmContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
getLanguage() {
|
getLanguage() {
|
||||||
|
@ -153,6 +153,14 @@ function register(editor, url) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
editor.ui.registry.addButton('editcodeeditor', {
|
||||||
|
tooltip: 'Edit code block',
|
||||||
|
icon: 'edit-block',
|
||||||
|
onAction() {
|
||||||
|
editor.execCommand('codeeditor');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
editor.addCommand('codeeditor', () => {
|
editor.addCommand('codeeditor', () => {
|
||||||
const selectedNode = editor.selection.getNode();
|
const selectedNode = editor.selection.getNode();
|
||||||
const doc = selectedNode.ownerDocument;
|
const doc = selectedNode.ownerDocument;
|
||||||
|
@ -208,6 +216,15 @@ function register(editor, url) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
editor.ui.registry.addContextToolbar('codeeditor', {
|
||||||
|
predicate: function (node) {
|
||||||
|
return node.nodeName.toLowerCase() === 'code-block';
|
||||||
|
},
|
||||||
|
items: 'editcodeeditor',
|
||||||
|
position: 'node',
|
||||||
|
scope: 'node'
|
||||||
|
});
|
||||||
|
|
||||||
editor.on('PreInit', () => {
|
editor.on('PreInit', () => {
|
||||||
defineCodeBlockCustomElement(editor);
|
defineCodeBlockCustomElement(editor);
|
||||||
});
|
});
|
||||||
|
|
|
@ -66,6 +66,7 @@ return [
|
||||||
'insert_link_title' => 'Insert/Edit Link',
|
'insert_link_title' => 'Insert/Edit Link',
|
||||||
'insert_horizontal_line' => 'Insert horizontal line',
|
'insert_horizontal_line' => 'Insert horizontal line',
|
||||||
'insert_code_block' => 'Insert code block',
|
'insert_code_block' => 'Insert code block',
|
||||||
|
'edit_code_block' => 'Edit code block',
|
||||||
'insert_drawing' => 'Insert/edit drawing',
|
'insert_drawing' => 'Insert/edit drawing',
|
||||||
'drawing_manager' => 'Drawing manager',
|
'drawing_manager' => 'Drawing manager',
|
||||||
'insert_media' => 'Insert/edit media',
|
'insert_media' => 'Insert/edit media',
|
||||||
|
|
|
@ -42,10 +42,14 @@ return [
|
||||||
|
|
||||||
// Permissions and restrictions
|
// Permissions and restrictions
|
||||||
'permissions' => 'الأذونات',
|
'permissions' => 'الأذونات',
|
||||||
'permissions_intro' => 'عند التفعيل، سوف تأخذ هذه الأذونات أولوية على أي صلاحية أخرى للدور.',
|
'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.',
|
||||||
'permissions_enable' => 'تفعيل الأذونات المخصصة',
|
'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.',
|
||||||
|
'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.',
|
||||||
'permissions_save' => 'حفظ الأذونات',
|
'permissions_save' => 'حفظ الأذونات',
|
||||||
'permissions_owner' => 'Owner',
|
'permissions_owner' => 'Owner',
|
||||||
|
'permissions_role_everyone_else' => 'Everyone Else',
|
||||||
|
'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.',
|
||||||
|
'permissions_role_override' => 'Override permissions for role',
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
'search_results' => 'نتائج البحث',
|
'search_results' => 'نتائج البحث',
|
||||||
|
|
|
@ -66,6 +66,7 @@ return [
|
||||||
'insert_link_title' => 'Вмъкни/редактирай връзка',
|
'insert_link_title' => 'Вмъкни/редактирай връзка',
|
||||||
'insert_horizontal_line' => 'Вмъкни хоризонтална линия',
|
'insert_horizontal_line' => 'Вмъкни хоризонтална линия',
|
||||||
'insert_code_block' => 'Въведи код',
|
'insert_code_block' => 'Въведи код',
|
||||||
|
'edit_code_block' => 'Edit code block',
|
||||||
'insert_drawing' => 'Вмъкни/редактирай рисунка',
|
'insert_drawing' => 'Вмъкни/редактирай рисунка',
|
||||||
'drawing_manager' => 'Управление на рисунките',
|
'drawing_manager' => 'Управление на рисунките',
|
||||||
'insert_media' => 'Вмъкни/редактирай мултимедия',
|
'insert_media' => 'Вмъкни/редактирай мултимедия',
|
||||||
|
|
|
@ -42,10 +42,14 @@ return [
|
||||||
|
|
||||||
// Permissions and restrictions
|
// Permissions and restrictions
|
||||||
'permissions' => 'Права',
|
'permissions' => 'Права',
|
||||||
'permissions_intro' => 'Веднъж добавени, тези права ще вземат приоритет над всички други установени права.',
|
'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.',
|
||||||
'permissions_enable' => 'Разреши уникални права',
|
'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.',
|
||||||
|
'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.',
|
||||||
'permissions_save' => 'Запази права',
|
'permissions_save' => 'Запази права',
|
||||||
'permissions_owner' => 'Собственик',
|
'permissions_owner' => 'Собственик',
|
||||||
|
'permissions_role_everyone_else' => 'Everyone Else',
|
||||||
|
'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.',
|
||||||
|
'permissions_role_override' => 'Override permissions for role',
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
'search_results' => 'Резултати от търсенето',
|
'search_results' => 'Резултати от търсенето',
|
||||||
|
|
|
@ -66,6 +66,7 @@ return [
|
||||||
'insert_link_title' => 'Insert/Edit Link',
|
'insert_link_title' => 'Insert/Edit Link',
|
||||||
'insert_horizontal_line' => 'Insert horizontal line',
|
'insert_horizontal_line' => 'Insert horizontal line',
|
||||||
'insert_code_block' => 'Insert code block',
|
'insert_code_block' => 'Insert code block',
|
||||||
|
'edit_code_block' => 'Edit code block',
|
||||||
'insert_drawing' => 'Insert/edit drawing',
|
'insert_drawing' => 'Insert/edit drawing',
|
||||||
'drawing_manager' => 'Drawing manager',
|
'drawing_manager' => 'Drawing manager',
|
||||||
'insert_media' => 'Insert/edit media',
|
'insert_media' => 'Insert/edit media',
|
||||||
|
|
|
@ -42,10 +42,14 @@ return [
|
||||||
|
|
||||||
// Permissions and restrictions
|
// Permissions and restrictions
|
||||||
'permissions' => 'Dozvole',
|
'permissions' => 'Dozvole',
|
||||||
'permissions_intro' => 'Jednom omogućene, ove dozvole imaju prednost nad dozvolama uloge.',
|
'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.',
|
||||||
'permissions_enable' => 'Omogući prilagođena dopuštenja',
|
'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.',
|
||||||
|
'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.',
|
||||||
'permissions_save' => 'Snimi dozvole',
|
'permissions_save' => 'Snimi dozvole',
|
||||||
'permissions_owner' => 'Vlasnik',
|
'permissions_owner' => 'Vlasnik',
|
||||||
|
'permissions_role_everyone_else' => 'Everyone Else',
|
||||||
|
'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.',
|
||||||
|
'permissions_role_override' => 'Override permissions for role',
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
'search_results' => 'Rezultati pretrage',
|
'search_results' => 'Rezultati pretrage',
|
||||||
|
|
|
@ -66,6 +66,7 @@ return [
|
||||||
'insert_link_title' => 'Insert/Edit Link',
|
'insert_link_title' => 'Insert/Edit Link',
|
||||||
'insert_horizontal_line' => 'Insert horizontal line',
|
'insert_horizontal_line' => 'Insert horizontal line',
|
||||||
'insert_code_block' => 'Insert code block',
|
'insert_code_block' => 'Insert code block',
|
||||||
|
'edit_code_block' => 'Edit code block',
|
||||||
'insert_drawing' => 'Insert/edit drawing',
|
'insert_drawing' => 'Insert/edit drawing',
|
||||||
'drawing_manager' => 'Drawing manager',
|
'drawing_manager' => 'Drawing manager',
|
||||||
'insert_media' => 'Insert/edit media',
|
'insert_media' => 'Insert/edit media',
|
||||||
|
|
|
@ -42,10 +42,14 @@ return [
|
||||||
|
|
||||||
// Permissions and restrictions
|
// Permissions and restrictions
|
||||||
'permissions' => 'Permisos',
|
'permissions' => 'Permisos',
|
||||||
'permissions_intro' => 'Si els activeu, aquests permisos tindran més prioritat que qualsevol permís de rol.',
|
'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.',
|
||||||
'permissions_enable' => 'Activa els permisos personalitzats',
|
'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.',
|
||||||
|
'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.',
|
||||||
'permissions_save' => 'Desa els permisos',
|
'permissions_save' => 'Desa els permisos',
|
||||||
'permissions_owner' => 'Propietari',
|
'permissions_owner' => 'Propietari',
|
||||||
|
'permissions_role_everyone_else' => 'Everyone Else',
|
||||||
|
'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.',
|
||||||
|
'permissions_role_override' => 'Override permissions for role',
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
'search_results' => 'Resultats de la cerca',
|
'search_results' => 'Resultats de la cerca',
|
||||||
|
|
|
@ -66,6 +66,7 @@ return [
|
||||||
'insert_link_title' => 'Insert/Edit Link',
|
'insert_link_title' => 'Insert/Edit Link',
|
||||||
'insert_horizontal_line' => 'Insert horizontal line',
|
'insert_horizontal_line' => 'Insert horizontal line',
|
||||||
'insert_code_block' => 'Insert code block',
|
'insert_code_block' => 'Insert code block',
|
||||||
|
'edit_code_block' => 'Edit code block',
|
||||||
'insert_drawing' => 'Insert/edit drawing',
|
'insert_drawing' => 'Insert/edit drawing',
|
||||||
'drawing_manager' => 'Drawing manager',
|
'drawing_manager' => 'Drawing manager',
|
||||||
'insert_media' => 'Insert/edit media',
|
'insert_media' => 'Insert/edit media',
|
||||||
|
|
|
@ -42,10 +42,14 @@ return [
|
||||||
|
|
||||||
// Permissions and restrictions
|
// Permissions and restrictions
|
||||||
'permissions' => 'Oprávnění',
|
'permissions' => 'Oprávnění',
|
||||||
'permissions_intro' => 'Pokud je povoleno, tato oprávnění budou mít přednost před všemi nastavenými oprávněními role.',
|
'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.',
|
||||||
'permissions_enable' => 'Povolit vlastní oprávnění',
|
'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.',
|
||||||
|
'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.',
|
||||||
'permissions_save' => 'Uložit oprávnění',
|
'permissions_save' => 'Uložit oprávnění',
|
||||||
'permissions_owner' => 'Vlastník',
|
'permissions_owner' => 'Vlastník',
|
||||||
|
'permissions_role_everyone_else' => 'Everyone Else',
|
||||||
|
'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.',
|
||||||
|
'permissions_role_override' => 'Override permissions for role',
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
'search_results' => 'Výsledky hledání',
|
'search_results' => 'Výsledky hledání',
|
||||||
|
|
|
@ -66,6 +66,7 @@ return [
|
||||||
'insert_link_title' => 'Insert/Edit Link',
|
'insert_link_title' => 'Insert/Edit Link',
|
||||||
'insert_horizontal_line' => 'Insert horizontal line',
|
'insert_horizontal_line' => 'Insert horizontal line',
|
||||||
'insert_code_block' => 'Insert code block',
|
'insert_code_block' => 'Insert code block',
|
||||||
|
'edit_code_block' => 'Edit code block',
|
||||||
'insert_drawing' => 'Insert/edit drawing',
|
'insert_drawing' => 'Insert/edit drawing',
|
||||||
'drawing_manager' => 'Drawing manager',
|
'drawing_manager' => 'Drawing manager',
|
||||||
'insert_media' => 'Insert/edit media',
|
'insert_media' => 'Insert/edit media',
|
||||||
|
|
|
@ -42,10 +42,14 @@ return [
|
||||||
|
|
||||||
// Permissions and restrictions
|
// Permissions and restrictions
|
||||||
'permissions' => 'Permissions',
|
'permissions' => 'Permissions',
|
||||||
'permissions_intro' => 'Once enabled, These permissions will take priority over any set role permissions.',
|
'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.',
|
||||||
'permissions_enable' => 'Enable Custom Permissions',
|
'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.',
|
||||||
|
'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.',
|
||||||
'permissions_save' => 'Save Permissions',
|
'permissions_save' => 'Save Permissions',
|
||||||
'permissions_owner' => 'Owner',
|
'permissions_owner' => 'Owner',
|
||||||
|
'permissions_role_everyone_else' => 'Everyone Else',
|
||||||
|
'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.',
|
||||||
|
'permissions_role_override' => 'Override permissions for role',
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
'search_results' => 'Search Results',
|
'search_results' => 'Search Results',
|
||||||
|
|
|
@ -66,6 +66,7 @@ return [
|
||||||
'insert_link_title' => 'Indsæt/Rediger Link',
|
'insert_link_title' => 'Indsæt/Rediger Link',
|
||||||
'insert_horizontal_line' => 'Indsæt vandret linje',
|
'insert_horizontal_line' => 'Indsæt vandret linje',
|
||||||
'insert_code_block' => 'Indsæt kodeblok',
|
'insert_code_block' => 'Indsæt kodeblok',
|
||||||
|
'edit_code_block' => 'Edit code block',
|
||||||
'insert_drawing' => 'Indsæt/rediger tegning',
|
'insert_drawing' => 'Indsæt/rediger tegning',
|
||||||
'drawing_manager' => 'Drawing manager',
|
'drawing_manager' => 'Drawing manager',
|
||||||
'insert_media' => 'Indsæt/rediger medie',
|
'insert_media' => 'Indsæt/rediger medie',
|
||||||
|
|
|
@ -42,10 +42,14 @@ return [
|
||||||
|
|
||||||
// Permissions and restrictions
|
// Permissions and restrictions
|
||||||
'permissions' => 'Rettigheder',
|
'permissions' => 'Rettigheder',
|
||||||
'permissions_intro' => 'Når de er aktiveret, vil disse tilladelser have prioritet over alle indstillede rolletilladelser.',
|
'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.',
|
||||||
'permissions_enable' => 'Aktivér tilpassede tilladelser',
|
'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.',
|
||||||
|
'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.',
|
||||||
'permissions_save' => 'Gem tilladelser',
|
'permissions_save' => 'Gem tilladelser',
|
||||||
'permissions_owner' => 'Ejer',
|
'permissions_owner' => 'Ejer',
|
||||||
|
'permissions_role_everyone_else' => 'Everyone Else',
|
||||||
|
'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.',
|
||||||
|
'permissions_role_override' => 'Override permissions for role',
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
'search_results' => 'Søgeresultater',
|
'search_results' => 'Søgeresultater',
|
||||||
|
|
|
@ -48,7 +48,7 @@ return [
|
||||||
'bookshelf_delete_notification' => 'Regal erfolgreich gelöscht',
|
'bookshelf_delete_notification' => 'Regal erfolgreich gelöscht',
|
||||||
|
|
||||||
// Favourites
|
// Favourites
|
||||||
'favourite_add_notification' => '":name" wurde zu deinen Favoriten hinzugefügt',
|
'favourite_add_notification' => '":name" wurde zu Ihren Favoriten hinzugefügt',
|
||||||
'favourite_remove_notification' => '":name" wurde aus Ihren Favoriten entfernt',
|
'favourite_remove_notification' => '":name" wurde aus Ihren Favoriten entfernt',
|
||||||
|
|
||||||
// MFA
|
// MFA
|
||||||
|
|
|
@ -35,7 +35,7 @@ return [
|
||||||
'register_thanks' => 'Vielen Dank für Ihre Registrierung!',
|
'register_thanks' => 'Vielen Dank für Ihre Registrierung!',
|
||||||
'register_confirm' => 'Bitte prüfen Sie Ihren Posteingang und bestätigen Sie die Registrierung.',
|
'register_confirm' => 'Bitte prüfen Sie Ihren Posteingang und bestätigen Sie die Registrierung.',
|
||||||
'registrations_disabled' => 'Eine Registrierung ist momentan nicht möglich',
|
'registrations_disabled' => 'Eine Registrierung ist momentan nicht möglich',
|
||||||
'registration_email_domain_invalid' => 'Sie können sich mit dieser E-Mail nicht registrieren.',
|
'registration_email_domain_invalid' => 'Sie können sich mit dieser E-Mail-Adresse nicht registrieren',
|
||||||
'register_success' => 'Vielen Dank für Ihre Registrierung! Die Daten sind gespeichert und Sie sind angemeldet.',
|
'register_success' => 'Vielen Dank für Ihre Registrierung! Die Daten sind gespeichert und Sie sind angemeldet.',
|
||||||
|
|
||||||
// Login auto-initiation
|
// Login auto-initiation
|
||||||
|
@ -58,7 +58,7 @@ return [
|
||||||
'email_confirm_greeting' => 'Danke, dass Sie sich für :appName registriert haben!',
|
'email_confirm_greeting' => 'Danke, dass Sie sich für :appName registriert haben!',
|
||||||
'email_confirm_text' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf die Schaltfläche klicken:',
|
'email_confirm_text' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf die Schaltfläche klicken:',
|
||||||
'email_confirm_action' => 'E-Mail-Adresse bestätigen',
|
'email_confirm_action' => 'E-Mail-Adresse bestätigen',
|
||||||
'email_confirm_send_error' => 'Leider konnte die für die Registrierung notwendige E-Mail zur bestätigung Ihrer E-Mail-Adresse nicht versandt werden. Bitte kontaktieren Sie den Systemadministrator!',
|
'email_confirm_send_error' => 'Leider konnte die für die Registrierung notwendige E-Mail zur Bestätigung Ihrer E-Mail-Adresse nicht versandt werden. Bitte kontaktieren Sie den Systemadministrator!',
|
||||||
'email_confirm_success' => 'Ihre E-Mail wurde bestätigt! Sie sollten nun in der Lage sein, sich mit dieser E-Mail-Adresse anzumelden.',
|
'email_confirm_success' => 'Ihre E-Mail wurde bestätigt! Sie sollten nun in der Lage sein, sich mit dieser E-Mail-Adresse anzumelden.',
|
||||||
'email_confirm_resent' => 'Bestätigungs-E-Mail wurde erneut versendet, bitte überprüfen Sie Ihren Posteingang.',
|
'email_confirm_resent' => 'Bestätigungs-E-Mail wurde erneut versendet, bitte überprüfen Sie Ihren Posteingang.',
|
||||||
|
|
||||||
|
@ -69,12 +69,12 @@ return [
|
||||||
'email_not_confirmed_resend_button' => 'Bestätigungs-E-Mail erneut senden',
|
'email_not_confirmed_resend_button' => 'Bestätigungs-E-Mail erneut senden',
|
||||||
|
|
||||||
// User Invite
|
// User Invite
|
||||||
'user_invite_email_subject' => 'Du wurdest eingeladen :appName beizutreten!',
|
'user_invite_email_subject' => 'Sie wurden eingeladen :appName beizutreten!',
|
||||||
'user_invite_email_greeting' => 'Ein Konto wurde für Sie auf :appName erstellt.',
|
'user_invite_email_greeting' => 'Ein Konto wurde für Sie auf :appName erstellt.',
|
||||||
'user_invite_email_text' => 'Klicken Sie auf die Schaltfläche unten, um ein Passwort festzulegen und Zugriff zu erhalten:',
|
'user_invite_email_text' => 'Klicken Sie auf die Schaltfläche unten, um ein Passwort festzulegen und Zugriff zu erhalten:',
|
||||||
'user_invite_email_action' => 'Account-Passwort festlegen',
|
'user_invite_email_action' => 'Account-Passwort festlegen',
|
||||||
'user_invite_page_welcome' => 'Willkommen bei :appName!',
|
'user_invite_page_welcome' => 'Willkommen bei :appName!',
|
||||||
'user_invite_page_text' => 'Um die Anmeldung abzuschließen und Zugriff auf :appName zu bekommen muss noch ein Passwort festgelegt werden. Dieses wird in Zukunft zum Einloggen benötigt.',
|
'user_invite_page_text' => 'Um die Anmeldung abzuschließen und Zugriff auf :appName zu bekommen muss noch ein Passwort festgelegt werden. Dieses wird in Zukunft für die Anmeldung benötigt.',
|
||||||
'user_invite_page_confirm_button' => 'Passwort bestätigen',
|
'user_invite_page_confirm_button' => 'Passwort bestätigen',
|
||||||
'user_invite_success_login' => 'Passwort gesetzt, Sie sollten nun in der Lage sein, sich mit Ihrem Passwort an :appName anzumelden!',
|
'user_invite_success_login' => 'Passwort gesetzt, Sie sollten nun in der Lage sein, sich mit Ihrem Passwort an :appName anzumelden!',
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue