Compare commits

...

69 Commits

Author SHA1 Message Date
Dan Brown
09436836a5 Updated version and assets for release v21.12 2021-12-22 17:04:18 +00:00
Dan Brown
bb455d7788 Merge branch 'master' into release 2021-12-22 17:03:50 +00:00
Dan Brown
b0666e5d70 Updated translator contribution before v21.12 release 2021-12-22 16:30:48 +00:00
Dan Brown
fc109f7e1c Applied latest StyleCI changes 2021-12-20 17:40:27 +00:00
Dan Brown
21f2a7087c Merge pull request #3118 from BookStackApp/copy_stuff
Additional copy/clone abilities
2021-12-20 17:39:44 +00:00
Dan Brown
ff70509fca Added copy considerations
Show to the user when copying stuff to highlight important things such
as what's not copied or change in permissions.
2021-12-20 17:33:19 +00:00
Dan Brown
0288320700 Added ability to clone books 2021-12-19 19:20:31 +00:00
Dan Brown
20e093a7a1 Added ability to copy/clone chapters
Builds upon page clone work. Takes permissions into account to decide
if child pages should be copied.
2021-12-19 15:40:52 +00:00
Dan Brown
3f9527f166 Extracted page copy to new cloner class
Fundemental refactor for planned additional clone operations.
No behaviour change intended in this commit.
2021-12-19 12:56:27 +00:00
Dan Brown
da01913616 Added ability to copy a role
- Copies via loading in model on create view.
- Updated role views while editing to bring up to similar format as
  that used for more modern app areas.
- Added tests to cover.

Related to #1123
2021-12-19 12:27:14 +00:00
Dan Brown
67b6c07548 Updated failing tests, Applied StyleCI changes 2021-12-18 16:41:42 +00:00
Dan Brown
bb9cd9d610 Aligned password length requirements
Updated all password validation to use central password defaults
system while updating length requirements to now all match
at 8 characters minimum.

Some language text was technically correct (More than 7 characters)
but this has been updated for clarity and to prompt other translations
to be updated.

Closes #2237
2021-12-18 16:33:40 +00:00
Dan Brown
04f37e21e2 Applied latest StyleCI changes 2021-12-18 11:43:05 +00:00
Dan Brown
a3ead5062a Merge branch 'webhooks' 2021-12-18 11:40:08 +00:00
Dan Brown
24e29c523b Aligned notification capitalisation 2021-12-18 11:24:58 +00:00
Dan Brown
04d59763c3 Updated auditlog IP search test
To ensure the test covers filtering logic.
Related to #3081.
2021-12-18 11:05:41 +00:00
Dan Brown
5c04f25c86 Merge branch 'search-by-ip' of https://github.com/johnroyer/BookStack into johnroyer-search-by-ip 2021-12-18 10:58:07 +00:00
Dan Brown
767a82fb41 Reverted unrequired use of mb_ function 2021-12-18 10:43:43 +00:00
Dan Brown
5c5a3de7cb Merge branch 'fix/multibyte-safe-search' 2021-12-18 10:40:38 +00:00
Dan Brown
c6e3e85e82 Added test case for multibyte search highlighting
Related to #3113
2021-12-18 10:38:33 +00:00
Kristian Krastev
d0fd1b7f5c Make building of search results work for multi-byte encoded characters 2021-12-15 16:29:43 +02:00
Dan Brown
009212ab80 Updated version and assets for release v21.11.3 2021-12-15 14:08:37 +00:00
Dan Brown
ba9cb591c8 Merge branch 'master' into release 2021-12-15 14:08:17 +00:00
Dan Brown
632cb71af4 Updated translator attribution before release v21.11.3 2021-12-15 14:07:54 +00:00
Dan Brown
74ab99ec41 Updated php deps 2021-12-15 14:00:30 +00:00
Dan Brown
aa9dafec85 Altered mysql start command in workflows
Due to https://github.com/actions/virtual-environments/issues/4732
2021-12-15 13:56:21 +00:00
Dan Brown
73a37b3cd9 Applied latest StyleCI changes 2021-12-15 13:49:20 +00:00
Dan Brown
e43f679e62 Merge branch 'user_list_control' 2021-12-15 13:47:48 +00:00
Dan Brown
57fc1ba38f New Crowdin updates (#3093)
* New translations auth.php (Vietnamese)

* New translations entities.php (Norwegian Bokmal)

* New translations common.php (Norwegian Bokmal)

* New translations entities.php (Norwegian Bokmal)

* New translations auth.php (Spanish, Argentina)

* New translations entities.php (Spanish, Argentina)

* New translations errors.php (Spanish, Argentina)

* New translations auth.php (Ukrainian)

* New translations auth.php (Ukrainian)

* New translations common.php (Ukrainian)

* New translations entities.php (Ukrainian)

* New translations errors.php (Ukrainian)

* New translations settings.php (Ukrainian)

* New translations validation.php (Ukrainian)

* New translations entities.php (Japanese)

* New translations common.php (Japanese)

* New translations entities.php (Japanese)

* New translations auth.php (Portuguese)

* New translations auth.php (Portuguese)

* New translations common.php (Portuguese)

* New translations entities.php (Portuguese)
2021-12-15 13:46:49 +00:00
Dan Brown
e765e61854 Addressed user detail harvesting issue
Altered access & usage of the /search/users/select endpoint with the
following changes:
- Removed searching of email address to prevent email detail discovery
  via hunting via search queries.
- Required the user to be logged in and have permission to manage users
  or manage permissions on items in some way.
- Removed the user migration option on user delete unless they have
  permission to manage users.

For #3108
Reported in https://huntr.dev/bounties/135f2d7d-ab0b-4351-99b9-889efac46fca/
Reported by @haxatron
2021-12-14 18:47:22 +00:00
Dan Brown
d00ac3101d Allowed database queue usage where desired 2021-12-13 18:34:18 +00:00
Dan Brown
f27d0d5aeb Added testing to cover webhook calling
Migrated call logic to Laravel's HTTP client for easier testing
capabilities.
2021-12-12 19:01:50 +00:00
Dan Brown
8d8b45860a Updated REST API docs with links to webhooks & theme-systems 2021-12-12 18:14:22 +00:00
Dan Brown
3bf34b6a0d Added webhook format example to webhook management views 2021-12-12 18:02:08 +00:00
Dan Brown
dbd4281ae8 Added active toggle to webhooks
To allow easy temporary de-activation without deletion or other
workarounds. Updated tests to cover.
2021-12-12 17:39:06 +00:00
Dan Brown
917598f7c8 Added webhook call functionality 2021-12-11 22:29:33 +00:00
Dan Brown
9079700170 Refactored the activity service
- Renamed to "ActivityLogger" to be more focused in usage.
- Extracted out query elements to seperate "ActivityQueries" class.
- Removed old 'addForEntity' activity method to limit activity record
  points.
2021-12-11 17:29:33 +00:00
Dan Brown
f2cb3b94f9 Added missing migration down table drop 2021-12-10 14:58:14 +00:00
Dan Brown
6381041252 Added testing for webhook management interface 2021-12-10 14:54:58 +00:00
Zero
7d13666039 Add unit test for ip addess searching 2021-12-10 15:11:30 +08:00
Zero
e6e92618b1 Fix PHP CS 2021-12-10 14:58:05 +08:00
Zero
2342f0c1c7 Fix UI error of IP searching input box 2021-12-10 14:50:04 +08:00
Zero
ee1106630e Update translation setting in blade 2021-12-10 14:50:04 +08:00
Zero
93e80e5d4e Delete duplicated translation 2021-12-10 14:50:04 +08:00
Zero
72d19968dd Search IP by partial-equal 2021-12-10 14:50:04 +08:00
Zero
2fd7b1f0d5 Update index name to 'activities_ip_index' 2021-12-10 14:50:04 +08:00
Zero
a93254430c Add index for user IP address 2021-12-10 14:50:04 +08:00
Zero
e686b2cf3c Show current search IP 2021-12-10 14:50:04 +08:00
Zero
4e63554cc6 Add an hidden submit
This hidden submit makes auto submit while user press Enter on IP
input field.
2021-12-10 14:50:04 +08:00
Zero
882f195927 Add margin right for IP input box 2021-12-10 14:50:04 +08:00
Zero
a12e346439 Add filter of user IP 2021-12-10 14:50:04 +08:00
Zero
8dee3d3a83 Add label translation 2021-12-10 14:50:04 +08:00
Zero
0e25298db9 Fix label and input box error 2021-12-10 14:50:04 +08:00
Zero
9cac6fad73 Add IP address search field mock 2021-12-10 14:50:04 +08:00
Dan Brown
8716b1922b Completed webhook management interface
Got webhook CRUD actions in place within the interface.
Quick manual test pass done, Needs automated tests.
2021-12-08 17:35:58 +00:00
Dan Brown
4621d8bcc5 Initial controller/views for webhooks management 2021-12-08 14:29:42 +00:00
Dan Brown
a3a3055695 Started webhook implementation 2021-12-07 14:55:11 +00:00
Dan Brown
867cbe15ea Added link to OIDC docs in .env.example.complete 2021-12-07 13:45:43 +00:00
Dan Brown
b22dd3cb88 Added url and preview_html params to search API results
Allows easy direct linking and usage of the HTML preview content
we show in the UI when viewing search results.
Note: preview_html content is a rough representation only, it does not
match exactly what was matched in the database-search-operation which
finds the results.

For #3096 and #3080
2021-12-06 20:42:04 +00:00
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
132 changed files with 3623 additions and 1388 deletions

View File

@@ -100,8 +100,7 @@ MEMCACHED_SERVERS=127.0.0.1:11211:100
REDIS_SERVERS=127.0.0.1:6379:0
# Queue driver to use
# Queue not really currently used but may be configurable in the future.
# Would advise not to change this for now.
# Can be 'sync', 'database' or 'redis'
QUEUE_CONNECTION=sync
# Storage system to use
@@ -134,7 +133,7 @@ STORAGE_S3_ENDPOINT=https://my-custom-s3-compatible.service.com:8001
STORAGE_URL=false
# Authentication method to use
# Can be 'standard', 'ldap' or 'saml2'
# Can be 'standard', 'ldap', 'saml2' or 'oidc'
AUTH_METHOD=standard
# Social authentication configuration
@@ -242,6 +241,7 @@ SAML2_GROUP_ATTRIBUTE=group
SAML2_REMOVE_FROM_GROUPS=false
# OpenID Connect authentication configuration
# Refer to https://www.bookstackapp.com/docs/admin/oidc-auth/
OIDC_NAME=SSO
OIDC_DISPLAY_NAME_CLAIMS=name
OIDC_CLIENT_ID=null

View File

@@ -126,7 +126,7 @@ Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
tatsuya.info :: Japanese
fadiapp :: Arabic
Jakub Bouček (jakubboucek) :: Czech
Marco (cdrfun) :: German
Marco (cdrfun) :: German; German Informal
10935336 :: Chinese Simplified
孟繁阳 (FanyangMeng) :: Chinese Simplified
Andrej Močan (andrejm) :: Slovenian
@@ -200,3 +200,9 @@ sulfo :: Danish
Raukze :: German
zygimantus :: Lithuanian
marinkaberg :: Russian
Vitaliy (gviabcua) :: Ukrainian
mannycarreiro :: Portuguese
Thiago Rafael Pereira de Carvalho (thiago.rafael) :: Portuguese, Brazilian
Ken Roger Bolgnes (kenbo124) :: Norwegian Bokmal
Nguyen Hung Phuong (hnwolf) :: Vietnamese
Umut ERGENE (umutergene67) :: Turkish

View File

@@ -36,7 +36,7 @@ jobs:
- name: Start Database
run: |
sudo /etc/init.d/mysql start
sudo systemctl start mysql
- name: Setup Database
run: |

View File

@@ -36,7 +36,7 @@ jobs:
- name: Start MySQL
run: |
sudo /etc/init.d/mysql start
sudo systemctl start mysql
- name: Create database & user
run: |

View File

@@ -0,0 +1,115 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
class ActivityLogger
{
protected $permissionService;
public function __construct(PermissionService $permissionService)
{
$this->permissionService = $permissionService;
}
/**
* Add a generic activity event to the database.
*
* @param string|Loggable $detail
*/
public function add(string $type, $detail = '')
{
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
$activity = $this->newActivityForUser($type);
$activity->detail = $detailToStore;
if ($detail instanceof Entity) {
$activity->entity_id = $detail->id;
$activity->entity_type = $detail->getMorphClass();
}
$activity->save();
$this->setNotification($type);
$this->dispatchWebhooks($type, $detail);
}
/**
* Get a new activity instance for the current user.
*/
protected function newActivityForUser(string $type): Activity
{
$ip = request()->ip() ?? '';
return (new Activity())->forceFill([
'type' => strtolower($type),
'user_id' => user()->id,
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
]);
}
/**
* Removes the entity attachment from each of its activities
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
*/
public function removeEntity(Entity $entity)
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
]);
}
/**
* Flashes a notification message to the session if an appropriate message is available.
*/
protected function setNotification(string $type): void
{
$notificationTextKey = 'activities.' . $type . '_notification';
if (trans()->has($notificationTextKey)) {
$message = trans($notificationTextKey);
session()->flash('success', $message);
}
}
/**
* @param string|Loggable $detail
*/
protected function dispatchWebhooks(string $type, $detail): void
{
$webhooks = Webhook::query()
->whereHas('trackedEvents', function (Builder $query) use ($type) {
$query->where('event', '=', $type)
->orWhere('event', '=', 'all');
})
->where('active', '=', true)
->get();
foreach ($webhooks as $webhook) {
dispatch(new DispatchWebhookJob($webhook, $type, $detail));
}
}
/**
* Log out a failed login attempt, Providing the given username
* as part of the message if the '%u' string is used.
*/
public function logFailedLogin(string $username)
{
$message = config('logging.failed_login.message');
if (!$message) {
return;
}
$message = str_replace('%u', $username, $message);
$channel = config('logging.failed_login.channel');
Log::channel($channel)->warning($message);
}
}

