Compare commits

..

28 Commits

Author SHA1 Message Date
Dan Brown
d00ac2f34e Updated version and assets for release v21.11.2 2021-11-30 14:30:19 +00:00
Dan Brown
bd4dc6d463 Merge branch 'master' into release 2021-11-30 14:29:53 +00:00
Dan Brown
e6c8ecba9c Merge branch 'master' of github.com:BookStackApp/BookStack 2021-11-30 14:25:27 +00:00
Dan Brown
9490457d04 Applied StyleCI changes 2021-11-30 14:25:09 +00:00
Dan Brown
3e97fdf827 New Crowdin updates (#3076)
* New translations entities.php (Chinese Simplified)

* New translations settings.php (Portuguese, Brazilian)

* New translations validation.php (Portuguese, Brazilian)

* New translations common.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations auth.php (Turkish)
2021-11-30 14:24:35 +00:00
Dan Brown
3b3eb0f44f Updated API session auth to consider public access setting
For #3091
2021-11-30 13:55:56 +00:00
Dan Brown
b4fa82e329 Fixed related permissions query not considering drafts
Page-related items added on drafts could be visible in certain scenarios
since the applied permissions query filters would not consider
page draft visibility.
This commit alters queries on related items to apply such filtering.

Included test to cover API scenario.
Thanks to @haxatron for reporting.
2021-11-30 00:06:17 +00:00
Dan Brown
42703dd859 Tweaked pdf export iframe replacement to fix compatibility
Was using a method that wasn't a proper available part of the
DomElement API.
2021-11-28 21:01:35 +00:00
Dan Brown
2c21850da7 Added conversion of iframes to anchors on PDF export
- Replaced iframe elements with anchor elements wrapped in a paragraph.
- Extracted PDF generation action to seperate class for easier mocking
  within testing.
- Added test to cover.

For #3077
2021-11-25 15:12:32 +00:00
Dan Brown
709533c1fb Fixed up logical theme docs a tad
- Added link to video guide on YouTube.
- Formalised the customCommand docs parts I hastily added before.
2021-11-24 18:58:46 +00:00
Dan Brown
d91180a909 Updated version and assets for release v21.11.1 2021-11-23 20:44:36 +00:00
Dan Brown
bc2913a5cb Merge branch 'master' into release 2021-11-23 20:44:12 +00:00
Dan Brown
cd7788f2e9 Updated translators and merged styleci fixes 2021-11-23 20:41:12 +00:00
Dan Brown
f63d7f60aa New Crowdin updates (#3057)
* New translations auth.php (Chinese Simplified)

* New translations common.php (Chinese Simplified)

* New translations entities.php (Chinese Simplified)

* New translations common.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations activities.php (Chinese Simplified)

* New translations entities.php (Chinese Simplified)

* New translations auth.php (Russian)

* New translations common.php (Russian)

* New translations common.php (Russian)

* New translations entities.php (Russian)

* New translations errors.php (Russian)

* New translations settings.php (Russian)

* New translations auth.php (Italian)

* New translations common.php (Italian)

* New translations entities.php (Italian)

* New translations entities.php (Italian)

* New translations auth.php (Estonian)
2021-11-23 20:38:52 +00:00
Dan Brown
197caddf96 Changed homepage card header links to be bottom-card-links
The old links in the headers were not obvious. This changes the
header-based links to instead be a link at the bottom of the card.

Related to #3046
2021-11-23 18:18:49 +00:00
Dan Brown
096ed722dd Added use of the prefers-contrast media query
Used upon areas we usually fade-out to provide a focused user
experience. If the user desires more contrasted we prevent this
behaviour using the prefers-contrast media query.

Related to #2634
2021-11-23 15:49:54 +00:00
Dan Brown
024924eef3 Applied another round of static analysis updates 2021-11-22 23:33:55 +00:00
Dan Brown
1bf59f434b Tweaked custom command registration, Added StyleCI fixes
Old command registration method was interfering with default commands,
causing only a limited subset of commands to show overall.
This change follows the method the frameworks uses when loading in from a
directory to prevent issues with run/load order.
2021-11-22 22:22:31 +00:00
Dan Brown
c6e196989e Merge pull request #3072 from BookStackApp/logical_theme_commands
Support custom commands via logical theme system
2021-11-22 19:08:15 +00:00
Dan Brown
cb30c258df Added test for logical-theme-system command registration
Changed how the command registration was handled due to complications of
action order found during testing. Now the theme service will resolve
and directly register the command on the Kernel instead of them being
fetched from the ThemeService from within Kernel.
More direct, Seems to work.
2021-11-22 19:03:04 +00:00
Dan Brown
cdaad2f40e Support custom commands via logical theme system
Added initial work to support registering commands through the logical
theme system. Includes docs changes and example.

Not yet covered via testing.
2021-11-22 18:30:58 +00:00
Dan Brown
4ddbc9556b Merge branch 'assign_ids_to_nested_headers' of https://github.com/Julesdevops/BookStack into Julesdevops-assign_ids_to_nested_headers 2021-11-22 16:34:28 +00:00
Dan Brown
9a5adc026a Updated test to ensure autofocus is set on TOTP input 2021-11-22 13:28:46 +00:00
Robert Accettura
37db51a627 Update verify-totp.blade.php 2021-11-21 23:15:37 -05:00
julesdevops
f8c16494fd feat(PageContent): set unique ids on nested headers 2021-11-21 22:45:25 +01:00
Robert Accettura
0d740ca681 Set taborder for TOTP Verification
Adding tabindex=0 means when pressing tab the focus goes right to the TOTP input field.  When using a Password Manager this makes it easier than having to hit tab 3X to get the right focus.
2021-11-21 15:40:11 -05:00
Dan Brown
876bc10d4d Applied another set of static analysis improvements 2021-11-20 14:03:56 +00:00
Dan Brown
754403a29e Added video guide link to visual theme system docs 2021-11-18 21:04:25 +00:00
72 changed files with 594 additions and 233 deletions

View File

@@ -199,3 +199,4 @@ M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian
sulfo :: Danish
Raukze :: German
zygimantus :: Lithuanian
marinkaberg :: Russian

View File

@@ -4,6 +4,7 @@ namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
@@ -100,14 +101,14 @@ class ActivityService
*/
public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
{
/** @var [string => int[]] $queryIds */
/** @var array<string, int[]> $queryIds */
$queryIds = [$entity->getMorphClass() => [$entity->id]];
if ($entity->isA('book')) {
$queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
if ($entity instanceof Book) {
$queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->scopes('visible')->pluck('id');
}
if ($entity->isA('book') || $entity->isA('chapter')) {
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->visible()->pluck('id');
if ($entity instanceof Book || $entity instanceof Chapter) {
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
}
$query = $this->activity->newQuery();
@@ -132,7 +133,7 @@ class ActivityService
}
/**
* Get latest activity for a user, Filtering out similar items.
* Get the latest activity for a user, Filtering out similar items.
*/
public function userActivity(User $user, int $count = 20, int $page = 0): array
{

View File

@@ -4,6 +4,7 @@ namespace BookStack\Auth\Access;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Database\Eloquent\Model;
class ExternalBaseUserProvider implements UserProvider
{
@@ -16,8 +17,6 @@ class ExternalBaseUserProvider implements UserProvider
/**
* LdapUserProvider constructor.
*
* @param $model
*/
public function __construct(string $model)
{
@@ -27,7 +26,7 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Create a new instance of the model.
*
* @return \Illuminate\Database\Eloquent\Model
* @return Model
*/
public function createModel()
{
@@ -41,7 +40,7 @@ class ExternalBaseUserProvider implements UserProvider
*
* @param mixed $identifier
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
* @return Authenticatable|null
*/
public function retrieveById($identifier)
{
@@ -54,7 +53,7 @@ class ExternalBaseUserProvider implements UserProvider
* @param mixed $identifier
* @param string $token
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
* @return Authenticatable|null
*/
public function retrieveByToken($identifier, $token)
{
@@ -64,8 +63,8 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Update the "remember me" token for the given user in storage.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param string $token
* @param Authenticatable $user
* @param string $token
*
* @return void
*/
@@ -79,7 +78,7 @@ class ExternalBaseUserProvider implements UserProvider
*
* @param array $credentials
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
* @return Authenticatable|null
*/
public function retrieveByCredentials(array $credentials)
{
@@ -94,8 +93,8 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Validate a user against the given credentials.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param array $credentials
* @param Authenticatable $user
* @param array $credentials
*
* @return bool
*/

View File

@@ -165,7 +165,7 @@ class LdapService
* Bind the system user to the LDAP connection using the given credentials
* otherwise anonymous access is attempted.
*
* @param $connection
* @param resource $connection
*
* @throws LdapException
*/

View File

@@ -41,16 +41,18 @@ class OidcJwtSigningKey
protected function loadFromPath(string $path)
{
try {
$this->key = PublicKeyLoader::load(
$key = PublicKeyLoader::load(
file_get_contents($path)
)->withPadding(RSA::SIGNATURE_PKCS1);
);
} catch (\Exception $exception) {
throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}");
}
if (!($this->key instanceof RSA)) {
if (!$key instanceof RSA) {
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
}
$this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
}
/**
@@ -81,14 +83,19 @@ class OidcJwtSigningKey
$n = strtr($jwk['n'] ?? '', '-_', '+/');
try {
/** @var RSA $key */
$this->key = PublicKeyLoader::load([
$key = PublicKeyLoader::load([
'e' => new BigInteger(base64_decode($jwk['e']), 256),
'n' => new BigInteger(base64_decode($n), 256),
])->withPadding(RSA::SIGNATURE_PKCS1);
]);
} catch (\Exception $exception) {
throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}");
}
if (!$key instanceof RSA) {
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
}
$this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
}
/**

View File

@@ -12,6 +12,7 @@ use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite;
use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Contracts\User as SocialUser;
use Laravel\Socialite\Two\GoogleProvider;
use SocialiteProviders\Manager\SocialiteWasCalled;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -278,7 +279,7 @@ class SocialAuthService
{
$driver = $this->socialite->driver($driverName);
if ($driverName === 'google' && config('services.google.select_account')) {
if ($driver instanceof GoogleProvider && config('services.google.select_account')) {
$driver->with(['prompt' => 'select_account']);
}

View File

@@ -602,25 +602,35 @@ class PermissionService
/**
* Filter items that have entities set as a polymorphic relation.
* For simplicity, this will not return results attached to draft pages.
* Draft pages should never really have related items though.
*
* @param Builder|QueryBuilder $query
*/
public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$pageMorphClass = (new Page())->getMorphClass();
$q = $query->where(function ($query) use ($tableDetails, $action) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
/** @var Builder $permissionQuery */
$permissionQuery->select(['role_id'])->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->where('action', '=', $action)
->whereIn('role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
/** @var Builder $permissionQuery */
$permissionQuery->select(['role_id'])->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->where('joint_permissions.action', '=', $action)
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
})->where(function ($query) use ($tableDetails, $pageMorphClass) {
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
->where('pages.draft', '=', false);
});
});
$this->clean();
@@ -634,25 +644,39 @@ class PermissionService
*/
public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
$morphClass = app($entityClass)->getMorphClass();
$fullEntityIdColumn = $tableName . '.' . $entityIdColumn;
$instance = new $entityClass();
$morphClass = $instance->getMorphClass();
$q = $query->where(function ($query) use ($tableDetails, $morphClass) {
$query->where(function ($query) use (&$tableDetails, $morphClass) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails, $morphClass) {
/** @var Builder $permissionQuery */
$permissionQuery->select('id')->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where('entity_type', '=', $morphClass)
->where('action', '=', 'view')
->whereIn('role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
$existsQuery = function ($permissionQuery) use ($fullEntityIdColumn, $morphClass) {
/** @var Builder $permissionQuery */
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $fullEntityIdColumn)
->where('joint_permissions.entity_type', '=', $morphClass)
->where('joint_permissions.action', '=', 'view')
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
})->orWhere($tableDetails['entityIdColumn'], '=', 0);
};
$q = $query->where(function ($query) use ($existsQuery, $fullEntityIdColumn) {
$query->whereExists($existsQuery)
->orWhere($fullEntityIdColumn, '=', 0);
});
if ($instance instanceof Page) {
// Prevent visibility of non-owned draft pages
$q->whereExists(function (QueryBuilder $query) use ($fullEntityIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullEntityIdColumn)
->where(function (QueryBuilder $query) {
$query->where('pages.draft', '=', false)
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
});
});
}
$this->clean();
return $q;
@@ -666,9 +690,9 @@ class PermissionService
*/
protected function addJointHasPermissionCheck($query, int $userIdToCheck)
{
$query->where('has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $userIdToCheck);
$query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
$query->where('joint_permissions.has_permission_own', '=', true)
->where('joint_permissions.owned_by', '=', $userIdToCheck);
});
}

View File

@@ -4,6 +4,7 @@ namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/**
* @property int $id
@@ -13,19 +14,15 @@ class RolePermission extends Model
/**
* The roles that belong to the permission.
*/
public function roles()
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class, 'permission_role', 'permission_id', 'role_id');
}
/**
* Get the permission object by name.
*
* @param $name
*
* @return mixed
*/
public static function getByName($name)
public static function getByName(string $name): ?RolePermission
{
return static::where('name', '=', $name)->first();
}

View File

@@ -49,7 +49,7 @@ class RegenerateSearch extends Command
DB::setDefaultConnection($this->option('database'));
}
$this->searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total) {
$this->searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total): void {
$this->info('Indexed ' . class_basename($model) . ' entries (' . $processed . '/' . $total . ')');
});

