Merge branch 'development' into release

This commit is contained in:
Dan Brown 2022-10-21 11:16:25 +01:00
commit 64b41dd626
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
294 changed files with 6053 additions and 3369 deletions

View File

@ -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

View File

@ -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

View File

@ -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');
} }
/** /**

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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
{ {

View File

@ -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);
} }
/** /**

View File

@ -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);
}
} }

View File

@ -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;
} }
/** /**

View File

@ -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');
}
}
} }

View File

@ -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'),
]);
}
}

View File

@ -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();

View File

@ -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;

View File

@ -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}
*/ */

View File

@ -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'])) {

View File

@ -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,
], ],
/* /*

View File

@ -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 . ']');
} }

View File

@ -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();
}
} }

View File

@ -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();
}
} }

View File

@ -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();
}
} }

View File

@ -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;
} }
/** /**

View File

@ -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();
}
} }

View File

@ -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.

View File

@ -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.
* *

View File

@ -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
{ {

View File

@ -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();

View File

@ -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);
} }
} }

View File

@ -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;
} }
} }

View File

@ -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);
} }

View File

@ -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;

View File

@ -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'],
]); ]);
} }

View File

@ -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.

View File

@ -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')
); );

View File

@ -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);
}
} }

View File

@ -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()],
]); ]);
} }
} }

View File

@ -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.

View File

@ -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.

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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();

View File

@ -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.
* *

View File

@ -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());
}
} }

View File

@ -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.
*/ */

View File

@ -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) {

View File

@ -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());
}
} }

View File

@ -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,
]);
}
}

View File

@ -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', [

View File

@ -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);

View File

@ -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,

View File

@ -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) {

View File

@ -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;
// });
}
}

View File

@ -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);
});
}
}

View File

@ -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 => [

View File

@ -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;
});
}
}

View File

@ -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);

View File

@ -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.

View File

@ -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); ?>";
});
}
}

View File

@ -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)

View File

@ -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'],

View File

@ -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"

664
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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');
}
}

View File

@ -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();
}
}

View File

@ -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
} }

View File

@ -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);
```

98
dev/docs/development.md Normal file
View File

@ -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.

138
dev/docs/javascript-code.md Normal file
View File

@ -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);
```

View File

@ -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
View File

@ -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.

View File

@ -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

4
resources/icons/role.svg Normal file
View File

@ -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

View File

@ -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',

View File

@ -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)}`;

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -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) ||

View File

@ -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));
} }
} }

View File

@ -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;

View File

@ -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),
}); });

View File

@ -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];
} }

View File

@ -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);
}, },

View File

@ -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);
}
}

View File

@ -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);
}); });

View File

@ -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',

View File

@ -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' => 'نتائج البحث',

View File

@ -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' => 'Вмъкни/редактирай мултимедия',

View File

@ -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' => 'Резултати от търсенето',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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í',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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

View File

@ -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