View File

@@ -8,84 +8,25 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Log;
class ActivityService
class ActivityQueries
{
protected $activity;
protected $permissionService;
public function __construct(Activity $activity, PermissionService $permissionService)
public function __construct(PermissionService $permissionService)
{
$this->activity = $activity;
$this->permissionService = $permissionService;
}
/**
* Add activity data to database for an entity.
*/
public function addForEntity(Entity $entity, string $type)
{
$activity = $this->newActivityForUser($type);
$entity->activity()->save($activity);
$this->setNotification($type);
}
/**
* Add a generic activity event to the database.
*
* @param string|Loggable $detail
*/
public function add(string $type, $detail = '')
{
if ($detail instanceof Loggable) {
$detail = $detail->logDescriptor();
}
$activity = $this->newActivityForUser($type);
$activity->detail = $detail;
$activity->save();
$this->setNotification($type);
}
/**
* Get a new activity instance for the current user.
*/
protected function newActivityForUser(string $type): Activity
{
$ip = request()->ip() ?? '';
return $this->activity->newInstance()->forceFill([
'type' => strtolower($type),
'user_id' => user()->id,
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
]);
}
/**
* Removes the entity attachment from each of its activities
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
*/
public function removeEntity(Entity $entity)
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
]);
}
/**
* Gets the latest activity.
*/
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->skip($count * $page)
@@ -111,7 +52,7 @@ class ActivityService
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
}
$query = $this->activity->newQuery();
$query = Activity::query();
$query->where(function (Builder $query) use ($queryIds) {
foreach ($queryIds as $morphClass => $idArr) {
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
@@ -133,12 +74,12 @@ 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
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)
@@ -152,8 +93,6 @@ class ActivityService
* Filters out similar activity.
*
* @param Activity[] $activities
*
* @return array
*/
protected function filterSimilar(iterable $activities): array
{
@@ -170,32 +109,4 @@ class ActivityService
return $newActivity;
}
/**
* Flashes a notification message to the session if an appropriate message is available.
*/
protected function setNotification(string $type)
{
$notificationTextKey = 'activities.' . $type . '_notification';
if (trans()->has($notificationTextKey)) {
$message = trans($notificationTextKey);
session()->flash('success', $message);
}
}
/**
* Log out a failed login attempt, Providing the given username
* as part of the message if the '%u' string is used.
*/
public function logFailedLogin(string $username)
{
$message = config('logging.failed_login.message');
if (!$message) {
return;
}
$message = str_replace('%u', $username, $message);
$channel = config('logging.failed_login.channel');
Log::channel($channel)->warning($message);
}
}

View File

@@ -53,4 +53,16 @@ class ActivityType
const MFA_SETUP_METHOD = 'mfa_setup_method';
const MFA_REMOVE_METHOD = 'mfa_remove_method';
const WEBHOOK_CREATE = 'webhook_create';
const WEBHOOK_UPDATE = 'webhook_update';
const WEBHOOK_DELETE = 'webhook_delete';
/**
* Get all the possible values.
*/
public static function all(): array
{
return (new \ReflectionClass(static::class))->getConstants();
}
}

View File

@@ -45,7 +45,7 @@ class CommentRepo
$comment->parent_id = $parent_id;
$entity->comments()->save($comment);
ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
return $comment;
}

View File

@@ -0,0 +1,112 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class DispatchWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* @var Webhook
*/
protected $webhook;
/**
* @var string
*/
protected $event;
/**
* @var string|Loggable
*/
protected $detail;
/**
* @var User
*/
protected $initiator;
/**
* @var int
*/
protected $initiatedTime;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Webhook $webhook, string $event, $detail)
{
$this->webhook = $webhook;
$this->event = $event;
$this->detail = $detail;
$this->initiator = user();
$this->initiatedTime = time();
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$response = Http::asJson()
->withOptions(['allow_redirects' => ['strict' => true]])
->timeout(3)
->post($this->webhook->endpoint, $this->buildWebhookData());
if ($response->failed()) {
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
}
}
protected function buildWebhookData(): array
{
$textParts = [
$this->initiator->name,
trans('activities.' . $this->event),
];
if ($this->detail instanceof Entity) {
$textParts[] = '"' . $this->detail->name . '"';
}
$data = [
'event' => $this->event,
'text' => implode(' ', $textParts),
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
'triggered_by' => $this->initiator->attributesToArray(),
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
'webhook_id' => $this->webhook->id,
'webhook_name' => $this->webhook->name,
];
if (method_exists($this->detail, 'getUrl')) {
$data['url'] = $this->detail->getUrl();
}
if ($this->detail instanceof Model) {
$data['related_item'] = $this->detail->attributesToArray();
}
return $data;
}
}

75
app/Actions/Webhook.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
namespace BookStack\Actions;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property string $name
* @property string $endpoint
* @property Collection $trackedEvents
* @property bool $active
*/
class Webhook extends Model implements Loggable
{
protected $fillable = ['name', 'endpoint'];
use HasFactory;
/**
* Define the tracked event relation a webhook.
*/
public function trackedEvents(): HasMany
{
return $this->hasMany(WebhookTrackedEvent::class);
}
/**
* Update the tracked events for a webhook from the given list of event types.
*/
public function updateTrackedEvents(array $events): void
{
$this->trackedEvents()->delete();
$eventsToStore = array_intersect($events, array_values(ActivityType::all()));
if (in_array('all', $events)) {
$eventsToStore = ['all'];
}
$trackedEvents = [];
foreach ($eventsToStore as $event) {
$trackedEvents[] = new WebhookTrackedEvent(['event' => $event]);
}
$this->trackedEvents()->saveMany($trackedEvents);
}
/**
* Check if this webhook tracks the given event.
*/
public function tracksEvent(string $event): bool
{
return $this->trackedEvents->pluck('event')->contains($event);
}
/**
* Get a URL for this webhook within the settings interface.
*/
public function getUrl(string $path = ''): string
{
return url('/settings/webhooks/' . $this->id . '/' . ltrim($path, '/'));
}
/**
* Get the string descriptor for this item.
*/
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}";
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace BookStack\Actions;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $webhook_id
* @property string $event
*/
class WebhookTrackedEvent extends Model
{
protected $fillable = ['event'];
use HasFactory;
}

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

@@ -2,7 +2,6 @@
namespace BookStack\Auth;
use Activity;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
@@ -63,13 +62,16 @@ class UserRepo
/**
* Get all the users with their permissions in a paginated format.
* Note: Due to the use of email search this should only be used when
* user is assumed to be trusted. (Admin users).
* Email search can be abused to extract email addresses.
*/
public function getAllUsersPaginatedAndSorted(int $count, array $sortData): LengthAwarePaginator
{
$sort = $sortData['sort'];
$query = User::query()->select(['*'])
->withLastActivityAt()
->scopes(['withLastActivityAt'])
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $sortData['order']);
@@ -215,14 +217,6 @@ class UserRepo
}
}
/**
* Get the latest activity for a user.
*/
public function getActivity(User $user, int $count = 20, int $page = 0): array
{
return Activity::userActivity($user, $count, $page);
}
/**
* Get the recently created content for this given user.
*/

View File

@@ -11,7 +11,7 @@
return [
// Default driver to use for the queue
// Options: null, sync, redis
// Options: sync, database, redis
'default' => env('QUEUE_CONNECTION', 'sync'),
// Queue connection configuration

View File

@@ -4,6 +4,9 @@ namespace BookStack\Console\Commands;
use BookStack\Auth\UserRepo;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
class CreateAdmin extends Command
@@ -45,43 +48,33 @@ class CreateAdmin extends Command
*/
public function handle()
{
$email = trim($this->option('email'));
if (empty($email)) {
$email = $this->ask('Please specify an email address for the new admin user');
$details = $this->options();
if (empty($details['email'])) {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
if (mb_strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error('Invalid email address provided');
if (empty($details['name'])) {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
if (empty($details['password'])) {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
}
$validator = Validator::make($details, [
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
'name' => ['required', 'min:2'],
'password' => ['required', Password::default()],
]);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return SymfonyCommand::FAILURE;
}
if ($this->userRepo->getByEmail($email) !== null) {
$this->error('A user with the provided email already exists!');
return SymfonyCommand::FAILURE;
}
$name = trim($this->option('name'));
if (empty($name)) {
$name = $this->ask('Please specify an name for the new admin user');
}
if (mb_strlen($name) < 2) {
$this->error('Invalid name provided');
return SymfonyCommand::FAILURE;
}
$password = trim($this->option('password'));
if (empty($password)) {
$password = $this->secret('Please specify a password for the new admin user');
}
if (mb_strlen($password) < 5) {
$this->error('Invalid password provided, Must be at least 5 characters');
return SymfonyCommand::FAILURE;
}
$user = $this->userRepo->create(['email' => $email, 'name' => $name, 'password' => $password]);
$user = $this->userRepo->create($validator->validated());
$this->userRepo->attachSystemRole($user, 'admin');
$this->userRepo->downloadAndAssignUserAvatar($user);
$user->email_confirmed = true;

View File

@@ -18,7 +18,7 @@ class Chapter extends BookChild
public $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'priority', 'book_id'];
protected $fillable = ['name', 'description', 'priority'];
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
/**

View File

@@ -14,6 +14,7 @@ use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Facades\Permissions;
use BookStack\Interfaces\Deletable;
use BookStack\Interfaces\Favouritable;
use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
@@ -45,7 +46,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static Builder withLastView()
* @method static Builder withViewCount()
*/
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
{
use SoftDeletes;
use HasCreatorAndUpdater;
@@ -321,4 +322,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
->where('user_id', '=', user()->id)
->exists();
}
/**
* {@inheritdoc}
*/
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}";
}
}

View File

@@ -91,7 +91,7 @@ class BookRepo
{
$book = new Book();
$this->baseRepo->create($book, $input);
Activity::addForEntity($book, ActivityType::BOOK_CREATE);
Activity::add(ActivityType::BOOK_CREATE, $book);
return $book;
}
@@ -102,7 +102,7 @@ class BookRepo
public function update(Book $book, array $input): Book
{
$this->baseRepo->update($book, $input);
Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
Activity::add(ActivityType::BOOK_UPDATE, $book);
return $book;
}
@@ -127,7 +127,7 @@ class BookRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyBook($book);
Activity::addForEntity($book, ActivityType::BOOK_DELETE);
Activity::add(ActivityType::BOOK_DELETE, $book);
$trashCan->autoClearOld();
}

View File

@@ -90,7 +90,7 @@ class BookshelfRepo
$shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input);
$this->updateBooks($shelf, $bookIds);
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
return $shelf;
}
@@ -106,7 +106,7 @@ class BookshelfRepo
$this->updateBooks($shelf, $bookIds);
}
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf);
return $shelf;
}
@@ -177,7 +177,7 @@ class BookshelfRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyShelf($shelf);
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE);
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
$trashCan->autoClearOld();
}
}

View File