View File

@@ -79,53 +79,43 @@ class Book extends Entity implements HasCoverImage
/**
* Get all pages within this book.
*
* @return HasMany
*/
public function pages()
public function pages(): HasMany
{
return $this->hasMany(Page::class);
}
/**
* Get the direct child pages of this book.
*
* @return HasMany
*/
public function directPages()
public function directPages(): HasMany
{
return $this->pages()->where('chapter_id', '=', '0');
}
/**
* Get all chapters within this book.
*
* @return HasMany
*/
public function chapters()
public function chapters(): HasMany
{
return $this->hasMany(Chapter::class);
}
/**
* Get the shelves this book is contained within.
*
* @return BelongsToMany
*/
public function shelves()
public function shelves(): BelongsToMany
{
return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
}
/**
* Get the direct child items within this book.
*
* @return Collection
*/
public function getDirectChildren(): Collection
{
$pages = $this->directPages()->visible()->get();
$chapters = $this->chapters()->visible()->get();
$pages = $this->directPages()->scopes('visible')->get();
$chapters = $this->chapters()->scopes('visible')->get();
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}

View File

@@ -37,7 +37,7 @@ class Bookshelf extends Entity implements HasCoverImage
*/
public function visibleBooks(): BelongsToMany
{
return $this->books()->visible();
return $this->books()->scopes('visible');
}
/**

View File

@@ -23,6 +23,8 @@ class Chapter extends BookChild
/**
* Get the pages that this chapter contains.
*
* @return HasMany<Page>
*/
public function pages(string $dir = 'ASC'): HasMany
{
@@ -50,7 +52,8 @@ class Chapter extends BookChild
*/
public function getVisiblePages(): Collection
{
return $this->pages()->visible()
return $this->pages()
->scopes('visible')
->orderBy('draft', 'desc')
->orderBy('priority', 'asc')
->get();

View File

@@ -3,13 +3,14 @@
namespace BookStack\Entities\Models;
use BookStack\Auth\User;
use BookStack\Interfaces\Deletable;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property Model $deletable
* @property Deletable $deletable
*/
class Deletion extends Model implements Loggable
{

View File

@@ -12,6 +12,7 @@ use BookStack\Auth\Permissions\JointPermission;
use BookStack\Entities\Tools\SearchIndex;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Facades\Permissions;
use BookStack\Interfaces\Deletable;
use BookStack\Interfaces\Favouritable;
use BookStack\Interfaces\Sluggable;
use BookStack\Interfaces\Viewable;
@@ -44,7 +45,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static Builder withLastView()
* @method static Builder withViewCount()
*/
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable
{
use SoftDeletes;
use HasCreatorAndUpdater;
@@ -120,11 +121,11 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
return true;
}
if (($entity->isA('chapter') || $entity->isA('page')) && $this->isA('book')) {
if (($entity instanceof BookChild) && $this instanceof Book) {
return $entity->book_id === $this->id;
}
if ($entity->isA('page') && $this->isA('chapter')) {
if ($entity instanceof Page && $this instanceof Chapter) {
return $entity->chapter_id === $this->id;
}
@@ -210,6 +211,8 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/**
* Check if this instance or class is a certain type of entity.
* Examples of $type are 'page', 'book', 'chapter'.
*
* @deprecated Use instanceof instead.
*/
public static function isA(string $type): bool
{

View File

@@ -63,10 +63,8 @@ class PageRevision extends Model
/**
* Get the previous revision for the same page if existing.
*
* @return \BookStack\Entities\PageRevision|null
*/
public function getPrevious()
public function getPrevious(): ?PageRevision
{
$id = static::newQuery()->where('page_id', '=', $this->page_id)
->where('id', '<', $this->id)
@@ -84,11 +82,9 @@ class PageRevision extends Model
* Included here to align with entities in similar use cases.
* (Yup, Bit of an awkward hack).
*
* @param $type
*
* @return bool
* @deprecated Use instanceof instead.
*/
public static function isA($type)
public static function isA(string $type): bool
{
return $type === 'revision';
}

View File

@@ -67,10 +67,12 @@ class BaseRepo
/**
* Update the given items' cover image, or clear it.
*
* @param Entity&HasCoverImage $entity
*
* @throws ImageUploadException
* @throws \Exception
*/
public function updateCoverImage(HasCoverImage $entity, ?UploadedFile $coverImage, bool $removeImage = false)
public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false)
{
if ($coverImage) {
$this->imageRepo->destroyImage($entity->cover);

View File

@@ -69,9 +69,10 @@ class PageRepo
*/
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
{
/** @var ?PageRevision $revision */
$revision = PageRevision::query()
->whereHas('page', function (Builder $query) {
$query->visible();
$query->scopes('visible');
})
->where('slug', '=', $pageSlug)
->where('type', '=', 'version')
@@ -80,7 +81,7 @@ class PageRepo
->with('page')
->first();
return $revision ? $revision->page : null;
return $revision->page ?? null;
}
/**
@@ -290,6 +291,8 @@ class PageRepo
public function restoreRevision(Page $page, int $revisionId): Page
{
$page->revision_count++;
/** @var PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
$page->fill($revision->toArray());
@@ -334,7 +337,8 @@ class PageRepo
}
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
$page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id);
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
$page->changeBook($newBookId);
$page->rebuildPermissions();
Activity::addForEntity($page, ActivityType::PAGE_MOVE);
@@ -406,7 +410,7 @@ class PageRepo
*/
protected function changeParent(Page $page, Entity $parent)
{
$book = ($parent instanceof Book) ? $parent : $parent->book;
$book = ($parent instanceof Chapter) ? $parent->book : $parent;
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
$page->save();
@@ -467,6 +471,7 @@ class PageRepo
{
$parent = $page->getParent();
if ($parent instanceof Chapter) {
/** @var ?Page $lastPage */
$lastPage = $parent->pages('desc')->first();
return $lastPage ? $lastPage->priority + 1 : 0;

View File

@@ -67,7 +67,7 @@ class BookContents
$all->each(function (Entity $entity) use ($renderPages) {
$entity->setRelation('book', $this->book);
if ($renderPages && $entity->isA('page')) {
if ($renderPages && $entity instanceof Page) {
$entity->html = (new PageContent($entity))->render();
}
});
@@ -151,7 +151,7 @@ class BookContents
$priorityChanged = intval($model->priority) !== intval($sortMapItem->sort);
$bookChanged = intval($model->book_id) !== intval($sortMapItem->book);
$chapterChanged = ($sortMapItem->type === 'page') && intval($model->chapter_id) !== $sortMapItem->parentChapter;
$chapterChanged = ($model instanceof Page) && intval($model->chapter_id) !== $sortMapItem->parentChapter;
if ($bookChanged) {
$model->changeBook($sortMapItem->book);

View File

@@ -7,21 +7,24 @@ use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Uploads\ImageService;
use DomPDF;
use DOMDocument;
use DOMElement;
use DOMXPath;
use Exception;
use SnappyPDF;
use Throwable;
class ExportFormatter
{
protected $imageService;
protected $pdfGenerator;
/**
* ExportService constructor.
*/
public function __construct(ImageService $imageService)
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator)
{
$this->imageService = $imageService;
$this->pdfGenerator = $pdfGenerator;
}
/**
@@ -139,16 +142,40 @@ class ExportFormatter
*/
protected function htmlToPdf(string $html): string
{
$containedHtml = $this->containHtml($html);
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
if ($useWKHTML) {
$pdf = SnappyPDF::loadHTML($containedHtml);
$pdf->setOption('print-media-type', true);
} else {
$pdf = DomPDF::loadHTML($containedHtml);
$html = $this->containHtml($html);
$html = $this->replaceIframesWithLinks($html);
return $this->pdfGenerator->fromHtml($html);
}
/**
* Within the given HTML content, replace any iframe elements
* with anchor links within paragraph blocks.
*/
protected function replaceIframesWithLinks(string $html): string
{
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$iframes = $xPath->query('//iframe');
/** @var DOMElement $iframe */
foreach ($iframes as $iframe) {
$link = $iframe->getAttribute('src');
if (strpos($link, '//') === 0) {
$link = 'https:' . $link;
}
$anchor = $doc->createElement('a', $link);
$anchor->setAttribute('href', $link);
$paragraph = $doc->createElement('p');
$paragraph->appendChild($anchor);
$iframe->parentNode->replaceChild($paragraph, $iframe);
}
return $pdf->output();
return $doc->saveHTML();
}
/**

View File

@@ -64,7 +64,7 @@ class NextPreviousContentLocator
/** @var Entity $item */
foreach ($bookTree->all() as $item) {
$flatOrdered->push($item);
$childPages = $item->visible_pages ?? [];
$childPages = $item->getAttribute('visible_pages') ?? [];
$flatOrdered = $flatOrdered->concat($childPages);
}

View File

@@ -12,6 +12,8 @@ use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService;
use BookStack\Util\HtmlContentFilter;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMNodeList;
use DOMXPath;
use Illuminate\Support\Str;
@@ -156,7 +158,7 @@ class PageContent
/**
* Parse a base64 image URI into the data and extension.
*
* @return array{extension: array, data: string}
* @return array{extension: string, data: string}
*/
protected function parseBase64ImageUri(string $uri): array
{
@@ -193,6 +195,15 @@ class PageContent
}
}
// Set ids on nested header nodes
$nestedHeaders = $xPath->query('//body//*//h1|//body//*//h2|//body//*//h3|//body//*//h4|//body//*//h5|//body//*//h6');
foreach ($nestedHeaders as $nestedHeader) {
[$oldId, $newId] = $this->setUniqueId($nestedHeader, $idMap);
if ($newId && $newId !== $oldId) {
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
}
}
// Ensure no duplicate ids within child items
$idElems = $xPath->query('//body//*//*[@id]');
foreach ($idElems as $domElem) {
@@ -228,9 +239,9 @@ class PageContent
* A map for existing ID's should be passed in to check for current existence.
* Returns a pair of strings in the format [old_id, new_id].
*/
protected function setUniqueId(\DOMNode $element, array &$idMap): array
protected function setUniqueId(DOMNode $element, array &$idMap): array
{
if (get_class($element) !== 'DOMElement') {
if (!$element instanceof DOMElement) {
return ['', ''];
}
@@ -242,7 +253,7 @@ class PageContent
return [$existingId, $existingId];
}
// Create an unique id for the element
// Create a unique id for the element
// Uses the content as a basis to ensure output is the same every time
// the same content is passed through.
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
@@ -312,7 +323,7 @@ class PageContent
*/
protected function headerNodesToLevelList(DOMNodeList $nodeList): array
{
$tree = collect($nodeList)->map(function ($header) {
$tree = collect($nodeList)->map(function (DOMElement $header) {
$text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
$text = mb_substr($text, 0, 100);

View File

@@ -0,0 +1,26 @@
<?php
namespace BookStack\Entities\Tools;
use Barryvdh\DomPDF\Facade as DomPDF;
use Barryvdh\Snappy\Facades\SnappyPdf;
class PdfGenerator
{
/**
* Generate PDF content from the given HTML content.
*/
public function fromHtml(string $html): string
{
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
if ($useWKHTML) {
$pdf = SnappyPDF::loadHTML($html);
$pdf->setOption('print-media-type', true);
} else {
$pdf = DomPDF::loadHTML($html);
}
return $pdf->output();
}
}

View File

@@ -9,6 +9,7 @@ use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\SearchTerm;
use DOMDocument;
use DOMNode;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class SearchIndex
@@ -67,7 +68,7 @@ class SearchIndex
* - The number that have been processed so far.
* - The total number of that model to be processed.
*
* @param callable(Entity, int, int)|null $progressCallback
* @param callable(Entity, int, int):void|null $progressCallback
*/
public function indexAllEntities(?callable $progressCallback = null)
{
@@ -76,7 +77,9 @@ class SearchIndex
foreach ($this->entityProvider->all() as $entityModel) {
$indexContentField = $entityModel instanceof Page ? 'html' : 'description';
$selectFields = ['id', 'name', $indexContentField];
$total = $entityModel->newQuery()->withTrashed()->count();
/** @var Builder<Entity> $query */
$query = $entityModel->newQuery();
$total = $query->withTrashed()->count();
$chunkSize = 250;
$processed = 0;
@@ -223,7 +226,7 @@ class SearchIndex
if ($entity instanceof Page) {
$bodyTermsMap = $this->generateTermScoreMapFromHtml($entity->html);
} else {
$bodyTermsMap = $this->generateTermScoreMapFromText($entity->description ?? '', $entity->searchFactor);
$bodyTermsMap = $this->generateTermScoreMapFromText($entity->getAttribute('description') ?? '', $entity->searchFactor);
}
$mergedScoreMap = $this->mergeTermScoreMaps($nameTermsMap, $bodyTermsMap, $tagTermsMap);

View File

@@ -9,6 +9,7 @@ use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\SearchTerm;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -144,13 +145,13 @@ class SearchRunner
if ($entityModelInstance instanceof BookChild) {
$relations['book'] = function (BelongsTo $query) {
$query->visible();
$query->scopes('visible');
};
}
if ($entityModelInstance instanceof Page) {
$relations['chapter'] = function (BelongsTo $query) {
$query->visible();
$query->scopes('visible');
};
}
@@ -356,7 +357,9 @@ class SearchRunner
// We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
// search the value as a string which prevents being able to do number-based operations
// on the tag values. We ensure it has a numeric value and then cast it just to be sure.
$tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
/** @var Connection $connection */
$connection = $query->getConnection();
$tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'");
$query->whereRaw("value ${tagOperator} ${tagValue}");
} else {
$query->where('value', $tagOperator, $tagValue);

View File

@@ -5,6 +5,7 @@ namespace BookStack\Entities\Tools;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use Illuminate\Support\Collection;
@@ -24,7 +25,7 @@ class SiblingFetcher
}
// Page in book or chapter
if (($entity instanceof Page && !$entity->chapter) || $entity->isA('chapter')) {
if (($entity instanceof Page && !$entity->chapter) || $entity instanceof Chapter) {
$entities = $entity->book->getDirectChildren();
}

View File

@@ -15,6 +15,7 @@ use BookStack\Facades\Activity;
use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\ImageService;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class TrashCan
@@ -141,11 +142,9 @@ class TrashCan
{
$count = 0;
$pages = $chapter->pages()->withTrashed()->get();
if (count($pages)) {
foreach ($pages as $page) {
$this->destroyPage($page);
$count++;
}
foreach ($pages as $page) {
$this->destroyPage($page);
$count++;
}
$this->destroyCommonRelations($chapter);
@@ -183,9 +182,10 @@ class TrashCan
{
$counts = [];
/** @var Entity $instance */
foreach ((new EntityProvider())->all() as $key => $instance) {
$counts[$key] = $instance->newQuery()->onlyTrashed()->count();
/** @var Builder<Entity> $query */
$query = $instance->newQuery();
$counts[$key] = $query->onlyTrashed()->count();
}
return $counts;
@@ -235,13 +235,15 @@ class TrashCan
{
$shouldRestore = true;
$restoreCount = 0;
$parent = $deletion->deletable->getParent();
if ($parent && $parent->trashed()) {
$shouldRestore = false;
if ($deletion->deletable instanceof Entity) {
$parent = $deletion->deletable->getParent();
if ($parent && $parent->trashed()) {
$shouldRestore = false;
}
}
if ($shouldRestore) {
if ($deletion->deletable instanceof Entity && $shouldRestore) {
$restoreCount = $this->restoreEntity($deletion->deletable);
}
@@ -342,9 +344,9 @@ class TrashCan
$entity->deletions()->delete();
$entity->favourites()->delete();
if ($entity instanceof HasCoverImage && $entity->cover) {
if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
$imageService = app()->make(ImageService::class);
$imageService->destroy($entity->cover);
$imageService->destroy($entity->cover()->first());
}
}
}

View File

@@ -4,6 +4,7 @@ namespace BookStack\Exceptions;
use Exception;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -75,15 +76,20 @@ class Handler extends ExceptionHandler
/**
* Render an exception when the API is in use.
*/
protected function renderApiException(Exception $e): JsonResponse
protected function renderApiException(Throwable $e): JsonResponse
{
$code = $e->getCode() === 0 ? 500 : $e->getCode();
$code = 500;
$headers = [];
if ($e instanceof HttpException) {
$code = $e->getStatusCode();
$headers = $e->getHeaders();
}
if ($e instanceof ModelNotFoundException) {
$code = 404;
}
$responseData = [
'error' => [
'message' => $e->getMessage(),

View File

@@ -75,7 +75,7 @@ class BookshelfApiController extends ApiController
$shelf = Bookshelf::visible()->with([
'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
'books' => function (BelongsToMany $query) {
$query->visible()->get(['id', 'name', 'slug']);
$query->scopes('visible')->get(['id', 'name', 'slug']);
},
])->findOrFail($id);

View File

@@ -70,7 +70,7 @@ class ChapterApiController extends ApiController
public function read(string $id)
{
$chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
$query->visible()->get(['id', 'name', 'slug']);
$query->scopes('visible')->get(['id', 'name', 'slug']);
}])->findOrFail($id);
return response()->json($chapter);

View File

@@ -114,7 +114,7 @@ class BookController extends Controller
{
$book = $this->bookRepo->getBySlug($slug);
$bookChildren = (new BookContents($book))->getTree(true);
$bookParentShelves = $book->shelves()->visible()->get();
$bookParentShelves = $book->shelves()->scopes('visible')->get();
View::incrementFor($book);
if ($request->has('shelf')) {

View File

@@ -39,7 +39,7 @@ class HomeController extends Controller
$recentlyUpdatedPages = Page::visible()->with('book')
->where('draft', false)
->orderBy('updated_at', 'desc')
->take($favourites->count() > 0 ? 6 : 12)
->take($favourites->count() > 0 ? 5 : 10)
->select(Page::$listAttributes)
->get();

View File

@@ -58,6 +58,7 @@ class RecycleBinController extends Controller
$searching = false;
}
}
/** @var ?Deletion $parentDeletion */
$parentDeletion = ($currentDeletable === $deletion->deletable) ? null : $currentDeletable->deletions()->first();

View File

@@ -35,7 +35,7 @@ class ApiAuthenticate
// Return if the user is already found to be signed in via session-based auth.
// This is to make it easy to browser the API via browser after just logging into the system.
if (signedInUser() || session()->isStarted()) {
if (!user()->can('access-api')) {
if (!$this->sessionUserHasApiAccess()) {
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
}
@@ -49,6 +49,16 @@ class ApiAuthenticate
auth()->authenticate();
}
/**
* Check if the active session user has API access.
*/
protected function sessionUserHasApiAccess(): bool
{
$hasApiPermission = user()->can('access-api');
return $hasApiPermission && hasAppAccess();
}
/**
* Provide a standard API unauthorised response.
*/

View File

@@ -0,0 +1,14 @@
<?php
namespace BookStack\Interfaces;
use Illuminate\Database\Eloquent\Relations\MorphMany;
/**
* A model that can be deleted in a manner that deletions
* are tracked to be part of the recycle bin system.
*/
interface Deletable
{
public function deletions(): MorphMany;
}

View File

@@ -3,6 +3,9 @@
namespace BookStack\Theming;
use BookStack\Auth\Access\SocialAuthService;
use Illuminate\Console\Application;
use Illuminate\Console\Application as Artisan;
use Symfony\Component\Console\Command\Command;
class ThemeService
{
@@ -43,6 +46,16 @@ class ThemeService
return null;
}
/**
* Register a new custom artisan command to be available.
*/
public function registerCommand(Command $command)
{
Artisan::starting(function (Application $application) use ($command) {
$application->addCommands([$command]);
});
}
/**
* Read any actions from the set theme path if the 'functions.php' file exists.
*/

View File

@@ -103,7 +103,10 @@ class ImageRepo
if ($filterType === 'page') {
$query->where('uploaded_to', '=', $contextPage->id);
} elseif ($filterType === 'book') {
$validPageIds = $contextPage->book->pages()->visible()->pluck('id')->toArray();
$validPageIds = $contextPage->book->pages()
->scopes('visible')
->pluck('id')
->toArray();
$query->whereIn('uploaded_to', $validPageIds);
}
};

View File

@@ -6,6 +6,8 @@ WARNING: This system is currently in alpha so may incur changes. Once we've gath
## Getting Started
*[Video Guide](https://www.youtube.com/watch?v=YVbpm_35crQ)*
This makes use of the theme system. Create a folder for your theme within your BookStack `themes` directory. As an example we'll use `my_theme`, so we'd create a `themes/my_theme` folder.
You'll need to tell BookStack to use your theme via the `APP_THEME` option in your `.env` file. For example: `APP_THEME=my_theme`.
@@ -50,6 +52,23 @@ This method allows you to register a custom social authentication driver within
*See "Custom Socialite Service Example" below.*
### `Theme::registerCommand`
This method allows you to register a custom command which can then be used via the artisan console.
**Arguments**
- string $driverName
- array $config
- string $socialiteHandler
**Example**
*See "Custom Command Registration Example" below for a more detailed example.*
```php
Theme::registerCommand(new SayHelloCommand());
```
## Available Events
All available events dispatched by BookStack are exposed as static properties on the `\BookStack\Theming\ThemeEvents` class, which can be found within the file `app/Theming/ThemeEvents.php` relative to your root BookStack folder. Alternatively, the events for the latest release can be [seen on GitHub here](https://github.com/BookStackApp/BookStack/blob/release/app/Theming/ThemeEvents.php).
@@ -77,6 +96,33 @@ Theme::listen(ThemeEvents::APP_BOOT, function($app) {
});
```
## Custom Command Registration Example
The logical theme system supports adding custom [artisan commands](https://laravel.com/docs/8.x/artisan) to BookStack.
These can be registered in your `functions.php` file by calling `Theme::registerCommand($command)`, where `$command` is an instance of `\Symfony\Component\Console\Command\Command`.
Below is an example of registering a command that could then be ran using `php artisan bookstack:meow` on the command line.
```php
<?php
use BookStack\Facades\Theme;
use Illuminate\Console\Command;
class MeowCommand extends Command
{
protected $signature = 'bookstack:meow';
protected $description = 'Say meow on the command line';
public function handle()
{
$this->line('Meow there!');
}
}
Theme::registerCommand(new MeowCommand);
```
## Custom Socialite Service Example
The below shows an example of adding a custom reddit socialite service to BookStack.

View File

@@ -6,6 +6,8 @@ This theme system itself is maintained and supported but usages of this system,
## Getting Started
*[Video Guide](https://www.youtube.com/watch?v=gLy_2GBse48)*
This makes use of the theme system. Create a folder for your theme within your BookStack `themes` directory. As an example we'll use `my_theme`, so we'd create a `themes/my_theme` folder.
You'll need to tell BookStack to use your theme via the `APP_THEME` option in your `.env` file. For example: `APP_THEME=my_theme`.
@@ -28,4 +30,4 @@ As an example, Say I wanted to change 'Search' to 'Find'; Within a `themes/<them
return [
'search' => 'find',
];
```
```

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -54,7 +54,7 @@ return [
'email_confirm_text' => 'Palun kinnita oma e-posti aadress, klõpsates alloleval nupul:',
'email_confirm_action' => 'Kinnita e-posti aadress',
'email_confirm_send_error' => 'E-posti aadressi kinnitamine on vajalik, aga e-kirja saatmine ebaõnnestus. Võta ühendust administraatoriga.',
'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
'email_confirm_success' => 'E-posti aadress on kinnitatud! Nüüd saad selle aadressiga sisse logida.',
'email_confirm_resent' => 'Kinnituskiri on saadetud, vaata oma postkasti.',
'email_not_confirmed' => 'E-posti aadress ei ole kinnitatud',
@@ -71,7 +71,7 @@ return [
'user_invite_page_welcome' => 'Tere tulemast rakendusse :appName!',
'user_invite_page_text' => 'Registreerumise lõpetamiseks ja ligipääsu saamiseks pead seadma parooli, millega edaspidi rakendusse sisse logid.',
'user_invite_page_confirm_button' => 'Kinnita parool',
'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
'user_invite_success_login' => 'Parool seatud, nüüd on sul selle parooli abil ligipääs rakendusele :appName!',
// Multi-factor Authentication
'mfa_setup' => 'Seadista mitmeastmeline autentimine',

View File

@@ -54,7 +54,7 @@ return [
'email_confirm_text' => 'Conferma il tuo indirizzo email cliccando il pulsante sotto:',
'email_confirm_action' => 'Conferma Email',
'email_confirm_send_error' => 'La conferma della mail è richiesta ma non è stato possibile mandare la mail. Contatta l\'amministratore.',
'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
'email_confirm_success' => 'La tua email è stata confermata! Ora dovresti essere in grado di effettuare il login utilizzando questo indirizzo email.',
'email_confirm_resent' => 'Mail di conferma reinviata, controlla la tua posta.',
'email_not_confirmed' => 'Indirizzo Email Non Confermato',
@@ -71,7 +71,7 @@ return [
'user_invite_page_welcome' => 'Benvenuto in :appName!',
'user_invite_page_text' => 'Per completare il tuo account e ottenere l\'accesso devi impostare una password che verrà utilizzata per accedere a :appName in futuro.',
'user_invite_page_confirm_button' => 'Conferma Password',
'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
'user_invite_success_login' => 'Password impostata, ora dovresti essere in grado di effettuare il login utilizzando la password impostata per accedere a :appName!',
// Multi-factor Authentication
'mfa_setup' => 'Imposta Autenticazione Multi-Fattore',

View File

@@ -45,8 +45,8 @@ return [
'unfavourite' => 'Rimuovi dai preferiti',
'next' => 'Successivo',
'previous' => 'Precedente',
'filter_active' => 'Active Filter:',
'filter_clear' => 'Clear Filter',
'filter_active' => 'Filtro attivo:',
'filter_clear' => 'Pulisci filtro',
// Sort Options
'sort_options' => 'Opzioni Ordinamento',

View File

@@ -258,16 +258,16 @@ return [
'tags_explain' => "Aggiungi tag per categorizzare meglio il contenuto. \n Puoi assegnare un valore ai tag per una migliore organizzazione.",
'tags_add' => 'Aggiungi un altro tag',
'tags_remove' => 'Rimuovi questo tag',
'tags_usages' => 'Total tag usages',
'tags_assigned_pages' => 'Assigned to Pages',
'tags_assigned_chapters' => 'Assigned to Chapters',
'tags_assigned_books' => 'Assigned to Books',
'tags_assigned_shelves' => 'Assigned to Shelves',
'tags_usages' => 'Utilizzo totale dei tag',
'tags_assigned_pages' => 'Assegnato alle Pagine',
'tags_assigned_chapters' => 'Assegnato ai capitoli',
'tags_assigned_books' => 'Assegnato a Libri',
'tags_assigned_shelves' => 'Assegnato alle Librerie',
'tags_x_unique_values' => ':count unique values',
'tags_all_values' => 'All values',
'tags_view_tags' => 'View Tags',
'tags_view_existing_tags' => 'View existing tags',
'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
'tags_all_values' => 'Tutti i valori',
'tags_view_tags' => 'Visualizza tag',
'tags_view_existing_tags' => 'Usa i tag esistenti',
'tags_list_empty_hint' => 'I tag possono essere assegnati tramite la barra laterale dell\'editor di pagina o durante la modifica dei dettagli di un libro, capitolo o libreria.',
'attachments' => 'Allegati',
'attachments_explain' => 'Carica alcuni file o allega link per visualizzarli nella pagina. Questi sono visibili nella sidebar della pagina.',
'attachments_explain_instant_save' => 'I cambiamenti qui sono salvati istantaneamente.',

View File

@@ -38,7 +38,7 @@ return [
'app_homepage_desc' => 'Selecione uma opção para ser exibida como página inicial em vez da padrão. Permissões de página serão ignoradas para as páginas selecionadas.',
'app_homepage_select' => 'Selecione uma página',
'app_footer_links' => 'Links do Rodapé',
'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
'app_footer_links_desc' => 'Adicionar links para mostrar dentro do rodapé do site. Estes serão exibidos na parte inferior da maioria das páginas, incluindo aqueles que não necessitam de login. Você pode usar uma etiqueta de "trans::<key>" para usar traduções definidas pelo sistema. Por exemplo: Usando "trans::common.privacy_policy" fornecerá o texto traduzido "Política de Privacidade" e "trans::common.terms_of_service" fornecerá o texto traduzido "Termos de Serviço".',
'app_footer_links_label' => 'Etiqueta do Link',
'app_footer_links_url' => 'URL do Link',
'app_footer_links_add' => 'Adicionar Link de Rodapé',

View File

@@ -15,7 +15,7 @@ return [
'alpha_dash' => 'O campo :attribute deve conter apenas letras, números, traços e underlines.',
'alpha_num' => 'O campo :attribute deve conter apenas letras e números.',
'array' => 'O campo :attribute deve ser uma array.',
'backup_codes' => 'The provided code is not valid or has already been used.',
'backup_codes' => 'O código fornecido não é válido ou já foi usado.',
'before' => 'O campo :attribute deve ser uma data anterior à data :date.',
'between' => [
'numeric' => 'O campo :attribute deve estar entre :min e :max.',
@@ -99,7 +99,7 @@ return [
],
'string' => 'O campo :attribute deve ser uma string.',
'timezone' => 'O campo :attribute deve conter uma timezone válida.',
'totp' => 'The provided code is not valid or has expired.',
'totp' => 'O código fornecido não é válido ou expirou.',
'unique' => 'Já existe um campo/dado de nome :attribute.',
'url' => 'O formato da URL :attribute é inválido.',
'uploaded' => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.',

View File

@@ -54,7 +54,7 @@ return [
'email_confirm_text' => 'Пожалуйста, подтвердите свой адрес электронной почты нажав на кнопку ниже:',
'email_confirm_action' => 'Подтвердить адрес электронной почты',
'email_confirm_send_error' => 'Требуется подтверждение электронной почты, но система не может отправить письмо. Свяжитесь с администратором, чтобы убедиться, что адрес электронной почты настроен правильно.',
'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
'email_confirm_success' => 'Ваш адрес электронной почты был подтвержден! Теперь вы можете войти в систему, используя этот адрес электронной почты.',
'email_confirm_resent' => 'Письмо с подтверждение выслано снова. Пожалуйста, проверьте ваш почтовый ящик.',
'email_not_confirmed' => 'Адрес электронной почты не подтвержден',
@@ -71,7 +71,7 @@ return [
'user_invite_page_welcome' => 'Добро пожаловать в :appName!',
'user_invite_page_text' => 'Завершите настройку аккаунта, установите пароль для дальнейшего входа в :appName.',
'user_invite_page_confirm_button' => 'Подтвердите пароль',
'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
'user_invite_success_login' => 'Пароль установлен, теперь вы можете войти в систему, используя установленный пароль для доступа к :appName!',
// Multi-factor Authentication
'mfa_setup' => 'Двухфакторная аутентификация',

View File

@@ -39,14 +39,14 @@ return [
'reset' => 'Сбросить',
'remove' => 'Удалить',
'add' => 'Добавить',
'configure' => 'Configure',
'configure' => 'Настройка',
'fullscreen' => 'На весь экран',
'favourite' => 'Избранное',
'unfavourite' => 'Убрать из избранного',
'next' => 'Следующая',
'previous' => 'Предыдущая',
'filter_active' => 'Active Filter:',
'filter_clear' => 'Clear Filter',
'filter_active' => 'Активный фильтр:',
'filter_clear' => 'Сбросить фильтр',
// Sort Options
'sort_options' => 'Параметры сортировки',

View File

@@ -36,7 +36,7 @@ return [
'export_html' => 'Веб файл',
'export_pdf' => 'PDF файл',
'export_text' => 'Текстовый файл',
'export_md' => 'Markdown File',
'export_md' => 'Файл Markdown',
// Permissions and restrictions
'permissions' => 'Разрешения',
@@ -99,7 +99,7 @@ return [
'shelves_permissions' => 'Доступы к книжной полке',
'shelves_permissions_updated' => 'Доступы к книжной полке обновлены',
'shelves_permissions_active' => 'Действующие разрешения книжной полки',
'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
'shelves_permissions_cascade_warning' => 'Разрешения на полки не наследуются автоматически содержащимся в них книгам. Это происходит потому, что книга может находиться на нескольких полках. Однако разрешения могут быть установлены для книг полки с помощью опции, приведенной ниже.',
'shelves_copy_permissions_to_books' => 'Наследовать доступы книгам',
'shelves_copy_permissions' => 'Копировать доступы',
'shelves_copy_permissions_explain' => 'Это применит текущие настройки доступов этой книжной полки ко всем книгам, содержащимся внутри. Перед активацией убедитесь, что все изменения в доступах этой книжной полки сохранены.',
@@ -234,7 +234,7 @@ return [
'pages_initial_name' => 'Новая страница',
'pages_editing_draft_notification' => 'В настоящее время вы редактируете черновик, который был сохранён :timeDiff.',
'pages_draft_edited_notification' => 'Эта страница была обновлена до этого момента. Рекомендуется отменить этот черновик.',
'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
'pages_draft_page_changed_since_creation' => 'Эта страница была обновлена с момента создания данного черновика. Рекомендуется выбросить этот черновик или следить за тем, чтобы не перезаписать все изменения на странице.',
'pages_draft_edit_active' => [
'start_a' => ':count пользователей начали редактирование этой страницы',
'start_b' => ':userName начал редактирование этой страницы',
@@ -258,16 +258,16 @@ return [
'tags_explain' => "Добавьте теги, чтобы лучше классифицировать ваш контент. \\n Вы можете присвоить значение тегу для более глубокой организации.",
'tags_add' => 'Добавить тег',
'tags_remove' => 'Удалить этот тег',
'tags_usages' => 'Total tag usages',
'tags_assigned_pages' => 'Assigned to Pages',
'tags_assigned_chapters' => 'Assigned to Chapters',
'tags_assigned_books' => 'Assigned to Books',
'tags_assigned_shelves' => 'Assigned to Shelves',
'tags_x_unique_values' => ':count unique values',
'tags_all_values' => 'All values',
'tags_view_tags' => 'View Tags',
'tags_view_existing_tags' => 'View existing tags',
'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
'tags_usages' => 'Всего использовано тегов',
'tags_assigned_pages' => 'Назначено на страницы',
'tags_assigned_chapters' => 'Назначено на главы',
'tags_assigned_books' => 'Назначено на книги',
'tags_assigned_shelves' => 'Назначено на полки',
'tags_x_unique_values' => 'Уникальные значения: :count',
'tags_all_values' => 'Все значения',
'tags_view_tags' => 'Посмотреть теги',
'tags_view_existing_tags' => 'Просмотр имеющихся тегов',
'tags_list_empty_hint' => 'Теги можно присваивать через боковую панель редактора страниц или при редактировании сведений о книге, главе или полке.',
'attachments' => 'Вложения',
'attachments_explain' => 'Загрузите несколько файлов или добавьте ссылку для отображения на своей странице. Они видны на боковой панели страницы.',
'attachments_explain_instant_save' => 'Изменения здесь сохраняются мгновенно.',

View File

@@ -23,10 +23,10 @@ return [
'saml_no_email_address' => 'Не удалось найти email для этого пользователя в данных, предоставленных внешней системой аутентификации',
'saml_invalid_response_id' => 'Запрос от внешней системы аутентификации не распознается процессом, запущенным этим приложением. Переход назад после входа в систему может вызвать эту проблему.',
'saml_fail_authed' => 'Вход с помощью :system не удался, система не предоставила успешную авторизацию',
'oidc_already_logged_in' => 'Already logged in',
'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
'oidc_already_logged_in' => 'Вход в систему уже произведен',
'oidc_user_not_registered' => 'Пользователь :name не зарегистрирован и автоматическая регистрация отключена',
'oidc_no_email_address' => 'Не удалось найти email этого пользователя в данных, предоставленных внешней системой аутентификации',
'oidc_fail_authed' => 'Вход в систему с помощью :system не удался, система не обеспечила успешную авторизацию',
'social_no_action_defined' => 'Действие не определено',
'social_login_bad_response' => "При попытке входа с :socialAccount произошла ошибка: \\n:error",
'social_account_in_use' => 'Этот :socialAccount аккаунт уже используется, попробуйте войти с параметрами :socialAccount.',

View File

@@ -92,7 +92,7 @@ return [
'recycle_bin' => 'Корзина',
'recycle_bin_desc' => 'Здесь вы можете восстановить удаленные элементы или навсегда удалить их из системы. Этот список не отфильтрован в отличие от аналогичных списков действий в системе, где применяются фильтры.',
'recycle_bin_deleted_item' => 'Удаленный элемент',
'recycle_bin_deleted_parent' => 'Parent',
'recycle_bin_deleted_parent' => 'Родительский объект',
'recycle_bin_deleted_by' => 'Удалён',
'recycle_bin_deleted_at' => 'Время удаления',
'recycle_bin_permanently_delete' => 'Удалить навсегда',
@@ -105,7 +105,7 @@ return [
'recycle_bin_restore_list' => 'Элементы для восстановления',
'recycle_bin_restore_confirm' => 'Это действие восстановит удаленный элемент, включая дочерние, в исходное место. Если исходное место было удалено и теперь находится в корзине, родительский элемент также необходимо будет восстановить.',
'recycle_bin_restore_deleted_parent' => 'Родитель этого элемента также был удален. Элементы будут удалены до тех пор, пока этот родитель не будет восстановлен.',
'recycle_bin_restore_parent' => 'Restore Parent',
'recycle_bin_restore_parent' => 'Восстановить родительский объект',
'recycle_bin_destroy_notification' => 'Удалено :count элементов из корзины.',
'recycle_bin_restore_notification' => 'Восстановлено :count элементов из корзины',
@@ -139,7 +139,7 @@ return [
'role_details' => 'Детали роли',
'role_name' => 'Название роли',
'role_desc' => 'Краткое описание роли',
'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
'role_mfa_enforced' => 'Требует многофакторной аутентификации',
'role_external_auth_id' => 'Внешние ID авторизации',
'role_system' => 'Системные разрешения',
'role_manage_users' => 'Управление пользователями',
@@ -149,7 +149,7 @@ return [
'role_manage_page_templates' => 'Управление шаблонами страниц',
'role_access_api' => 'Доступ к системному API',
'role_manage_settings' => 'Управление настройками приложения',
'role_export_content' => 'Export content',
'role_export_content' => 'Экспорт контента',
'role_asset' => 'Права доступа к материалам',
'roles_system_warning' => 'Имейте в виду, что доступ к любому из указанных выше трех разрешений может позволить пользователю изменить свои собственные привилегии или привилегии других пользователей системы. Назначать роли с этими правами можно только доверенным пользователям.',
'role_asset_desc' => 'Эти разрешения контролируют доступ по умолчанию к параметрам внутри системы. Разрешения на книги, главы и страницы перезапишут эти разрешения.',
@@ -209,7 +209,7 @@ return [
'users_api_tokens_docs' => 'Документация',
'users_mfa' => 'Двухфакторная аутентификация',
'users_mfa_desc' => 'Двухфакторная аутентификация повышает степень безопасности вашей учетной записи.',
'users_mfa_x_methods' => ':count method configured|:count methods configured',
'users_mfa_x_methods' => 'методов настроено :count|методов сконфигурировано :count',
'users_mfa_configure' => 'Настройка методов',
// API Tokens

View File

@@ -76,12 +76,12 @@ return [
// Multi-factor Authentication
'mfa_setup' => 'Setup Multi-Factor Authentication',
'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
'mfa_setup_configured' => 'Already configured',
'mfa_setup_reconfigure' => 'Reconfigure',
'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
'mfa_setup_configured' => 'Zaten yapılandırıldı',
'mfa_setup_reconfigure' => 'Yeniden yapılandır',
'mfa_setup_remove_confirmation' => '2 adımlı doğrulamayı kaldırmak istediğinize emin misiniz?',
'mfa_setup_action' => 'Setup',
'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
'mfa_option_totp_title' => 'Mobile App',
'mfa_option_totp_title' => 'Mobil Uygulama',
'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
'mfa_option_backup_codes_title' => 'Backup Codes',
'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
@@ -106,5 +106,5 @@ return [
'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
'mfa_setup_login_notification' => '2 adımlı doğrulama ayarlandı, Lütfen 2 adımlı doğrulama kullanarak yeniden giriş yapınız.',
];

View File

@@ -44,8 +44,8 @@ return [
'bookshelf_delete_notification' => '书架已成功删除',
// Favourites
'favourite_add_notification' => '":name" 已添加到的收藏',
'favourite_remove_notification' => '":name" 已从的收藏中删除',
'favourite_add_notification' => '":name" 已添加到的收藏',
'favourite_remove_notification' => '":name" 已从的收藏中删除',
// MFA
'mfa_setup_method_notification' => '多重身份认证设置成功',

View File

@@ -54,7 +54,7 @@ return [
'email_confirm_text' => '请点击下面的按钮确认您的Email地址',
'email_confirm_action' => '确认Email',
'email_confirm_send_error' => '需要Email验证但系统无法发送电子邮件请联系网站管理员。',
'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
'email_confirm_success' => '您已成功验证电子邮件地址!您现在可以使用此电子邮件地址登录。',
'email_confirm_resent' => '验证邮件已重新发送,请检查收件箱。',
'email_not_confirmed' => 'Email地址未验证',
@@ -71,7 +71,7 @@ return [
'user_invite_page_welcome' => '欢迎来到 :appName',
'user_invite_page_text' => '要完成您的帐户并获得访问权限,您需要设置一个密码,该密码将在以后访问时用于登录 :appName。',
'user_invite_page_confirm_button' => '确认密码',
'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
'user_invite_success_login' => '密码已设置,您现在可以使用您设置的密码登录 :appName!',
// Multi-factor Authentication
'mfa_setup' => '设置多重身份认证',
@@ -92,7 +92,7 @@ return [
'mfa_gen_backup_codes_usage_warning' => '每个认证码只能使用一次',
'mfa_gen_totp_title' => '移动设备 App',
'mfa_gen_totp_desc' => '要使用多重身份认证功能,您需要一个支持 TOTP基于时间的一次性密码算法 的移动设备 App如谷歌身份验证器Google Authenticator、Authy 或微软身份验证器Microsoft Authenticator。',
'mfa_gen_totp_scan' => '要开始操作,请使用的身份验证 App 扫描下面的二维码。',
'mfa_gen_totp_scan' => '要开始操作,请使用的身份验证 App 扫描下面的二维码。',
'mfa_gen_totp_verify_setup' => '验证设置',
'mfa_gen_totp_verify_setup_desc' => '请在下面的框中输入您在身份验证 App 中生成的认证码来验证一切是否正常:',
'mfa_gen_totp_provide_code_here' => '在此输入您的 App 生成的认证码',
@@ -103,7 +103,7 @@ return [
'mfa_verify_use_totp' => '使用移动设备 App 进行认证',
'mfa_verify_use_backup_codes' => '使用备用认证码进行认证',
'mfa_verify_backup_code' => '备用认证码',
'mfa_verify_backup_code_desc' => '在下面输入的其中一个备用认证码:',
'mfa_verify_backup_code_desc' => '在下面输入的其中一个备用认证码:',
'mfa_verify_backup_code_enter_here' => '在这里输入备用认证码',
'mfa_verify_totp_desc' => '在下面输入您的移动 App 生成的认证码:',
'mfa_setup_login_notification' => '多重身份认证已设置,请使用新配置的方法重新登录。',

View File

@@ -19,7 +19,7 @@ return [
'description' => '概要',
'role' => '角色',
'cover_image' => '封面图片',
'cover_image_description' => '图像大小需要为440x250px。',
'cover_image_description' => '图像大小应约为 440x250 像素。',
// Actions
'actions' => '操作',
@@ -45,8 +45,8 @@ return [
'unfavourite' => '取消收藏',
'next' => '下一页',
'previous' => '上一页',
'filter_active' => 'Active Filter:',
'filter_clear' => 'Clear Filter',
'filter_active' => '标签过滤器:',
'filter_clear' => '清除过滤器',
// Sort Options
'sort_options' => '排序选项',
@@ -75,8 +75,8 @@ return [
// Header
'header_menu_expand' => '展开标头菜单',
'profile_menu' => '个人资料',
'view_profile' => '查看资料',
'edit_profile' => '编辑资料',
'view_profile' => '查看个人资料',
'edit_profile' => '编辑个人资料',
'dark_mode' => '夜间模式',
'light_mode' => '日间模式',

View File

@@ -254,20 +254,20 @@ return [
'tag' => '标签',
'tags' => '标签',
'tag_name' => '标签名称',
'tag_value' => '标签值 (Optional)',
'tags_explain' => "添加一些标签以更好地对您的内容进行分类。\n您可以为标签分配一个值,以进行更深入的组织",
'tag_value' => '标签值 (可选)',
'tags_explain' => "添加一些标签以更好地对您的内容进行分类。\n您可以为标签分配一个值,以进行更好的进行管理",
'tags_add' => '添加另一个标签',
'tags_remove' => '删除此标签',
'tags_usages' => 'Total tag usages',
'tags_assigned_pages' => 'Assigned to Pages',
'tags_assigned_chapters' => 'Assigned to Chapters',
'tags_assigned_books' => 'Assigned to Books',
'tags_assigned_shelves' => 'Assigned to Shelves',
'tags_x_unique_values' => ':count unique values',
'tags_all_values' => 'All values',
'tags_view_tags' => 'View Tags',
'tags_view_existing_tags' => 'View existing tags',
'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
'tags_usages' => '标签总使用量',
'tags_assigned_pages' => '有这个标签的页面',
'tags_assigned_chapters' => '有这个标签的章节',
'tags_assigned_books' => '有这个标签的图书',
'tags_assigned_shelves' => '有这个标签的书架',
'tags_x_unique_values' => ':count 个不重复项目',
'tags_all_values' => '所有值',
'tags_view_tags' => '查看标签',
'tags_view_existing_tags' => '查看已有的标签',
'tags_list_empty_hint' => '您可以在页面编辑器的侧边栏添加标签,或者在编辑图书、章节、书架时添加。',
'attachments' => '附件',
'attachments_explain' => '上传一些文件或附加一些链接显示在您的网页上。这些在页面的侧边栏中可见。',
'attachments_explain_instant_save' => '这里的更改将立即保存。',

View File

@@ -31,7 +31,7 @@ return [
'app_custom_html_desc' => '此处添加的任何内容都将插入到每个页面的<head>部分的底部,这对于覆盖样式或添加分析代码很方便。',
'app_custom_html_disabled_notice' => '在此设置页面上禁用了自定义HTML标题内容以确保可以恢复所有重大更改。',
'app_logo' => '站点Logo',
'app_logo_desc' => '这个图片的高度应该为43px。<br>大图片将会被缩小。',
'app_logo_desc' => '这个图片的高度应为 43 像素。<br>大图片将会被缩小。',
'app_primary_color' => '站点主色',
'app_primary_color_desc' => '这应该是一个十六进制值。<br>保留为空以重置为默认颜色。',
'app_homepage' => '站点主页',
@@ -151,7 +151,7 @@ return [
'role_manage_settings' => '管理App设置',
'role_export_content' => '导出内容',
'role_asset' => '资源许可',
'roles_system_warning' => '请注意,有上述三个权限中的任何一个都可以允许用户更改自己的权或系统中其他人的权。 只将有这些权限的角色分配给信任的用户。',
'roles_system_warning' => '请注意,有上述三个权限中的任何一个都可以允许用户更改自己的权或系统中其他人的权只将有这些权限的角色分配给信任的用户。',
'role_asset_desc' => '对系统内资源的默认访问许可将由这些权限控制。单独设置在书籍,章节和页面上的权限将覆盖这里的权限设定。',
'role_asset_admins' => '管理员可自动获得对所有内容的访问权限但这些选项可能会显示或隐藏UI选项。',
'role_all' => '全部的',
@@ -193,7 +193,7 @@ return [
'users_edit_profile' => '编辑资料',
'users_edit_success' => '用户更新成功',
'users_avatar' => '用户头像',
'users_avatar_desc' => '当前图片应该为约256px的正方形。',
'users_avatar_desc' => '选择一张头像。 这张图片应该是约 256 像素的正方形。',
'users_preferred_language' => '语言',
'users_preferred_language_desc' => '此选项将更改用于应用程序用户界面的语言。 这不会影响任何用户创建的内容。',
'users_social_accounts' => '社交账户',

View File

@@ -87,6 +87,20 @@
.card-title a {
line-height: 1;
}
.card-footer-link {
display: block;
padding: $-s $-m;
line-height: 1;
border-top: 1px solid;
@include lightDark(border-color, #DDD, #555);
border-radius: 0 0 3px 3px;
font-size: 0.9em;
margin-top: $-xs;
&:hover {
text-decoration: none;
@include lightDark(background-color, #f2f2f2, #2d2d2d);
}
}
.card.border-card {
border: 1px solid #DDD;
@@ -229,6 +243,9 @@
&:hover, &:focus-within {
opacity: 1;
}
@media (prefers-contrast: more) {
opacity: 1;
}
}
/**

View File

@@ -262,6 +262,9 @@ header .search-box {
&:hover, &:focus-within {
opacity: 1;
}
@media (prefers-contrast: more) {
opacity: 1;
}
}
@include smaller-than($l) {

View File

@@ -369,6 +369,9 @@ body.flexbox {
&:focus-within {
opacity: 1;
}
@media (prefers-contrast: more) {
opacity: 1;
}
}
}

View File

@@ -412,4 +412,7 @@ body.mce-fullscreen, body.markdown-fullscreen {
text-decoration: none;
opacity: 1;
}
@media (prefers-contrast: more) {
opacity: 1;
}
}

View File

@@ -44,27 +44,27 @@
<div>
@if(count($favourites) > 0)
<div id="top-favourites" class="card mb-xl">
<h3 class="card-title">
<a href="{{ url('/favourites') }}" class="no-color">{{ trans('entities.my_most_viewed_favourites') }}</a>
</h3>
<h3 class="card-title">{{ trans('entities.my_most_viewed_favourites') }}</h3>
<div class="px-m">
@include('entities.list', [
'entities' => $favourites,
'style' => 'compact',
])
</div>
<a href="{{ url('/favourites') }}" class="card-footer-link">{{ trans('common.view_all') }}</a>
</div>
@endif
<div id="recent-pages" class="card mb-xl">
<h3 class="card-title"><a class="no-color" href="{{ url("/pages/recently-updated") }}">{{ trans('entities.recently_updated_pages') }}</a></h3>
<h3 class="card-title">{{ trans('entities.recently_updated_pages') }}</h3>
<div id="recently-updated-pages" class="px-m">
@include('entities.list', [
'entities' => $recentlyUpdatedPages,
'style' => 'compact',
'emptyText' => trans('entities.no_pages_recently_updated')
'emptyText' => trans('entities.no_pages_recently_updated'),
])
</div>
<a href="{{ url("/pages/recently-updated") }}" class="card-footer-link">{{ trans('common.view_all') }}</a>
</div>
</div>

View File

@@ -7,13 +7,12 @@
@if(count($favourites) > 0)
<div id="top-favourites" class="mb-xl">
<h5>
<a href="{{ url('/favourites') }}" class="no-color">{{ trans('entities.my_most_viewed_favourites') }}</a>
</h5>
<h5>{{ trans('entities.my_most_viewed_favourites') }}</h5>
@include('entities.list', [
'entities' => $favourites,
'style' => 'compact',
])
<a href="{{ url('/favourites') }}" class="text-muted block py-xs">{{ trans('common.view_all') }}</a>
</div>
@endif
@@ -27,7 +26,7 @@
</div>
<div class="mb-xl">
<h5><a class="no-color" href="{{ url("/pages/recently-updated") }}">{{ trans('entities.recently_updated_pages') }}</a></h5>
<h5>{{ trans('entities.recently_updated_pages') }}</h5>
<div id="recently-updated-pages">
@include('entities.list', [
'entities' => $recentlyUpdatedPages,
@@ -35,6 +34,7 @@
'emptyText' => trans('entities.no_pages_recently_updated')
])
</div>
<a href="{{ url('/pages/recently-updated') }}" class="text-muted block py-xs">{{ trans('common.view_all') }}</a>
</div>
<div id="recent-activity" class="mb-xl">

View File

@@ -6,6 +6,7 @@
{{ csrf_field() }}
<input type="text"
name="code"
autofocus
placeholder="{{ trans('auth.mfa_gen_totp_provide_code_here') }}"
class="input-fill-width {{ $errors->has('code') ? 'neg' : '' }}">
@if($errors->has('code'))
@@ -14,4 +15,4 @@
<div class="mt-s text-right">
<button class="button">{{ trans('common.confirm') }}</button>
</div>
</form>
</form>

View File

@@ -3,6 +3,7 @@
namespace Tests\Api;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use Carbon\Carbon;
use Tests\TestCase;
@@ -91,6 +92,26 @@ class ApiAuthTest extends TestCase
$resp->assertJson($this->errorResponse('The owner of the used API token does not have permission to make API calls', 403));
}
public function test_access_prevented_for_guest_users_with_api_permission_while_public_access_disabled()
{
$this->disableCookieEncryption();
$publicRole = Role::getSystemRole('public');
$accessApiPermission = RolePermission::getByName('access-api');
$publicRole->attachPermission($accessApiPermission);
$this->withCookie('bookstack_session', 'abc123');
// Test API access when not public
setting()->put('app-public', false);
$resp = $this->get($this->endpoint);
$resp->assertStatus(403);
// Test API access when public
setting()->put('app-public', true);
$resp = $this->get($this->endpoint);
$resp->assertStatus(200);
}
public function test_token_expiry_checked()
{
$editor = $this->getEditor();

View File

@@ -224,6 +224,29 @@ class AttachmentsApiTest extends TestCase
unlink(storage_path($attachment->path));
}
public function test_attachment_not_visible_on_other_users_draft()
{
$this->actingAsApiAdmin();
$editor = $this->getEditor();
/** @var Page $page */
$page = Page::query()->first();
$page->draft = true;
$page->owned_by = $editor;
$page->save();
$this->regenEntityPermissions($page);
$attachment = $this->createAttachmentForPage($page, [
'name' => 'my attachment',
'path' => 'https://example.com',
'order' => 1,
]);
$resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}");
$resp->assertStatus(404);
}
public function test_update_endpoint()
{
$this->actingAsApiAdmin();

View File

@@ -23,7 +23,7 @@ class MfaVerificationTest extends TestCase
$resp = $this->get('/mfa/verify');
$resp->assertSee('Verify Access');
$resp->assertSee('Enter the code, generated using your mobile app, below:');
$resp->assertElementExists('form[action$="/mfa/totp/verify"] input[name="code"]');
$resp->assertElementExists('form[action$="/mfa/totp/verify"] input[name="code"][autofocus]');
$google2fa = new Google2FA();
$resp = $this->post('/mfa/totp/verify', [

View File

@@ -6,6 +6,7 @@ use BookStack\Auth\Role;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\PdfGenerator;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
@@ -289,6 +290,24 @@ class ExportTest extends TestCase
$resp->assertDontSee('ExportWizardTheFifth');
}
public function test_page_pdf_export_converts_iframes_to_links()
{
$page = Page::query()->first()->forceFill([
'html' => '<iframe width="560" height="315" src="//www.youtube.com/embed/ShqUjt33uOs"></iframe>',
]);
$page->save();
$pdfHtml = '';
$mockPdfGenerator = $this->mock(PdfGenerator::class);
$mockPdfGenerator->shouldReceive('fromHtml')
->with(\Mockery::capture($pdfHtml))
->andReturn('');
$this->asEditor()->get($page->getUrl('/export/pdf'));
$this->assertStringNotContainsString('iframe>', $pdfHtml);
$this->assertStringContainsString('<p><a href="https://www.youtube.com/embed/ShqUjt33uOs">https://www.youtube.com/embed/ShqUjt33uOs</a></p>', $pdfHtml);
}
public function test_page_markdown_export()
{
$page = Page::query()->first();

View File

@@ -670,4 +670,24 @@ class PageContentTest extends TestCase
$page->refresh();
$this->assertStringContainsString('<img src=""', $page->html);
}
public function test_nested_headers_gets_assigned_an_id()
{
$this->asEditor();
$page = Page::query()->first();
$content = '<table><tbody><tr><td><h5>Simple Test</h5></td></tr></tbody></table>';
$this->put($page->getUrl(), [
'name' => $page->name,
'html' => $content,
'summary' => '',
]);
$updatedPage = Page::query()->where('id', '=', $page->id)->first();
// The top level <table> node will get assign the bkmrk-simple-test id because the system will
// take the node value of h5
// So the h5 should get the bkmrk-simple-test-1 id
$this->assertStringContainsString('<h5 id="bkmrk-simple-test-1">Simple Test</h5>', $updatedPage->html);
}
}

View File

@@ -7,8 +7,10 @@ use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\PageContent;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Illuminate\Console\Command;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use League\CommonMark\ConfigurableEnvironmentInterface;
@@ -206,6 +208,16 @@ class ThemeTest extends TestCase
$this->assertStringContainsString('donkey=donut', $redirect);
}
public function test_register_command_allows_provided_command_to_be_usable_via_artisan()
{
Theme::registerCommand(new MyCustomCommand());
Artisan::call('bookstack:test-custom-command', []);
$output = Artisan::output();
$this->assertStringContainsString('Command ran!', $output);
}
protected function usingThemeFolder(callable $callback)
{
// Create a folder and configure a theme
@@ -220,3 +232,13 @@ class ThemeTest extends TestCase
File::deleteDirectory($themeFolderPath);
}
}
class MyCustomCommand extends Command
{
protected $signature = 'bookstack:test-custom-command';
public function handle()
{
$this->line('Command ran!');
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Tests\Unit;
use BadMethodCallException;
use BookStack\Entities\Models\Page;
use Tests\TestCase;
/**
* This class tests assumptions we're relying upon in the framework.
* This is primarily to keep track of certain bits of functionality that
* may be used in important areas such as to enforce permissions.
*/
class FrameworkAssumptionTest extends TestCase
{
public function test_scopes_error_if_not_existing()
{
$this->expectException(BadMethodCallException::class);
$this->expectExceptionMessage('Call to undefined method BookStack\Entities\Models\Page::scopeNotfoundscope()');
Page::query()->scopes('notfoundscope');
}
public function test_scopes_applies_upon_existing()
{
// Page has SoftDeletes trait by default, so we apply our custom scope and ensure
// it stacks on the global scope to filter out deleted items.
$query = Page::query()->scopes('visible')->toSql();
$this->assertStringContainsString('joint_permissions', $query);
$this->assertStringContainsString('`deleted_at` is null', $query);
}
}

View File

@@ -1 +1 @@
v21.11
v21.11.2