@@ -5,6 +5,7 @@ namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
@@ -49,7 +50,7 @@ class ChapterRepo
$chapter->book_id = $parentBook->id;
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
$this->baseRepo->create($chapter, $input);
Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE);
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
return $chapter;
}
@@ -60,7 +61,7 @@ class ChapterRepo
public function update(Chapter $chapter, array $input): Chapter
{
$this->baseRepo->update($chapter, $input);
Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
return $chapter;
}
@@ -74,7 +75,7 @@ class ChapterRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyChapter($chapter);
Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE);
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
$trashCan->autoClearOld();
}
@@ -87,24 +88,36 @@ class ChapterRepo
*/
public function move(Chapter $chapter, string $parentIdentifier): Book
{
$stringExploded = explode(':', $parentIdentifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
if ($entityType !== 'book') {
throw new MoveOperationException('Chapters can only be moved into books');
}
/** @var Book $parent */
$parent = Book::visible()->where('id', '=', $entityId)->first();
if ($parent === null) {
$parent = $this->findParentByIdentifier($parentIdentifier);
if (is_null($parent)) {
throw new MoveOperationException('Book to move chapter into not found');
}
$chapter->changeBook($parent->id);
$chapter->rebuildPermissions();
Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE);
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
return $parent;
}
/**
* Find a page parent entity via an identifier string in the format:
* {type}:{id}
* Example: (book:5).
*
* @throws MoveOperationException
*/
public function findParentByIdentifier(string $identifier): ?Book
{
$stringExploded = explode(':', $identifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
if ($entityType !== 'book') {
throw new MoveOperationException('Chapters can only be in books');
}
return Book::visible()->where('id', '=', $entityId)->first();
}
}

View File

@@ -171,7 +171,7 @@ class PageRepo
$draft->indexForSearch();
$draft->refresh();
Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
Activity::add(ActivityType::PAGE_CREATE, $draft);
return $draft;
}
@@ -205,7 +205,7 @@ class PageRepo
$this->savePageRevision($page, $summary);
}
Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
Activity::add(ActivityType::PAGE_UPDATE, $page);
return $page;
}
@@ -281,7 +281,7 @@ class PageRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyPage($page);
Activity::addForEntity($page, ActivityType::PAGE_DELETE);
Activity::add(ActivityType::PAGE_DELETE, $page);
$trashCan->autoClearOld();
}
@@ -312,7 +312,7 @@ class PageRepo
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
$this->savePageRevision($page, $summary);
Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
Activity::add(ActivityType::PAGE_RESTORE, $page);
return $page;
}
@@ -341,56 +341,19 @@ class PageRepo
$page->changeBook($newBookId);
$page->rebuildPermissions();
Activity::addForEntity($page, ActivityType::PAGE_MOVE);
Activity::add(ActivityType::PAGE_MOVE, $page);
return $parent;
}
/**
* Copy an existing page in the system.
* Optionally providing a new parent via string identifier and a new name.
*
* @throws MoveOperationException
* @throws PermissionsException
*/
public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
{
$parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent();
if ($parent === null) {
throw new MoveOperationException('Book or chapter to move page into not found');
}
if (!userCan('page-create', $parent)) {
throw new PermissionsException('User does not have permission to create a page within the new parent');
}
$copyPage = $this->getNewDraftPage($parent);
$pageData = $page->getAttributes();
// Update name
if (!empty($newName)) {
$pageData['name'] = $newName;
}
// Copy tags from previous page if set
if ($page->tags) {
$pageData['tags'] = [];
foreach ($page->tags as $tag) {
$pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value];
}
}
return $this->publishDraft($copyPage, $pageData);
}
/**
* Find a page parent entity via a identifier string in the format:
* Find a page parent entity via an identifier string in the format:
* {type}:{id}
* Example: (book:5).
*
* @throws MoveOperationException
*/
protected function findParentByIdentifier(string $identifier): ?Entity
public function findParentByIdentifier(string $identifier): ?Entity
{
$stringExploded = explode(':', $identifier);
$entityType = $stringExploded[0];

View File

@@ -0,0 +1,147 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Actions\Tag;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
use Illuminate\Http\UploadedFile;
class Cloner
{
/**
* @var PageRepo
*/
protected $pageRepo;
/**
* @var ChapterRepo
*/
protected $chapterRepo;
/**
* @var BookRepo
*/
protected $bookRepo;
/**
* @var ImageService
*/
protected $imageService;
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
{
$this->pageRepo = $pageRepo;
$this->chapterRepo = $chapterRepo;
$this->bookRepo = $bookRepo;
$this->imageService = $imageService;
}
/**
* Clone the given page into the given parent using the provided name.
*/
public function clonePage(Page $original, Entity $parent, string $newName): Page
{
$copyPage = $this->pageRepo->getNewDraftPage($parent);
$pageData = $original->getAttributes();
// Update name & tags
$pageData['name'] = $newName;
$pageData['tags'] = $this->entityTagsToInputArray($original);
return $this->pageRepo->publishDraft($copyPage, $pageData);
}
/**
* Clone the given page into the given parent using the provided name.
* Clones all child pages.
*/
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
{
$chapterDetails = $original->getAttributes();
$chapterDetails['name'] = $newName;
$chapterDetails['tags'] = $this->entityTagsToInputArray($original);
$copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
if (userCan('page-create', $copyChapter)) {
/** @var Page $page */
foreach ($original->getVisiblePages() as $page) {
$this->clonePage($page, $copyChapter, $page->name);
}
}
return $copyChapter;
}
/**
* Clone the given book.
* Clones all child chapters & pages.
*/
public function cloneBook(Book $original, string $newName): Book
{
$bookDetails = $original->getAttributes();
$bookDetails['name'] = $newName;
$bookDetails['tags'] = $this->entityTagsToInputArray($original);
$copyBook = $this->bookRepo->create($bookDetails);
$directChildren = $original->getDirectChildren();
foreach ($directChildren as $child) {
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
$this->cloneChapter($child, $copyBook, $child->name);
}
if ($child instanceof Page && !$child->draft && userCan('page-create', $copyBook)) {
$this->clonePage($child, $copyBook, $child->name);
}
}
if ($original->cover) {
try {
$tmpImgFile = tmpfile();
$uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile);
$this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false);
} catch (\Exception $exception) {
}
}
return $copyBook;
}
/**
* Convert an image instance to an UploadedFile instance to mimic
* a file being uploaded.
*/
protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile
{
$imgData = $this->imageService->getImageData($image);
$tmpImgFilePath = stream_get_meta_data($tmpFile)['uri'];
file_put_contents($tmpImgFilePath, $imgData);
return new UploadedFile($tmpImgFilePath, basename($image->path));
}
/**
* Convert the tags on the given entity to the raw format
* that's used for incoming request data.
*/
protected function entityTagsToInputArray(Entity $entity): array
{
$tags = [];
/** @var Tag $tag */
foreach ($entity->tags as $tag) {
$tags[] = ['name' => $tag->name, 'value' => $tag->value];
}
return $tags;
}
}

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

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

@@ -35,7 +35,7 @@ class PermissionsUpdater
$entity->save();
$entity->rebuildPermissions();
Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE);
Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
}
/**

View File

@@ -57,17 +57,17 @@ class SearchResultsFormatter
protected function highlightTagsContainingTerms(array $tags, array $terms): void
{
foreach ($tags as $tag) {
$tagName = strtolower($tag->name);
$tagValue = strtolower($tag->value);
$tagName = mb_strtolower($tag->name);
$tagValue = mb_strtolower($tag->value);
foreach ($terms as $term) {
$termLower = strtolower($term);
$termLower = mb_strtolower($term);
if (strpos($tagName, $termLower) !== false) {
if (mb_strpos($tagName, $termLower) !== false) {
$tag->setAttribute('highlight_name', true);
}
if (strpos($tagValue, $termLower) !== false) {
if (mb_strpos($tagValue, $termLower) !== false) {
$tag->setAttribute('highlight_value', true);
}
}
@@ -84,17 +84,17 @@ class SearchResultsFormatter
protected function getMatchPositions(string $text, array $terms): array
{
$matchRefs = [];
$text = strtolower($text);
$text = mb_strtolower($text);
foreach ($terms as $term) {
$offset = 0;
$term = strtolower($term);
$pos = strpos($text, $term, $offset);
$term = mb_strtolower($term);
$pos = mb_strpos($text, $term, $offset);
while ($pos !== false) {
$end = $pos + strlen($term);
$end = $pos + mb_strlen($term);
$matchRefs[$pos] = $end;
$offset = $end;
$pos = strpos($text, $term, $offset);
$pos = mb_strpos($text, $term, $offset);
}
}
@@ -141,7 +141,7 @@ class SearchResultsFormatter
*/
protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText, int $targetLength): string
{
$maxEnd = strlen($originalText);
$maxEnd = mb_strlen($originalText);
$fetchAll = ($targetLength === 0);
$contextLength = ($fetchAll ? 0 : 32);
@@ -165,7 +165,7 @@ class SearchResultsFormatter
$contextStart = $start;
// Trims off '$startDiff' number of characters to bring it back to the start
// if this current match zone.
$content = substr($content, 0, strlen($content) + $startDiff);
$content = mb_substr($content, 0, mb_strlen($content) + $startDiff);
$contentTextLength += $startDiff;
}
@@ -176,16 +176,16 @@ class SearchResultsFormatter
} elseif ($fetchAll) {
// Or fill in gap since the previous match
$fillLength = $contextStart - $lastEnd;
$content .= e(substr($originalText, $lastEnd, $fillLength));
$content .= e(mb_substr($originalText, $lastEnd, $fillLength));
$contentTextLength += $fillLength;
}
// Add our content including the bolded matching text
$content .= e(substr($originalText, $contextStart, $start - $contextStart));
$content .= e(mb_substr($originalText, $contextStart, $start - $contextStart));
$contentTextLength += $start - $contextStart;
$content .= '<strong>' . e(substr($originalText, $start, $end - $start)) . '</strong>';
$content .= '<strong>' . e(mb_substr($originalText, $start, $end - $start)) . '</strong>';
$contentTextLength += $end - $start;
$content .= e(substr($originalText, $end, $contextEnd - $end));
$content .= e(mb_substr($originalText, $end, $contextEnd - $end));
$contentTextLength += $contextEnd - $end;
// Update our last end position
@@ -204,7 +204,7 @@ class SearchResultsFormatter
// Just copy out the content if we haven't moved along anywhere.
if ($lastEnd === 0) {
$content = e(substr($originalText, 0, $targetLength));
$content = e(mb_substr($originalText, 0, $targetLength));
$contentTextLength = $targetLength;
$lastEnd = $targetLength;
}
@@ -213,7 +213,7 @@ class SearchResultsFormatter
$remainder = $targetLength - $contentTextLength;
if ($remainder > 10) {
$padEndLength = min($maxEnd - $lastEnd, $remainder);
$content .= e(substr($originalText, $lastEnd, $padEndLength));
$content .= e(mb_substr($originalText, $lastEnd, $padEndLength));
$lastEnd += $padEndLength;
$contentTextLength += $padEndLength;
}
@@ -223,7 +223,7 @@ class SearchResultsFormatter
$firstStart = $firstStart ?: 0;
if (!$fetchAll && $remainder > 10 && $firstStart !== 0) {
$padStart = max(0, $firstStart - $remainder);
$content = ($padStart === 0 ? '' : '...') . e(substr($originalText, $padStart, $firstStart - $padStart)) . substr($content, 4);
$content = ($padStart === 0 ? '' : '...') . e(mb_substr($originalText, $padStart, $firstStart - $padStart)) . mb_substr($content, 4);
}
// Add ellipsis if we're not at the end

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

@@ -4,6 +4,9 @@ namespace BookStack\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @see \BookStack\Actions\ActivityLogger
*/
class Activity extends Facade
{
/**

View File

@@ -4,12 +4,14 @@ namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\SearchOptions;
use BookStack\Entities\Tools\SearchResultsFormatter;
use BookStack\Entities\Tools\SearchRunner;
use Illuminate\Http\Request;
class SearchApiController extends ApiController
{
protected $searchRunner;
protected $resultsFormatter;
protected $rules = [
'all' => [
@@ -19,9 +21,10 @@ class SearchApiController extends ApiController
],
];
public function __construct(SearchRunner $searchRunner)
public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter)
{
$this->searchRunner = $searchRunner;
$this->resultsFormatter = $resultsFormatter;
}
/**
@@ -45,6 +48,7 @@ class SearchApiController extends ApiController
$count = min(intval($request->get('count', '0')) ?: 20, 100);
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
$this->resultsFormatter->format($results['results']->all(), $options);
/** @var Entity $result */
foreach ($results['results'] as $result) {
@@ -52,9 +56,14 @@ class SearchApiController extends ApiController
'id', 'name', 'slug', 'book_id',
'chapter_id', 'draft', 'template',
'created_at', 'updated_at',
'tags', 'type',
'tags', 'type', 'preview_html', 'url',
]);
$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([

View File

@@ -20,6 +20,7 @@ class AuditLogController extends Controller
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'user' => $request->get('user', ''),
'ip' => $request->get('ip', ''),
];
$query = Activity::query()
@@ -44,6 +45,9 @@ class AuditLogController extends Controller
if ($listDetails['date_to']) {
$query->where('created_at', '<=', $listDetails['date_to']);
}
if ($listDetails['ip']) {
$query->where('ip', 'like', $listDetails['ip'] . '%');
}
$activities = $query->paginate(100);
$activities->appends($listDetails);

View File

@@ -2,11 +2,11 @@
namespace BookStack\Http\Controllers\Auth;
use Activity;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Facades\Activity;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;

View File

@@ -13,6 +13,7 @@ use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
@@ -70,7 +71,7 @@ class RegisterController extends Controller
return Validator::make($data, [
'name' => ['required', 'min:2', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'min:8'],
'password' => ['required', Password::default()],
]);
}

View File

@@ -11,6 +11,7 @@ use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Validation\Rules\Password;
class UserInviteController extends Controller
{
@@ -55,7 +56,7 @@ class UserInviteController extends Controller
public function setPassword(Request $request, string $token)
{
$this->validate($request, [
'password' => ['required', 'min:8'],
'password' => ['required', Password::default()],
]);
try {

View File

@@ -2,15 +2,18 @@
namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\ActivityQueries;
use BookStack\Actions\ActivityType;
use BookStack\Actions\View;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
@@ -101,7 +104,7 @@ class BookController extends Controller
if ($bookshelf) {
$bookshelf->appendBook($book);
Activity::addForEntity($bookshelf, ActivityType::BOOKSHELF_UPDATE);
Activity::add(ActivityType::BOOKSHELF_UPDATE, $bookshelf);
}
return redirect($book->getUrl());
@@ -110,7 +113,7 @@ class BookController extends Controller
/**
* Display the specified book.
*/
public function show(Request $request, string $slug)
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$book = $this->bookRepo->getBySlug($slug);
$bookChildren = (new BookContents($book))->getTree(true);
@@ -128,7 +131,7 @@ class BookController extends Controller
'current' => $book,
'bookChildren' => $bookChildren,
'bookParentShelves' => $bookParentShelves,
'activity' => Activity::entityActivity($book, 20, 1),
'activity' => $activities->entityActivity($book, 20, 1),
]);
}
@@ -224,4 +227,39 @@ class BookController extends Controller
return redirect($book->getUrl());
}
/**
* Show the view to copy a book.
*
* @throws NotFoundException
*/
public function showCopy(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-view', $book);
session()->flashInput(['name' => $book->name]);
return view('books.copy', [
'book' => $book,
]);
}
/**
* Create a copy of a book within the requested target destination.
*
* @throws NotFoundException
*/
public function copy(Request $request, Cloner $cloner, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-view', $book);
$this->checkPermission('book-create-all');
$newName = $request->get('name') ?: $book->name;
$bookCopy = $cloner->cloneBook($book, $newName);
$this->showSuccessNotification(trans('entities.books_copy_success'));
return redirect($bookCopy->getUrl());
}
}

View File

@@ -71,7 +71,7 @@ class BookSortController extends Controller
// Rebuild permissions and add activity for involved books.
$booksInvolved->each(function (Book $book) {
Activity::addForEntity($book, ActivityType::BOOK_SORT);
Activity::add(ActivityType::BOOK_SORT, $book);
});
return redirect($book->getUrl());

View File

@@ -2,7 +2,7 @@
namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\ActivityQueries;
use BookStack\Actions\View;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\BookshelfRepo;
@@ -101,7 +101,7 @@ class BookshelfController extends Controller
*
* @throws NotFoundException
*/
public function show(string $slug)
public function show(ActivityQueries $activities, string $slug)
{
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('book-view', $shelf);
@@ -124,7 +124,7 @@ class BookshelfController extends Controller
'shelf' => $shelf,
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
'view' => $view,
'activity' => Activity::entityActivity($shelf, 20, 1),
'activity' => $activities->entityActivity($shelf, 20, 1),
'order' => $order,
'sort' => $sort,
]);

View File

@@ -6,6 +6,7 @@ use BookStack\Actions\View;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
use BookStack\Entities\Tools\NextPreviousContentLocator;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Exceptions\MoveOperationException;
@@ -190,6 +191,53 @@ class ChapterController extends Controller
return redirect($chapter->getUrl());
}
/**
* Show the view to copy a chapter.
*
* @throws NotFoundException
*/
public function showCopy(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
session()->flashInput(['name' => $chapter->name]);
return view('chapters.copy', [
'book' => $chapter->book,
'chapter' => $chapter,
]);
}
/**
* Create a copy of a chapter within the requested target destination.
*
* @throws NotFoundException
* @throws Throwable
*/
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$entitySelection = $request->get('entity_selection') ?: null;
$newParentBook = $entitySelection ? $this->chapterRepo->findParentByIdentifier($entitySelection) : $chapter->getParent();
if (is_null($newParentBook)) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));
return redirect()->back();
}
$this->checkOwnablePermission('chapter-create', $newParentBook);
$newName = $request->get('name') ?: $chapter->name;
$chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
$this->showSuccessNotification(trans('entities.chapters_copy_success'));
return redirect($chapterCopy->getUrl());
}
/**
* Show the Restrictions view.
*

View File

@@ -2,7 +2,7 @@
namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\ActivityQueries;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\RecentlyViewed;
@@ -16,9 +16,9 @@ class HomeController extends Controller
/**
* Display the homepage.
*/
public function index()
public function index(ActivityQueries $activities)
{
$activity = Activity::latest(10);
$activity = $activities->latest(10);
$draftPages = [];
if ($this->isSignedIn()) {

View File

@@ -67,7 +67,7 @@ class MaintenanceController extends Controller
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
try {
user()->notify(new TestEmail());
user()->notifyNow(new TestEmail());
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
} catch (\Exception $exception) {
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();

View File

@@ -6,6 +6,7 @@ use BookStack\Actions\View;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
use BookStack\Entities\Tools\NextPreviousContentLocator;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditActivity;
@@ -447,26 +448,24 @@ class PageController extends Controller
* @throws NotFoundException
* @throws Throwable
*/
public function copy(Request $request, string $bookSlug, string $pageSlug)
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $page);
$entitySelection = $request->get('entity_selection', null) ?? null;
$newName = $request->get('name', null);
try {
$pageCopy = $this->pageRepo->copy($page, $entitySelection, $newName);
} catch (Exception $exception) {
if ($exception instanceof PermissionsException) {
$this->showPermissionError();
}
$entitySelection = $request->get('entity_selection') ?: null;
$newParent = $entitySelection ? $this->pageRepo->findParentByIdentifier($entitySelection) : $page->getParent();
if (is_null($newParent)) {
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();
}
$this->checkOwnablePermission('page-create', $newParent);
$newName = $request->get('name') ?: $page->name;
$pageCopy = $cloner->clonePage($page, $newParent, $newName);
$this->showSuccessNotification(trans('entities.pages_copy_success'));
return redirect($pageCopy->getUrl());

View File

@@ -3,6 +3,7 @@
namespace BookStack\Http\Controllers;
use BookStack\Auth\Permissions\PermissionsRepo;
use BookStack\Auth\Role;
use BookStack\Exceptions\PermissionsException;
use Exception;
use Illuminate\Http\Request;
@@ -23,7 +24,7 @@ class RoleController extends Controller
/**
* Show a listing of the roles in the system.
*/
public function list()
public function index()
{
$this->checkPermission('user-roles-manage');
$roles = $this->permissionsRepo->getAllRoles();
@@ -34,11 +35,21 @@ class RoleController extends Controller
/**
* Show the form to create a new role.
*/
public function create()
public function create(Request $request)
{
$this->checkPermission('user-roles-manage');
return view('settings.roles.create');
/** @var ?Role $role */
$role = null;
if ($request->has('copy_from')) {
$role = Role::query()->find($request->get('copy_from'));
}
if ($role) {
$role->display_name .= ' (' . trans('common.copy') . ')';
}
return view('settings.roles.create', ['role' => $role]);
}
/**
@@ -49,7 +60,7 @@ class RoleController extends Controller
$this->checkPermission('user-roles-manage');
$this->validate($request, [
'display_name' => ['required', 'min:3', 'max:180'],
'description' => 'max:180',
'description' => ['max:180'],
]);
$this->permissionsRepo->saveNewRole($request->all());
@@ -84,7 +95,7 @@ class RoleController extends Controller
$this->checkPermission('user-roles-manage');
$this->validate($request, [
'display_name' => ['required', 'min:3', 'max:180'],
'description' => 'max:180',
'description' => ['max:180'],
]);
$this->permissionsRepo->updateRole($id, $request->all());

View File

@@ -13,6 +13,7 @@ use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
class UserController extends Controller
@@ -82,7 +83,7 @@ class UserController extends Controller
$sendInvite = ($request->get('send_invite', 'false') === 'true');
if ($authMethod === 'standard' && !$sendInvite) {
$validationRules['password'] = ['required', 'min:6'];
$validationRules['password'] = ['required', Password::default()];
$validationRules['password-confirm'] = ['required', 'same:password'];
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
$validationRules['external_auth_id'] = ['required'];
@@ -155,11 +156,11 @@ class UserController extends Controller
$this->checkPermissionOrCurrentUser('users-manage', $id);
$this->validate($request, [
'name' => 'min:2',
'name' => ['min:2'],
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
'password' => ['min:6', 'required_with:password_confirm'],
'password' => ['required_with:password_confirm', Password::default()],
'password-confirm' => ['same:password', 'required_with:password'],
'setting' => 'array',
'setting' => ['array'],
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
]);

View File

@@ -2,6 +2,7 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityQueries;
use BookStack\Auth\UserRepo;
class UserProfileController extends Controller
@@ -9,11 +10,11 @@ class UserProfileController extends Controller
/**
* Show the user profile page.
*/
public function show(UserRepo $repo, string $slug)
public function show(UserRepo $repo, ActivityQueries $activities, string $slug)
{
$user = $repo->getBySlug($slug);
$userActivity = $repo->getActivity($user);
$userActivity = $activities->userActivity($user);
$recentlyCreated = $repo->getRecentlyCreated($user, 5);
$assetCounts = $repo->getAssetCounts($user);

View File

@@ -3,7 +3,6 @@
namespace BookStack\Http\Controllers;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class UserSearchController extends Controller
@@ -14,19 +13,27 @@ class UserSearchController extends Controller
*/
public function forSelect(Request $request)
{
$hasPermission = signedInUser() && (
userCan('users-manage')
|| userCan('restrictions-manage-own')
|| userCan('restrictions-manage-all')
);
if (!$hasPermission) {
$this->showPermissionError();
}
$search = $request->get('search', '');
$query = User::query()->orderBy('name', 'desc')
$query = User::query()
->orderBy('name', 'asc')
->take(20);
if (!empty($search)) {
$query->where(function (Builder $query) use ($search) {
$query->where('email', 'like', '%' . $search . '%')
->orWhere('name', 'like', '%' . $search . '%');
});
$query->where('name', 'like', '%' . $search . '%');
}
$users = $query->get();
return view('form.user-select-list', compact('users'));
return view('form.user-select-list', [
'users' => $query->get(),
]);
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Actions\Webhook;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function __construct()
{
$this->middleware([
'can:settings-manage',
]);
}
/**
* Show all webhooks configured in the system.
*/
public function index()
{
$webhooks = Webhook::query()
->orderBy('name', 'desc')
->with('trackedEvents')
->get();
return view('settings.webhooks.index', ['webhooks' => $webhooks]);
}
/**
* Show the view for creating a new webhook in the system.
*/
public function create()
{
return view('settings.webhooks.create');
}
/**
* Store a new webhook in the system.
*/
public function store(Request $request)
{
$validated = $this->validate($request, [
'name' => ['required', 'max:150'],
'endpoint' => ['required', 'url', 'max:500'],
'events' => ['required', 'array'],
'active' => ['required'],
]);
$webhook = new Webhook($validated);
$webhook->active = $validated['active'] === 'true';
$webhook->save();
$webhook->updateTrackedEvents(array_values($validated['events']));
$this->logActivity(ActivityType::WEBHOOK_CREATE, $webhook);
return redirect('/settings/webhooks');
}
/**
* Show the view to edit an existing webhook.
*/
public function edit(string $id)
{
/** @var Webhook $webhook */
$webhook = Webhook::query()
->with('trackedEvents')
->findOrFail($id);
return view('settings.webhooks.edit', ['webhook' => $webhook]);
}
/**
* Update an existing webhook with the provided request data.
*/
public function update(Request $request, string $id)
{
$validated = $this->validate($request, [
'name' => ['required', 'max:150'],
'endpoint' => ['required', 'url', 'max:500'],
'events' => ['required', 'array'],
'active' => ['required'],
]);
/** @var Webhook $webhook */
$webhook = Webhook::query()->findOrFail($id);
$webhook->active = $validated['active'] === 'true';
$webhook->fill($validated)->save();
$webhook->updateTrackedEvents($validated['events']);
$this->logActivity(ActivityType::WEBHOOK_UPDATE, $webhook);
return redirect('/settings/webhooks');
}
/**
* Show the view to delete a webhook.
*/
public function delete(string $id)
{
/** @var Webhook $webhook */
$webhook = Webhook::query()->findOrFail($id);
return view('settings.webhooks.delete', ['webhook' => $webhook]);
}
/**
* Destroy a webhook from the system.
*/
public function destroy(string $id)
{
/** @var Webhook $webhook */
$webhook = Webhook::query()->findOrFail($id);
$webhook->trackedEvents()->delete();
$webhook->delete();
$this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook);
return redirect('/settings/webhooks');
}
}

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

@@ -11,6 +11,7 @@ use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AuthServiceProvider extends ServiceProvider
{
@@ -21,6 +22,12 @@ class AuthServiceProvider extends ServiceProvider
*/
public function boot()
{
// Password Configuration
Password::defaults(function () {
return Password::min(8);
});
// Custom guards
Auth::extend('api-token', function ($app, $name, array $config) {
return new ApiTokenGuard($app['request'], $app->make(LoginService::class));
});

View File

@@ -2,7 +2,7 @@
namespace BookStack\Providers;
use BookStack\Actions\ActivityService;
use BookStack\Actions\ActivityLogger;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Theming\ThemeService;
use BookStack\Uploads\ImageService;
@@ -28,7 +28,7 @@ class CustomFacadeProvider extends ServiceProvider
public function register()
{
$this->app->singleton('activity', function () {
return $this->app->make(ActivityService::class);
return $this->app->make(ActivityLogger::class);
});
$this->app->singleton('images', function () {

1083
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
<?php
namespace Database\Factories\Actions;
use BookStack\Actions\Webhook;
use Illuminate\Database\Eloquent\Factories\Factory;
class WebhookFactory extends Factory
{
protected $model = Webhook::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'name' => 'My webhook for ' . $this->faker->country(),
'endpoint' => $this->faker->url,
'active' => true,
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Database\Factories\Actions;
use BookStack\Actions\ActivityType;
use BookStack\Actions\Webhook;
use Illuminate\Database\Eloquent\Factories\Factory;
class WebhookTrackedEventFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'webhook_id' => Webhook::factory(),
'event' => ActivityType::all()[array_rand(ActivityType::all())],
];
}
}

View File

@@ -2,6 +2,7 @@
namespace Database\Factories\Auth;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
@@ -12,7 +13,7 @@ class UserFactory extends Factory
*
* @var string
*/
protected $model = \BookStack\Auth\User::class;
protected $model = User::class;
/**
* Define the model's default state.
@@ -26,7 +27,7 @@ class UserFactory extends Factory
return [
'name' => $name,
'email' => $this->faker->email,
'slug' => \Illuminate\Support\Str::slug($name . '-' . \Illuminate\Support\Str::random(5)),
'slug' => Str::slug($name . '-' . Str::random(5)),
'password' => Str::random(10),
'remember_token' => Str::random(10),
'email_confirmed' => 1,

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddIndexForUserIp extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('activities', function (Blueprint $table) {
$table->index('ip', 'activities_ip_index');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('activities', function (Blueprint $table) {
$table->dropIndex('activities_ip_index');
});
}
}

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateWebhooksTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('webhooks', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 150);
$table->boolean('active');
$table->string('endpoint', 500);
$table->timestamps();
$table->index('name');
$table->index('active');
});
Schema::create('webhook_tracked_events', function (Blueprint $table) {
$table->increments('id');
$table->integer('webhook_id');
$table->string('event', 50);
$table->timestamps();
$table->index('event');
$table->index('webhook_id');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('webhooks');
Schema::dropIfExists('webhook_tracked_events');
}
}

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateJobsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('jobs');
}
}

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateFailedJobsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('failed_jobs');
}
}

View File

@@ -8,6 +8,11 @@
"created_at": "2021-11-14T15:57:35.000000Z",
"updated_at": "2021-11-14T15:57:35.000000Z",
"type": "chapter",
"url": "https://example.com/books/my-book/chapter/a-chapter-for-cats",
"preview_html": {
"name": "A chapter for <strong>cats</strong>",
"content": "...once a bunch of <strong>cats</strong> named tony...behaviour of <strong>cats</strong> is unsuitable"
},
"tags": []
},
{
@@ -21,6 +26,11 @@
"created_at": "2021-05-15T16:28:10.000000Z",
"updated_at": "2021-11-14T15:56:49.000000Z",
"type": "page",
"url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats",
"preview_html": {
"name": "The hows and whys of <strong>cats</strong>",
"content": "...people ask why <strong>cats</strong>? but there are...the reason that <strong>cats</strong> are fast are due to..."
},
"tags": [
{
"name": "Animal",
@@ -45,6 +55,11 @@
"created_at": "2020-11-29T21:55:07.000000Z",
"updated_at": "2021-11-14T16:02:39.000000Z",
"type": "page",
"url": "https://example.com/books/my-book/page/how-advanced-are-cats",
"preview_html": {
"name": "How advanced are <strong>cats</strong>?",
"content": "<strong>cats</strong> are some of the most advanced animals in the world."
},
"tags": []
}
],

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,9 +96,10 @@ Theme::listen(ThemeEvents::APP_BOOT, function($app) {
});
```
## Custom Commands
## 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`.
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.

66
public/dist/app.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10,15l5.88,0c0.27-0.31,0.67-0.5,1.12-0.5c0.83,0,1.5,0.67,1.5,1.5c0,0.83-0.67,1.5-1.5,1.5c-0.44,0-0.84-0.19-1.12-0.5 l-3.98,0c-0.46,2.28-2.48,4-4.9,4c-2.76,0-5-2.24-5-5c0-2.42,1.72-4.44,4-4.9l0,2.07C4.84,13.58,4,14.7,4,16c0,1.65,1.35,3,3,3 s3-1.35,3-3V15z M12.5,4c1.65,0,3,1.35,3,3h2c0-2.76-2.24-5-5-5l0,0c-2.76,0-5,2.24-5,5c0,1.43,0.6,2.71,1.55,3.62l-2.35,3.9 C6.02,14.66,5.5,15.27,5.5,16c0,0.83,0.67,1.5,1.5,1.5s1.5-0.67,1.5-1.5c0-0.16-0.02-0.31-0.07-0.45l3.38-5.63 C10.49,9.61,9.5,8.42,9.5,7C9.5,5.35,10.85,4,12.5,4z M17,13c-0.64,0-1.23,0.2-1.72,0.54l-3.05-5.07C11.53,8.35,11,7.74,11,7 c0-0.83,0.67-1.5,1.5-1.5S14,6.17,14,7c0,0.15-0.02,0.29-0.06,0.43l2.19,3.65C16.41,11.03,16.7,11,17,11l0,0c2.76,0,5,2.24,5,5 c0,2.76-2.24,5-5,5c-1.85,0-3.47-1.01-4.33-2.5l2.67,0C15.82,18.82,16.39,19,17,19c1.65,0,3-1.35,3-3S18.65,13,17,13z"/></svg>

After

Width:  |  Height:  |  Size: 903 B

View File

@@ -50,6 +50,7 @@ import templateManager from "./template-manager.js"
import toggleSwitch from "./toggle-switch.js"
import triLayout from "./tri-layout.js"
import userSelect from "./user-select.js"
import webhookEvents from "./webhook-events";
import wysiwygEditor from "./wysiwyg-editor.js"
const componentMapping = {
@@ -105,6 +106,7 @@ const componentMapping = {
"toggle-switch": toggleSwitch,
"tri-layout": triLayout,
"user-select": userSelect,
"webhook-events": webhookEvents,
"wysiwyg-editor": wysiwygEditor,
};

View File

@@ -0,0 +1,32 @@
/**
* Webhook Events
* Manages dynamic selection control in the webhook form interface.
* @extends {Component}
*/
class WebhookEvents {
setup() {
this.checkboxes = this.$el.querySelectorAll('input[type="checkbox"]');
this.allCheckbox = this.$el.querySelector('input[type="checkbox"][value="all"]');
this.$el.addEventListener('change', event => {
if (event.target.checked && event.target === this.allCheckbox) {
this.deselectIndividualEvents();
} else if (event.target.checked) {
this.allCheckbox.checked = false;
}
});
}
deselectIndividualEvents() {
for (const checkbox of this.checkboxes) {
if (checkbox !== this.allCheckbox) {
checkbox.checked = false;
}
}
}
}
export default WebhookEvents;

View File

@@ -7,41 +7,41 @@ return [
// Pages
'page_create' => 'created page',
'page_create_notification' => 'Page Successfully Created',
'page_create_notification' => 'Page successfully created',
'page_update' => 'updated page',
'page_update_notification' => 'Page Successfully Updated',
'page_update_notification' => 'Page successfully updated',
'page_delete' => 'deleted page',
'page_delete_notification' => 'Page Successfully Deleted',
'page_delete_notification' => 'Page successfully deleted',
'page_restore' => 'restored page',
'page_restore_notification' => 'Page Successfully Restored',
'page_restore_notification' => 'Page successfully restored',
'page_move' => 'moved page',
// Chapters
'chapter_create' => 'created chapter',
'chapter_create_notification' => 'Chapter Successfully Created',
'chapter_create_notification' => 'Chapter successfully created',
'chapter_update' => 'updated chapter',
'chapter_update_notification' => 'Chapter Successfully Updated',
'chapter_update_notification' => 'Chapter successfully updated',
'chapter_delete' => 'deleted chapter',
'chapter_delete_notification' => 'Chapter Successfully Deleted',
'chapter_delete_notification' => 'Chapter successfully deleted',
'chapter_move' => 'moved chapter',
// Books
'book_create' => 'created book',
'book_create_notification' => 'Book Successfully Created',
'book_create_notification' => 'Book successfully created',
'book_update' => 'updated book',
'book_update_notification' => 'Book Successfully Updated',
'book_update_notification' => 'Book successfully updated',
'book_delete' => 'deleted book',
'book_delete_notification' => 'Book Successfully Deleted',
'book_delete_notification' => 'Book successfully deleted',
'book_sort' => 'sorted book',
'book_sort_notification' => 'Book Successfully Re-sorted',
'book_sort_notification' => 'Book successfully re-sorted',
// Bookshelves
'bookshelf_create' => 'created Bookshelf',
'bookshelf_create_notification' => 'Bookshelf Successfully Created',
'bookshelf_create' => 'created bookshelf',
'bookshelf_create_notification' => 'Bookshelf successfully created',
'bookshelf_update' => 'updated bookshelf',
'bookshelf_update_notification' => 'Bookshelf Successfully Updated',
'bookshelf_update_notification' => 'Bookshelf successfully updated',
'bookshelf_delete' => 'deleted bookshelf',
'bookshelf_delete_notification' => 'Bookshelf Successfully Deleted',
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
// Favourites
'favourite_add_notification' => '":name" has been added to your favourites',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
// Webhooks
'webhook_create' => 'created webhook',
'webhook_create_notification' => 'Webhook successfully created',
'webhook_update' => 'updated webhook',
'webhook_update_notification' => 'Webhook successfully updated',
'webhook_delete' => 'deleted webhook',
'webhook_delete_notification' => 'Webhook successfully deleted',
// Other
'commented_on' => 'commented on',
'permissions_update' => 'updated permissions',

View File

@@ -21,7 +21,7 @@ return [
'email' => 'Email',
'password' => 'Password',
'password_confirm' => 'Confirm Password',
'password_hint' => 'Must be over 7 characters',
'password_hint' => 'Must be at least 8 characters',
'forgot_password' => 'Forgot Password?',
'remember_me' => 'Remember Me',
'ldap_email_hint' => 'Please enter an email to use for this account.',

View File

@@ -71,6 +71,9 @@ return [
'list_view' => 'List View',
'default' => 'Default',
'breadcrumb' => 'Breadcrumb',
'status' => 'Status',
'status_active' => 'Active',
'status_inactive' => 'Inactive',
// Header
'header_menu_expand' => 'Expand Header Menu',

View File

@@ -143,6 +143,8 @@ return [
'books_sort_chapters_last' => 'Chapters Last',
'books_sort_show_other' => 'Show Other Books',
'books_sort_save' => 'Save New Order',
'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied',
// Chapters
'chapter' => 'Chapter',
@@ -161,6 +163,8 @@ return [
'chapters_move' => 'Move Chapter',
'chapters_move_named' => 'Move Chapter :chapterName',
'chapter_move_success' => 'Chapter moved to :bookName',
'chapters_copy' => 'Copy Chapter',
'chapters_copy_success' => 'Chapter successfully copied',
'chapters_permissions' => 'Chapter Permissions',
'chapters_empty' => 'No pages are currently in this chapter.',
'chapters_permissions_active' => 'Chapter Permissions Active',
@@ -332,4 +336,12 @@ return [
'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
'revision_delete_success' => 'Revision deleted',
'revision_cannot_delete_latest' => 'Cannot delete the latest revision.',
// Copy view
'copy_consider' => 'Please consider the below when copying content.',
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
'copy_consider_owner' => 'You will become the owner of all copied content.',
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
'copy_consider_attachments' => 'Page attachments will not be copied.',
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
];

View File

@@ -174,7 +174,7 @@ return [
'users_role' => 'User Roles',
'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
'users_password' => 'User Password',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
'users_send_invite_option' => 'Send user invite email',
'users_external_auth_id' => 'External Authentication ID',
@@ -233,6 +233,28 @@ return [
'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
'user_api_token_delete_success' => 'API token successfully deleted',
// Webhooks
'webhooks' => 'Webhooks',
'webhooks_create' => 'Create New Webhook',
'webhooks_none_created' => 'No webhooks have yet been created.',
'webhooks_edit' => 'Edit Webhook',
'webhooks_save' => 'Save Webhook',
'webhooks_details' => 'Webhook Details',
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
'webhooks_events' => 'Webhook Events',
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
'webhooks_events_all' => 'All system events',
'webhooks_name' => 'Webhook Name',
'webhooks_endpoint' => 'Webhook Endpoint',
'webhooks_active' => 'Webhook Active',
'webhook_events_table_header' => 'Events',
'webhooks_delete' => 'Delete Webhook',
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
'webhooks_format_example' => 'Webhook Format Example',
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
//! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en.
//!////////////////////////////////

View File

@@ -54,7 +54,7 @@ return [
'email_confirm_text' => 'Por favor confirme su dirección de correo electrónico presionando en el siguiente botón:',
'email_confirm_action' => 'Confirmar correo electrónico',
'email_confirm_send_error' => 'Se pidió confirmación de correo electrónico pero el sistema no pudo enviar el correo electrónico. Contacte al administrador para asegurarse que el correo electrónico está configurado correctamente.',
'email_confirm_success' => Tu correo electrónico ha sido confirmado! Ahora deberías poder iniciar sesión usando esta dirección de correo electrónico.',
'email_confirm_success' => Su correo electrónico ha sido confirmado! Ahora debería poder iniciar sesión usando esta dirección de correo electrónico.',
'email_confirm_resent' => 'Correo electrónico de confirmación reenviado, Por favor verifique su bandeja de entrada.',
'email_not_confirmed' => 'Dirección de correo electrónico no confirmada',

View File

@@ -260,7 +260,7 @@ return [
'tags_remove' => 'Eliminar esta etiqueta',
'tags_usages' => 'Uso total de etiquetas',
'tags_assigned_pages' => 'Asignadas a páginas',
'tags_assigned_chapters' => 'Asignadas a capitulos',
'tags_assigned_chapters' => 'Asignadas a capítulos',
'tags_assigned_books' => 'Asignadas a libros',
'tags_assigned_shelves' => 'Asignadas a estantes',
'tags_x_unique_values' => ':count valores únicos',

View File

@@ -23,9 +23,9 @@ return [
'saml_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo',
'saml_invalid_response_id' => 'La solicitud del sistema de autenticación externo no está reconocida por un proceso iniciado por esta aplicación. Navegar hacia atrás después de un inicio de sesión podría causar este problema.',
'saml_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta',
'oidc_already_logged_in' => 'Ya tenías la sesión iniciada',
'oidc_already_logged_in' => 'Ya está conectado',
'oidc_user_not_registered' => 'El usuario :name no está registrado y el registro automático está deshabilitado',
'oidc_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo',
'oidc_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico para este usuario en los datos proporcionados por el sistema de autenticación externo',
'oidc_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta',
'social_no_action_defined' => 'Acción no definida',
'social_login_bad_response' => "SE recibió un Error durante el acceso con :socialAccount : \n:error",

View File

@@ -45,8 +45,8 @@ return [
'unfavourite' => 'お気に入りから削除',
'next' => '次へ',
'previous' => '前へ',
'filter_active' => 'Active Filter:',
'filter_clear' => 'Clear Filter',
'filter_active' => '有効なフィルター:',
'filter_clear' => 'フィルターを解除',
// Sort Options
'sort_options' => '並べ替えオプション',
@@ -63,7 +63,7 @@ return [
'no_activity' => '表示するアクティビティがありません',
'no_items' => 'アイテムはありません',
'back_to_top' => '上に戻る',
'skip_to_main_content' => 'Skip to main content',
'skip_to_main_content' => 'メインコンテンツへスキップ',
'toggle_details' => '概要の表示切替',
'toggle_thumbnails' => 'Toggle Thumbnails',
'details' => '詳細',

View File

@@ -75,35 +75,35 @@ return [
// Shelves
'shelf' => '本棚',
'shelves' => '本棚',
'x_shelves' => ':count Shelf|:count Shelves',
'x_shelves' => ':count 本棚|:count 本棚',
'shelves_long' => '本棚',
'shelves_empty' => 'No shelves have been created',
'shelves_empty' => '本棚が作成されていません',
'shelves_create' => '新しい本棚を作成',
'shelves_popular' => '人気の本棚',
'shelves_new' => '新しい本棚',
'shelves_new_action' => '新しい本棚',
'shelves_popular_empty' => 'The most popular shelves will appear here.',
'shelves_new_empty' => 'The most recently created shelves will appear here.',
'shelves_popular_empty' => 'ここに人気の本棚が表示されます。',
'shelves_new_empty' => '最近作成された本棚がここに表示されます。',
'shelves_save' => '本棚を保存',
'shelves_books' => 'この本棚のブック',
'shelves_add_books' => 'この本棚にブックを追加',
'shelves_drag_books' => 'ブックをここにドラッグすると本棚に追加されます',
'shelves_empty_contents' => 'This shelf has no books assigned to it',
'shelves_edit_and_assign' => 'Edit shelf to assign books',
'shelves_empty_contents' => 'この本棚にはブックが割り当てられていません。',
'shelves_edit_and_assign' => '本棚を編集してブックを割り当てる',
'shelves_edit_named' => '本棚「:name」を編集',
'shelves_edit' => '本棚を編集',
'shelves_delete' => '本棚を削除',
'shelves_delete_named' => '本棚「:name」を削除',
'shelves_delete_explain' => "これにより、この本棚「:name」が削除されます。含まれているブックは削除されません。",
'shelves_delete_confirmation' => '本当にこの本棚を削除してよろしいですか?',
'shelves_permissions' => 'Bookshelf Permissions',
'shelves_permissions_updated' => 'Bookshelf Permissions Updated',
'shelves_permissions_active' => 'Bookshelf 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_copy_permissions_to_books' => 'Copy Permissions to Books',
'shelves_copy_permissions' => 'Copy Permissions',
'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
'shelves_copy_permission_success' => 'Bookshelf permissions copied to :count books',
'shelves_permissions' => '本棚の権限',
'shelves_permissions_updated' => '本棚の権限を更新しました',
'shelves_permissions_active' => '本棚の権限は有効です',
'shelves_permissions_cascade_warning' => '本棚の権限は含まれる本には自動的に継承されません。これは、1つのブックが複数の本棚に存在する可能性があるためです。ただし、以下のオプションを使用すると権限を子ブックにコピーできます。',
'shelves_copy_permissions_to_books' => 'ブックに権限をコピー',
'shelves_copy_permissions' => '権限をコピー',
'shelves_copy_permissions_explain' => 'これにより、この本棚の現在の権限設定を本棚に含まれるすべてのブックに適用します。有効にする前に、この本棚の権限への変更が保存されていることを確認してください。',
'shelves_copy_permission_success' => '本棚の権限が:count個のブックにコピーされました',
// Books
'book' => 'ブック',
@@ -115,7 +115,7 @@ return [
'books_new' => '新しいブック',
'books_new_action' => '新しいブック',
'books_popular_empty' => 'ここに人気のブックが表示されます。',
'books_new_empty' => 'The most recently created books will appear here.',
'books_new_empty' => '最近作成されたブックがここに表示されます。',
'books_create' => '新しいブックを作成',
'books_delete' => 'ブックを削除',
'books_delete_named' => 'ブック「:bookName」を削除',
@@ -153,7 +153,7 @@ return [
'chapters_create' => 'チャプターを作成',
'chapters_delete' => 'チャプターを削除',
'chapters_delete_named' => 'チャプター「:chapterName」を削除',
'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
'chapters_delete_explain' => 'これにより、チャプター「:chapterName」が削除されます。このチャプターに存在するページもすべて削除されます。',
'chapters_delete_confirm' => 'チャプターを削除してよろしいですか?',
'chapters_edit' => 'チャプターを編集',
'chapters_edit_named' => 'チャプター「:chapterName」を編集',
@@ -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がページの編集を開始しました',
@@ -244,7 +244,7 @@ return [
],
'pages_draft_discarded' => '下書きが破棄されました。エディタは現在の内容へ復元されています。',
'pages_specific' => '特定のページ',
'pages_is_template' => 'Page Template',
'pages_is_template' => 'ページテンプレート',
// Editor Sidebar
'page_tags' => 'タグ',
@@ -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' => 'この変更は即座に保存されます。',
@@ -275,7 +275,7 @@ return [
'attachments_upload' => 'アップロード',
'attachments_link' => 'リンクを添付',
'attachments_set_link' => 'リンクを設定',
'attachments_delete' => 'Are you sure you want to delete this attachment?',
'attachments_delete' => 'この添付ファイルを削除してよろしいですか?',
'attachments_dropzone' => 'ファイルをドロップするか、クリックして選択',
'attachments_no_files' => 'ファイルはアップロードされていません',
'attachments_explain_link' => 'ファイルをアップロードしたくない場合、他のページやクラウド上のファイルへのリンクを添付できます。',
@@ -284,7 +284,7 @@ return [
'attachments_link_url' => 'ファイルURL',
'attachments_link_url_hint' => 'WebサイトまたはファイルへのURL',
'attach' => '添付',
'attachments_insert_link' => 'Add Attachment Link to Page',
'attachments_insert_link' => '添付ファイルへのリンクをページに追加',
'attachments_edit_file' => 'ファイルを編集',
'attachments_edit_file_name' => 'ファイル名',
'attachments_edit_drop_upload' => 'ファイルをドロップするか、クリックしてアップロード',
@@ -294,12 +294,12 @@ return [
'attachments_file_uploaded' => 'ファイルがアップロードされました',
'attachments_file_updated' => 'ファイルが更新されました',
'attachments_link_attached' => 'リンクがページへ添付されました',
'templates' => 'Templates',
'templates_set_as_template' => 'Page is a template',
'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.',
'templates_replace_content' => 'Replace page content',
'templates_append_content' => 'Append to page content',
'templates_prepend_content' => 'Prepend to page content',
'templates' => 'テンプレート',
'templates_set_as_template' => 'テンプレートに設定',
'templates_explain_set_as_template' => 'このページをテンプレートとして設定すると、他のページを作成する際にこの内容を利用することができます。他のユーザーは、このページの表示権限を持っていればこのテンプレートを使用できます。',
'templates_replace_content' => 'ページの内容を置換',
'templates_append_content' => 'ページの末尾に追加',
'templates_prepend_content' => 'ページの先頭に追加',
// Profile View
'profile_user_for_x' => ':time前に作成',
@@ -307,7 +307,7 @@ return [
'profile_not_created_pages' => ':userNameはページを作成していません',
'profile_not_created_chapters' => ':userNameはチャプターを作成していません',
'profile_not_created_books' => ':userNameはブックを作成していません',
'profile_not_created_shelves' => ':userName has not created any shelves',
'profile_not_created_shelves' => ':userNameは本棚を作成していません',
// Comments
'comment' => 'コメント',

View File

@@ -18,7 +18,7 @@ return [
'name' => 'Navn',
'description' => 'Beskrivelse',
'role' => 'Rolle',
'cover_image' => 'Bokomslag',
'cover_image' => 'Forside',
'cover_image_description' => 'Bildet bør være ca. 440x250px.',
// Actions

View File

@@ -27,7 +27,7 @@ return [
'images' => 'Bilder',
'my_recent_drafts' => 'Mine nylige utkast',
'my_recently_viewed' => 'Mine nylige visninger',
'my_most_viewed_favourites' => 'Mine mest viste favoritter',
'my_most_viewed_favourites' => 'Mine mest sette favoritter',
'my_favourites' => 'Mine favoritter',
'no_pages_viewed' => 'Du har ikke sett på noen sider',
'no_pages_recently_created' => 'Ingen sider har nylig blitt opprettet',
@@ -99,7 +99,7 @@ return [
'shelves_permissions' => 'Tilganger til hylla',
'shelves_permissions_updated' => 'Hyllas tilganger er oppdatert',
'shelves_permissions_active' => 'Hyllas tilganger er aktive',
'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' => 'Tillatelser på bokhyller vil ikke automatisk fordeles til bøkene på aktuell bokhylle. Dette da en bok kan tilhøre flere bokhyller. Tillatelser kan imidlertid kopieres til underliggende bøker ved å benytte alternativet nedenfor.',
'shelves_copy_permissions_to_books' => 'Kopier tilganger til bøkene på hylla',
'shelves_copy_permissions' => 'Kopier tilganger',
'shelves_copy_permissions_explain' => 'Dette vil angi gjeldende tillatelsesinnstillinger for denne bokhyllen på alle bøkene som finnes på den. Før du aktiverer, må du forsikre deg om at endringer i tillatelsene til denne bokhyllen er lagret.',
@@ -234,7 +234,7 @@ return [
'pages_initial_name' => 'Ny side',
'pages_editing_draft_notification' => 'Du skriver på et utkast som sist ble lagret :timeDiff.',
'pages_draft_edited_notification' => 'Siden har blitt endret siden du startet. Det anbefales at du forkaster dine endringer.',
'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' => 'Denne siden er blitt oppdatert etter at dette utkastet ble opprettet. Det anbefales at du forkaster dette utkastet, eller er ekstra forsiktig slik at du ikke overskriver noen sideendringer.',
'pages_draft_edit_active' => [
'start_a' => ':count forfattere har begynt å endre denne siden.',
'start_b' => ':userName skriver på siden for øyeblikket',

View File

@@ -6,11 +6,11 @@
*/
return [
'failed' => 'As credenciais fornecidas não correspondem aos nossos registos.',
'throttle' => 'Demasiadas tentativas de início de sessão. Por favor, tente novamente em :seconds segundos.',
'failed' => 'Estas credenciais não coincidem com os nossos registos.',
'throttle' => 'Demasiadas tentativas de acesso. Tente novamente em :seconds segundos.',
// Login & Register
'sign_up' => 'Criar conta',
'sign_up' => 'Registar',
'log_in' => 'Iniciar sessão',
'log_in_with' => 'Iniciar sessão com :socialDriver',
'sign_up_with' => 'Criar conta com :socialDriver',
@@ -54,7 +54,7 @@ return [
'email_confirm_text' => 'Por favor, confirme o seu endereço de e-mail ao carregar no botão abaixo:',
'email_confirm_action' => 'Confirmar E-mail',
'email_confirm_send_error' => 'A confirmação do endereço de e-mail é requerida, mas o sistema não pôde enviar a mensagem. Por favor, entre em contacto com o administrador para se certificar que o serviço de envio de e-mails está corretamente configurado.',
'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
'email_confirm_success' => 'O seu endereço de email foi confirmado! Neste momento já poderá entrar usando este endereço de email.',
'email_confirm_resent' => 'E-mail de confirmação reenviado. Por favor, verifique a sua caixa de entrada.',
'email_not_confirmed' => 'Endereço de E-mail Não Confirmado',
@@ -71,7 +71,7 @@ return [
'user_invite_page_welcome' => 'Bem-vindo(a) a :appName!',
'user_invite_page_text' => 'Para finalizar a sua conta e obter acesso, precisa de definir uma senha que será utilizada para efetuar login em :appName em visitas futuras.',
'user_invite_page_confirm_button' => 'Confirmar Palavra-Passe',
'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
'user_invite_success_login' => 'Palavra passe definida, agora poderá entrar usado a sua nova palavra passe para acessar :appName!',
// Multi-factor Authentication
'mfa_setup' => 'Configurar autenticação de múltiplos fatores',
@@ -80,31 +80,31 @@ return [
'mfa_setup_reconfigure' => 'Reconfigurar',
'mfa_setup_remove_confirmation' => 'Tem a certeza que deseja remover este método de autenticação de múltiplos fatores?',
'mfa_setup_action' => 'Configuração',
'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_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.',
'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
'mfa_gen_backup_codes_download' => 'Download Codes',
'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
'mfa_gen_totp_title' => 'Mobile App Setup',
'mfa_gen_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_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
'mfa_gen_totp_verify_setup' => 'Verify Setup',
'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
'mfa_verify_access' => 'Verify Access',
'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
'mfa_verify_no_methods' => 'No Methods Configured',
'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
'mfa_verify_use_totp' => 'Verify using a mobile app',
'mfa_verify_use_backup_codes' => 'Verify using a backup code',
'mfa_verify_backup_code' => 'Backup Code',
'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_backup_codes_usage_limit_warning' => 'Você tem menos de 5 códigos de backup restantes, Por favor, gere e armazene um novo conjunto antes de esgotar os códigos para evitar estar bloqueado para fora da sua conta.',
'mfa_option_totp_title' => 'Aplicação móvel',
'mfa_option_totp_desc' => 'Para usar a autenticação multi-fator, você precisará de um aplicativo móvel que suporte TOTP como o Autenticador do Google, Authy ou o autenticador Microsoft.',
'mfa_option_backup_codes_title' => 'Códigos de Backup',
'mfa_option_backup_codes_desc' => 'Armazene com segurança um conjunto de códigos de backup únicos que você pode inserir para verificar sua identidade.',
'mfa_gen_confirm_and_enable' => 'Confirmar e ativar',
'mfa_gen_backup_codes_title' => 'Configuração dos Códigos de Backup',
'mfa_gen_backup_codes_desc' => 'Armazene a lista de códigos abaixo em um lugar seguro. Ao acessar o sistema você poderá usar um dos códigos como um segundo mecanismo de autenticação.',
'mfa_gen_backup_codes_download' => 'Transferir códigos',
'mfa_gen_backup_codes_usage_warning' => 'Cada código só pode ser usado uma vez',
'mfa_gen_totp_title' => 'Configuração de aplicativo móvel',
'mfa_gen_totp_desc' => 'Para usar a autenticação multi-fator, você precisará de um aplicativo móvel que suporte TOTP como o Autenticador do Google, Authy ou o autenticador Microsoft.',
'mfa_gen_totp_scan' => 'Leia o código QR abaixo usando seu aplicativo de autenticação preferido para começar.',
'mfa_gen_totp_verify_setup' => 'Verificar configuração',
'mfa_gen_totp_verify_setup_desc' => 'Verifique se tudo está funcionando digitando um código, gerado dentro do seu aplicativo de autenticação, na caixa de entrada abaixo:',
'mfa_gen_totp_provide_code_here' => 'Forneça o código gerado pelo aplicativo aqui',
'mfa_verify_access' => 'Verificar Acesso',
'mfa_verify_access_desc' => 'Sua conta de usuário requer que você confirme sua identidade por meio de um nível adicional de verificação antes de conceder o acesso. Verifique o uso de um dos métodos configurados para continuar.',
'mfa_verify_no_methods' => 'Nenhum método configurado',
'mfa_verify_no_methods_desc' => 'Nenhum método de autenticação de vários fatores foi encontrado para a sua conta. Você precisará configurar pelo menos um método antes de ganhar acesso.',
'mfa_verify_use_totp' => 'Verificar usando um aplicativo móvel',
'mfa_verify_use_backup_codes' => 'Verificar usando código de backup',
'mfa_verify_backup_code' => 'Código de backup',
'mfa_verify_backup_code_desc' => 'Insira um dos seus códigos de backup restantes abaixo:',
'mfa_verify_backup_code_enter_here' => 'Insira o código de backup aqui',
'mfa_verify_totp_desc' => 'Digite o código, gerado através do seu aplicativo móvel, abaixo:',
'mfa_setup_login_notification' => 'Método de multi-fatores configurado, por favor faça login novamente usando o método configurado.',
];

View File

@@ -45,8 +45,8 @@ return [
'unfavourite' => 'Retirar Favorito',
'next' => 'Próximo',
'previous' => 'Anterior',
'filter_active' => 'Active Filter:',
'filter_clear' => 'Clear Filter',
'filter_active' => 'Filtro Ativo:',
'filter_clear' => 'Limpar Filtro',
// Sort Options
'sort_options' => 'Opções de Ordenação',

View File

@@ -258,15 +258,15 @@ return [
'tags_explain' => "Adicione algumas etiquetas para melhor categorizar o seu conteúdo. \n Você poderá atribuir valores às etiquetas para uma organização mais complexa.",
'tags_add' => 'Adicionar outra etiqueta',
'tags_remove' => 'Remover esta etiqueta',
'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_usages' => 'Total de marcadores usados',
'tags_assigned_pages' => 'Atribuído às páginas',
'tags_assigned_chapters' => 'Atribuído aos Capítulos',
'tags_assigned_books' => 'Atribuído a Livros',
'tags_assigned_shelves' => 'Atribuído a Prateleiras',
'tags_x_unique_values' => ':count valores únicos',
'tags_all_values' => 'Todos os valores',
'tags_view_tags' => 'Ver Marcadores',
'tags_view_existing_tags' => 'Ver marcadores existentes',
'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
'attachments' => 'Anexos',
'attachments_explain' => 'Carregue alguns arquivos ou anexe links para serem exibidos na sua página. Eles estarão visíveis na barra lateral à direita.',

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

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

@@ -54,7 +54,7 @@ return [
'email_confirm_text' => 'Будь ласка, підтвердьте свою адресу електронної пошти, натиснувши кнопку нижче:',
'email_confirm_action' => 'Підтвердити Email',
'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,40 +71,40 @@ 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' => 'Пароль встановлено, ви повинні увійти в систему, використовуючи свій встановлений пароль для доступу :appNam!',
// 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_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_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.',
'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
'mfa_gen_backup_codes_download' => 'Download Codes',
'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
'mfa_gen_totp_title' => 'Mobile App Setup',
'mfa_gen_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_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
'mfa_gen_totp_verify_setup' => 'Verify Setup',
'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
'mfa_verify_access' => 'Verify Access',
'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
'mfa_verify_no_methods' => 'No Methods Configured',
'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
'mfa_verify_use_totp' => 'Verify using a mobile app',
'mfa_verify_use_backup_codes' => 'Verify using a backup code',
'mfa_verify_backup_code' => 'Backup Code',
'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' => 'Налаштувати двофакторну автентифікацію',
'mfa_setup_desc' => 'Двофакторна аутентифікація додає ще один рівень безпеки для вашого облікового запису.',
'mfa_setup_configured' => 'Вже налаштовано',
'mfa_setup_reconfigure' => 'Переналаштувати',
'mfa_setup_remove_confirmation' => 'Ви впевнені, що хочете видалити цей метод багатофакторної автентифікації?',
'mfa_setup_action' => 'Встановлення',
'mfa_backup_codes_usage_limit_warning' => 'Залишилося менше 5 резервних кодів, Будь ласка, створіть та збережіть новий набір до того, як у вас не вистачає кодів, щоб запобігти блокуванню вашої обліковки.',
'mfa_option_totp_title' => 'Мобільний додаток',
'mfa_option_totp_desc' => 'Для використання багатофакторної автентифікації вам потрібен мобільний додаток, який підтримує TOTP такі як Google Authenticator, Authy або Microsoft Authenticator.',
'mfa_option_backup_codes_title' => 'Резервні коди',
'mfa_option_backup_codes_desc' => 'Безпечно зберігайте набір резервних кодів з використанням одноразового використання, які можна увійти, щоб підтвердити свою особу.',
'mfa_gen_confirm_and_enable' => 'Підтвердити та увімкнути',
'mfa_gen_backup_codes_title' => 'Налаштування резервних кодів',
'mfa_gen_backup_codes_desc' => 'Зберігайте список кодів в безпечному місці. Для доступу до системи ви зможете використовувати один з кодів як другий механізм аутентифікації.',
'mfa_gen_backup_codes_download' => 'Завантажити коди',
'mfa_gen_backup_codes_usage_warning' => 'Кожний код можна використати лише один раз',
'mfa_gen_totp_title' => 'Налаштування мобільного додатка',
'mfa_gen_totp_desc' => 'Для використання багатофакторної автентифікації вам потрібен мобільний додаток, який підтримує TOTP такі як Google Authenticator, Authy або Microsoft Authenticator.',
'mfa_gen_totp_scan' => 'Проскануйте QR-код внизу за допомогою бажаного додатку для аутентифікації, щоб розпочати.',
'mfa_gen_totp_verify_setup' => 'Перевірка налаштувань',
'mfa_gen_totp_verify_setup_desc' => 'Переконайтеся, що все працює, ввівши код, згенерований у вашому додатку аутентифікації, в полі вводу нижче:',
'mfa_gen_totp_provide_code_here' => 'Вкажіть код вашої програми тут',
'mfa_verify_access' => 'Підтвердити доступ',
'mfa_verify_access_desc' => 'Ваш обліковий запис користувача вимагає підтвердження за допомогою додаткового рівня перевірки, перш ніж отримати доступ. Перевірте, використовуючи один з ваших налаштованих способів для продовження.',
'mfa_verify_no_methods' => 'Немає налаштованих методів',
'mfa_verify_no_methods_desc' => 'Для вашого облікового запису не знайдено жодних методів багатофакторної аутентифікації. Вам потрібно буде налаштувати хоча б один спосіб перш ніж отримати доступ.',
'mfa_verify_use_totp' => 'Перевірити за допомогою мобільного додатку',
'mfa_verify_use_backup_codes' => 'Перевірка використовуючи резервний код',
'mfa_verify_backup_code' => 'Резервний код',
'mfa_verify_backup_code_desc' => 'Введіть один з резервних кодів нижче:',
'mfa_verify_backup_code_enter_here' => 'Введіть резервний код тут',
'mfa_verify_totp_desc' => 'Введіть код, згенерований за допомогою мобільного додатку:',
'mfa_setup_login_notification' => 'Налаштовано багатофакторний метод аутентифікації. Будь ласка, зараз увійдіть в систему знову, використовуючи налаштований метод.',
];

View File

@@ -39,14 +39,14 @@ return [
'reset' => 'Скинути',
'remove' => 'Видалити',
'add' => 'Додати',
'configure' => 'Configure',
'configure' => 'Налаштувати',
'fullscreen' => 'На весь екран',
'favourite' => 'Favourite',
'unfavourite' => 'Unfavourite',
'next' => 'Next',
'previous' => 'Previous',
'filter_active' => 'Active Filter:',
'filter_clear' => 'Clear Filter',
'favourite' => 'Улюблене',
'unfavourite' => 'Прибрати з обраного',
'next' => 'Уперед',
'previous' => 'Назад',
'filter_active' => 'Активний фільтр:',
'filter_clear' => 'Очистити фільтр',
// Sort Options
'sort_options' => 'Параметри сортування',
@@ -63,7 +63,7 @@ return [
'no_activity' => 'Немає активності для показу',
'no_items' => 'Немає доступних елементів',
'back_to_top' => 'Повернутися до початку',
'skip_to_main_content' => 'Skip to main content',
'skip_to_main_content' => 'Перейти до змісту',
'toggle_details' => 'Подробиці',
'toggle_thumbnails' => 'Мініатюри',
'details' => 'Деталі',
@@ -73,7 +73,7 @@ return [
'breadcrumb' => 'Навігація',
// Header
'header_menu_expand' => 'Expand Header Menu',
'header_menu_expand' => 'Розгорнути меню заголовка',
'profile_menu' => 'Меню профілю',
'view_profile' => 'Переглянути профіль',
'edit_profile' => 'Редагувати профіль',
@@ -82,9 +82,9 @@ return [
// Layout tabs
'tab_info' => 'Інфо',
'tab_info_label' => 'Tab: Show Secondary Information',
'tab_info_label' => 'Вкладка: показувати додаткову інформацію',
'tab_content' => 'Вміст',
'tab_content_label' => 'Tab: Show Primary Content',
'tab_content_label' => 'Вкладка: Показати основний вміст',
// Email Content
'email_action_help' => 'Якщо у вас виникають проблеми при натисканні кнопки ":actionText", скопіюйте та вставте URL у свій веб-браузер:',

View File

@@ -27,8 +27,8 @@ return [
'images' => 'Зображення',
'my_recent_drafts' => 'Мої останні чернетки',
'my_recently_viewed' => 'Мої недавні перегляди',
'my_most_viewed_favourites' => 'My Most Viewed Favourites',
'my_favourites' => 'My Favourites',
'my_most_viewed_favourites' => 'Мої найпопулярніші улюблені',
'my_favourites' => 'Моє обране',
'no_pages_viewed' => 'Ви не переглядали жодної сторінки',
'no_pages_recently_created' => 'Не було створено жодної сторінки',
'no_pages_recently_updated' => 'Немає недавно оновлених сторінок',
@@ -36,7 +36,7 @@ return [
'export_html' => 'Вбудований веб-файл',
'export_pdf' => 'PDF файл',
'export_text' => 'Текстовий файл',
'export_md' => 'Markdown File',
'export_md' => 'Файл розмітки',
// 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' => 'Не вдалося знайти електронну адресу для цього користувача у даних, наданих зовнішньою системою аутентифікації',
'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' => 'Не вдалося знайти адресу електронної пошти для цього користувача у даних, наданих зовнішньою системою автентифікації',
'oidc_fail_authed' => 'Увійти за допомогою :system не вдалося, система не надала успішної авторизації',
'social_no_action_defined' => 'Жодних дій не визначено',
'social_login_bad_response' => "Помилка, отримана під час входу з :socialAccount помилка : \n:error",
'social_account_in_use' => 'Цей :socialAccount обліковий запис вже використовується, спробуйте ввійти з параметрами :socialAccount.',
@@ -87,9 +87,9 @@ return [
'404_page_not_found' => 'Сторінку не знайдено',
'sorry_page_not_found' => 'Вибачте, сторінку, яку ви шукали, не знайдено.',
'sorry_page_not_found_permission_warning' => 'Якщо ви очікували що ця сторінки існує можливо у вас немає дозволу на її перегляд.',
'image_not_found' => 'Image Not Found',
'image_not_found_subtitle' => 'Sorry, The image file you were looking for could not be found.',
'image_not_found_details' => 'If you expected this image to exist it might have been deleted.',
'image_not_found' => 'Зображення не знайдено',
'image_not_found_subtitle' => 'Вибачте, файл зображення, що ви шукали, не знайдено.',
'image_not_found_details' => 'Якщо ви очікували існування цього зображення, його, можливо, було видалено.',
'return_home' => 'Повернутися на головну',
'error_occurred' => 'Виникла помилка',
'app_down' => ':appName зараз недоступний',

View File

@@ -17,7 +17,7 @@ return [
'app_name' => 'Назва програми',
'app_name_desc' => 'Ця назва показується у заголовку та в усіх листах.',
'app_name_header' => 'Показати назву програми в заголовку',
'app_public_access' => 'Публічнй доступ',
'app_public_access' => 'Публічний доступ',
'app_public_access_desc' => 'Увімкнення цієї опції дозволить відвідувачам, які не увійшли в систему, отримати доступ до вмісту у вашому екземплярі BookStack.',
'app_public_access_desc_guest' => 'Доступ для публічних відвідувачів можна контролювати через користувача "Гість".',
'app_public_access_toggle' => 'Дозволити публічний доступ',
@@ -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 елементів із кошика.',
@@ -119,7 +119,7 @@ return [
'audit_table_user' => 'Користувач',
'audit_table_event' => 'Подія',
'audit_table_related' => 'Пов’язаний елемент',
'audit_table_ip' => 'IP Address',
'audit_table_ip' => 'IP-адреса',
'audit_table_date' => 'Дата активності',
'audit_date_from' => 'Діапазон дат від',
'audit_date_to' => 'Діапазон дат до',
@@ -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' => 'Ці дозволи контролюють стандартні доступи всередині системи. Права на книги, розділи та сторінки перевизначать ці дозволи.',
@@ -207,10 +207,10 @@ return [
'users_api_tokens_create' => 'Створити токен',
'users_api_tokens_expires' => 'Закінчується',
'users_api_tokens_docs' => 'Документація API',
'users_mfa' => 'Multi-Factor Authentication',
'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
'users_mfa_x_methods' => ':count method configured|:count methods configured',
'users_mfa_configure' => 'Configure Methods',
'users_mfa' => 'Багатофакторна Автентифікація',
'users_mfa_desc' => 'Двофакторна аутентифікація додає ще один рівень безпеки для вашого облікового запису.',
'users_mfa_x_methods' => ':count метод налаштовано|:count методів налаштовано',
'users_mfa_configure' => 'Налаштувати Методи',
// API Tokens
'user_api_token_create' => 'Створити токен API',

View File

@@ -15,7 +15,7 @@ return [
'alpha_dash' => 'Поле :attribute має містити лише літери, цифри, дефіси та підкреслення.',
'alpha_num' => 'Поле :attribute має містити лише літери та цифри.',
'array' => 'Поле :attribute має бути масивом.',
'backup_codes' => 'The provided code is not valid or has already been used.',
'backup_codes' => 'Наданий код є недійсним або вже використаний.',
'before' => 'Поле :attribute має містити дату не пізніше :date.',
'between' => [
'numeric' => 'Поле :attribute має бути між :min та :max.',
@@ -99,7 +99,7 @@ return [
],
'string' => 'Поле :attribute повинне містити текст.',
'timezone' => 'Поле :attribute повинне містити коректну часову зону.',
'totp' => 'The provided code is not valid or has expired.',
'totp' => 'Наданий код не є дійсним або прострочений.',
'unique' => 'Вказане значення поля :attribute вже існує.',
'url' => 'Формат поля :attribute неправильний.',
'uploaded' => 'Не вдалося завантажити файл. Сервер може не приймати файли такого розміру.',

View File

@@ -96,12 +96,12 @@ return [
'mfa_gen_totp_verify_setup' => 'Xác nhận cài đặt',
'mfa_gen_totp_verify_setup_desc' => 'Xác nhận rằng tất cả hoạt động bằng cách nhập vào một mã, được tạo ra bởi ứng dụng xác thực của bạn vào ô dưới đây:',
'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
'mfa_verify_access' => 'Verify Access',
'mfa_verify_access' => 'Xác thực truy cập',
'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
'mfa_verify_no_methods' => 'No Methods Configured',
'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
'mfa_verify_use_totp' => 'Verify using a mobile app',
'mfa_verify_use_backup_codes' => 'Verify using a backup code',
'mfa_verify_no_methods' => 'Không có phương pháp nào được cấu hình',
'mfa_verify_no_methods_desc' => 'Tài khoản của bạn chưa đăng ký xác thực nhiều lớp. Bạn cần thiết lập ít nhất một phương pháp trước khi yêu cầu truy cập.',
'mfa_verify_use_totp' => 'Xác thực sử dụng mã di động',
'mfa_verify_use_backup_codes' => 'Xác thực sử dụng backup',
'mfa_verify_backup_code' => 'Mã dự phòng',
'mfa_verify_backup_code_desc' => 'Nhập một trong các mã dự phòng còn lại của bạn vào ô phía dưới:',
'mfa_verify_backup_code_enter_here' => 'Nhập mã xác thực của bạn tại đây',

View File

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

@@ -263,7 +263,7 @@ return [
'tags_assigned_chapters' => '有这个标签的章节',
'tags_assigned_books' => '有这个标签的图书',
'tags_assigned_shelves' => '有这个标签的书架',
'tags_x_unique_values' => '个不重复项目',
'tags_x_unique_values' => ':count 个不重复项目',
'tags_all_values' => '所有值',
'tags_view_tags' => '查看标签',
'tags_view_existing_tags' => '查看已有的标签',

View File

@@ -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' => '全部的',

View File

@@ -1,5 +1,27 @@
<h1 class="list-heading text-capitals mb-l">Getting Started</h1>
<p class="mb-none">
This documentation covers use of the REST API. <br>
Some alternative options for extension and customization can be found below:
</p>
<ul>
<li>
<a href="{{ url('/settings/webhooks') }}" target="_blank" rel="noopener noreferrer">Webhooks</a> -
HTTP POST calls upon events occurring in BookStack.
</li>
<li>
<a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/visual-theme-system.md" target="_blank" rel="noopener noreferrer">Visual Theme System</a> -
Methods to override views, translations and icons within BookStack.
</li>
<li>
<a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/logical-theme-system.md" target="_blank" rel="noopener noreferrer">Logical Theme System</a> -
Methods to extend back-end functionality within BookStack.
</li>
</ul>
<hr>
<h5 id="authentication" class="text-mono mb-m">Authentication</h5>
<p>
To access the API a user has to have the <em>"Access System API"</em> permission enabled on one of their assigned roles.

View File

@@ -0,0 +1,40 @@
@extends('layouts.simple')
@section('body')
<div class="container small">
<div class="my-s">
@include('entities.breadcrumbs', ['crumbs' => [
$book,
$book->getUrl('/copy') => [
'text' => trans('entities.books_copy'),
'icon' => 'copy',
]
]])
</div>
<div class="card content-wrap auto-height">
<h1 class="list-heading">{{ trans('entities.books_copy') }}</h1>
<form action="{{ $book->getUrl('/copy') }}" method="POST">
{!! csrf_field() !!}
<div class="form-group title-input">
<label for="name">{{ trans('common.name') }}</label>
@include('form.text', ['name' => 'name'])
</div>
@include('entities.copy-considerations')
<div class="form-group text-right">
<a href="{{ $book->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
<button type="submit" class="button">{{ trans('entities.books_copy') }}</button>
</div>
</form>
</div>
</div>
@stop

View File

@@ -110,6 +110,12 @@
<span>{{ trans('common.sort') }}</span>
</a>
@endif
@if(userCan('book-create-all'))
<a href="{{ $book->getUrl('/copy') }}" class="icon-list-item">
<span>@icon('copy')</span>
<span>{{ trans('common.copy') }}</span>
</a>
@endif
@if(userCan('restrictions-manage', $book))
<a href="{{ $book->getUrl('/permissions') }}" class="icon-list-item">
<span>@icon('lock')</span>

View File

@@ -0,0 +1,50 @@
@extends('layouts.simple')
@section('body')
<div class="container small">
<div class="my-s">
@include('entities.breadcrumbs', ['crumbs' => [
$chapter->book,
$chapter,
$chapter->getUrl('/copy') => [
'text' => trans('entities.chapters_copy'),
'icon' => 'copy',
]
]])
</div>
<div class="card content-wrap auto-height">
<h1 class="list-heading">{{ trans('entities.chapters_copy') }}</h1>
<form action="{{ $chapter->getUrl('/copy') }}" method="POST">
{!! csrf_field() !!}
<div class="form-group title-input">
<label for="name">{{ trans('common.name') }}</label>
@include('form.text', ['name' => 'name'])
</div>
<div class="form-group" collapsible>
<button type="button" class="collapse-title text-primary" collapsible-trigger aria-expanded="false">
<label for="entity_selection">{{ trans('entities.pages_copy_desination') }}</label>
</button>
<div class="collapse-content" collapsible-content>
@include('entities.selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book', 'entityPermission' => 'chapter-create'])
</div>
</div>
@include('entities.copy-considerations')
<div class="form-group text-right">
<a href="{{ $chapter->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
<button type="submit" class="button">{{ trans('entities.chapters_copy') }}</button>
</div>
</form>
</div>
</div>
@stop

View File

@@ -108,6 +108,12 @@
<span>{{ trans('common.edit') }}</span>
</a>
@endif
@if(userCanOnAny('chapter-create'))
<a href="{{ $chapter->getUrl('/copy') }}" class="icon-list-item">
<span>@icon('copy')</span>
<span>{{ trans('common.copy') }}</span>
</a>
@endif
@if(userCan('chapter-update', $chapter) && userCan('chapter-delete', $chapter))
<a href="{{ $chapter->getUrl('/move') }}" class="icon-list-item">
<span>@icon('folder')</span>

View File

@@ -24,8 +24,6 @@
"{{ $activity->entity->name }}"
@endif
@if($activity->extra) "{{ $activity->extra }}" @endif
<br>
<span class="text-muted"><small>@icon('time'){{ $activity->created_at->diffForHumans() }}</small></span>

View File

@@ -0,0 +1,15 @@
<p class="text-warn mb-none mt-l">
@icon('warning') <strong>{{ trans('entities.copy_consider') }}</strong>
</p>
<div class="grid half no-gap no-row-gap text-warn mb-m">
<ul class="pr-s mb-none">
<li>{{ trans('entities.copy_consider_permissions') }}</li>
<li>{{ trans('entities.copy_consider_owner') }}</li>
<li>{{ trans('entities.copy_consider_images') }}</li>
</ul>
<ul class="pr-s mb-none">
<li>{{ trans('entities.copy_consider_attachments') }}</li>
<li>{{ trans('entities.copy_consider_access') }}</li>
</ul>
</div>

View File

@@ -0,0 +1,3 @@
@if($errors->has($name))
<div class="text-neg text-small">{{ $errors->first($name) }}</div>
@endif

View File

@@ -37,6 +37,8 @@
</div>
</div>
@include('entities.copy-considerations')
<div class="form-group text-right">
<a href="{{ $page->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
<button type="submit" class="button">{{ trans('entities.pages_copy') }}</button>

View File

@@ -10,7 +10,7 @@
</div>
<div class="card content-wrap auto-height">
<h2 class="list-heading">{{ trans('settings.audit') }}</h2>
<h1 class="list-heading">{{ trans('settings.audit') }}</h1>
<p class="text-muted">{{ trans('settings.audit_desc') }}</p>
<div class="flex-container-row">
@@ -41,12 +41,19 @@
</div>
@endforeach
<div class="form-group ml-auto"
<div class="form-group ml-auto mr-m"
component="submit-on-change"
option:submit-on-change:filter='[name="user"]'>
<label for="owner">{{ trans('settings.audit_table_user') }}</label>
@include('form.user-select', ['user' => $listDetails['user'] ? \BookStack\Auth\User::query()->find($listDetails['user']) : null, 'name' => 'user', 'compact' => true])
</div>
<div class="form-group ml-auto">
<label for="ip">{{ trans('settings.audit_table_ip') }}</label>
@include('form.text', ['name' => 'ip', 'model' => (object) $listDetails])
<input type="submit" style="display: none">
</div>
</form>
</div>

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