Compare commits

...

75 Commits

Author SHA1 Message Date
Dan Brown
27bf4299cf Updated version and assets for release v23.08 2023-08-30 12:38:48 +01:00
Dan Brown
164f01bb25 Merge branch 'development' into release 2023-08-30 12:38:22 +01:00
Dan Brown
c6d0e690f9 Updated translations with latest Crowdin changes (#4462) 2023-08-30 12:35:10 +01:00
Dan Brown
77d65d1ca1 Updated translator attribution before v23.08 2023-08-30 11:49:45 +01:00
Dan Brown
dc77233ec3 MD Editor: Fixed scroll on mobile widths
Added min-height to flex elements to ensure they properly flex within
the container rathen than adjust to content.

For #4466
2023-08-30 02:41:51 +01:00
Dan Brown
3622c440d7 SSR: Added new option to complete env example file 2023-08-30 02:31:36 +01:00
Dan Brown
642210ab4c Merge branch 'srr_host_allowlist' into development 2023-08-27 12:45:00 +01:00
Dan Brown
e176aae940 Updated translations with latest Crowdin changes (#4380) 2023-08-27 12:43:59 +01:00
Dan Brown
903895814a SSR: Updated allow list handling & covered webhook usage
- Covered webhook SSR allow list useage via test.
- Updated allow list handling to use trailing slash, or hash, or end of
  line as late anchor for better handling for hosts (prevent .co.uk
passing for .co domain host)
2023-08-26 20:13:37 +01:00
Dan Brown
c324ad928d Security: Added new SSR allow list and validator
Included unit tests to cover validator functionality.
Added to webhooks.
Still need to do testing specifically for webhooks.
2023-08-26 15:28:29 +01:00
Dan Brown
9100a82b47 Guests: Prevented access to profile routes
Prevention of action on certain routes for guest user when public access
is enabled. Could not see a way this could be a security issue, beyond a
mild nuisance that'd only be visible if public users can edit, which
would present larger potential nuisance anyway.
2023-08-26 14:07:48 +01:00
Dan Brown
32516f7b68 Merge pull request #4457 from BookStackApp/drawing_backup_store
Browser-based drawing backup storage system
2023-08-23 19:12:29 +01:00
Dan Brown
69ac425903 Updated readme attribution and fixed eslint issues 2023-08-23 19:02:23 +01:00
Dan Brown
3917e50c90 Drawio: Tweaked fail backup handling during testing
- Tweaked wording of popup title.
- Updated WYSIWYG create handling to properly remove drawing container
  on failure.

Tested across FF and chrome, in both editors for create & editing.
2023-08-23 18:50:37 +01:00
Dan Brown
dd71658d70 Drawio: Added unsaved restore prompt and logic 2023-08-23 14:16:20 +01:00
Dan Brown
a4fbde9185 Drawio: Started browser drawing backup store system
Adds just the part to store image data, and remove on successfull save.
Alters save events to properly throw upon error.
Adds IDB-Keyval library for local large-size store.
For #4421
2023-08-22 19:30:39 +01:00
Dan Brown
cbcec189fd RTL: Fixed screen-reader-only elements pushout out view
For #4429
2023-08-22 18:25:14 +01:00
Dan Brown
0628c28f66 Cache: Increases database cache value size
Upped from text to medium text.
Aligns with modern Laravel default.
Fixes #4453 where were reaching the limit of TEXT.
2023-08-21 23:01:42 +01:00
Dan Brown
391478465a Merge branch 'add-priority' into development 2023-08-21 15:43:16 +01:00
Dan Brown
9ca1139ab0 API: Reviewed changes for API priority control
Review of #4313
- Made constructor changes while reviewing some classes.
- Updated API examples for consistency.
- Tweaked formatting for some array changes.
- Simplified added tests.
- Tweaked chapter/page repo priority handling to be simpler.

Performed manual API endpoint testing of page/chapter create/update.
2023-08-21 15:42:47 +01:00
Dan Brown
7bf5425c6b Updated PHP and npm deps, Upped node version 2023-08-19 20:22:19 +01:00
Dan Brown
e44ef57219 Status: Updated cache check to use unique key
Updated status endpoint cache check to include a random component in the
key to avoid conflict during simultaneous checks.
For #4396
2023-08-17 21:24:35 +01:00
Dan Brown
fef433a9cb Merge pull request #4390 from BookStackApp/content_notifications
Content user notifications
2023-08-17 21:09:52 +01:00
Dan Brown
e709caa005 Notifications: Switched testing from string to reference levels 2023-08-17 18:10:34 +01:00
Dan Brown
38829f8a38 Notifications: Fixed send content permission checking
Added test and changed logic to properly check the view permissions for
the notification receiver before sending.
Required change to permissions applicator to allow the user to be
manually determined, and a service provider update to provide the class
as a singleton without a specific user, so it checks the current logged
in user on demand.
2023-08-17 17:57:31 +01:00
Dan Brown
ee9e342b58 Notifications: Fixed issues causing failing tests
- Ensured watch options passed in all meta template usage to fix failing
  scenarios where watch options did not exist.
- Fixed testing issue caused by guest user permission caching.
2023-08-17 14:59:28 +01:00
Dan Brown
79470ea4b7 Notifications: Made improvements from manual testing
- Added titles for preference pages.
- Added extra check for non-guest for notifications on preferences page.
2023-08-16 20:15:49 +01:00
Dan Brown
565908ef52 Notifications: Add phpunit test for notification sending
Covers core case scenarios, and check of notification content.
2023-08-16 16:02:00 +01:00
Dan Brown
bc6e19b2a1 Notifications: Added testing to cover controls 2023-08-15 20:08:27 +01:00
Dan Brown
615741af9d Notifications: Cleaned up mails, added debounce for updates
- Updated mail notification design to be a bit prettier, and extracted
  text to new lang file for translation.
- Added debounce logic for page update notifications.
- Fixed watch options not being filtered to current user.
2023-08-15 14:39:39 +01:00
Dan Brown
371779205a Notifications: Added new preferences view and access control
- Added general user preferences view and updated link in profile menu
  to suit.
- Made notification permission required for notification preferences
  view, added test to cover.
2023-08-14 17:29:12 +01:00
Dan Brown
d9fdecd902 Notifications: User watch list and differnt page watch options
- Adds option filtering and alternative text for page watch options.
- Adds "Watched & Ignored Items" list to user notification preferences
  page to show existing watched items.
2023-08-14 13:11:18 +01:00
Dan Brown
c47b3f805a Notifications: Updated watch control to show parent status 2023-08-09 14:53:31 +01:00
Dan Brown
ecab2c8e42 Notifications: Added logic and classes for remaining notification types 2023-08-05 14:19:23 +01:00
Dan Brown
18ae67a138 Notifications: Got core notification logic working for new pages
Also rolled out watch UI to chapter and page views
2023-08-04 16:51:29 +01:00
Dan Brown
9779c1a357 Notifications: Started core user notification logic
Put together an initial notification.
Started logic to query and identify watchers.
2023-08-04 12:27:29 +01:00
Dan Brown
9d149e4d36 Notifications: Linked watch functionality to UI
Got watch system working to an initial base state.
Moved some existing logic where it makes sense.
2023-08-02 13:14:00 +01:00
Dan Brown
8cdf3203ef Notifications: Started back-end for watch system
Added DB and started controller method.
2023-07-31 16:08:29 +01:00
Dan Brown
6100b99828 Notifications: Extracted watch options, updated UI further 2023-07-31 15:23:28 +01:00
Dan Brown
730f539029 Notifications: Started entity watch UI 2023-07-27 14:27:45 +01:00
Dan Brown
ff2674c464 Notifications: Added role receive-notifications permission 2023-07-25 17:59:04 +01:00
Dan Brown
100b28707c Notifications: added user preference UI & logic
Includes testing to cover.
Also added file missing from previous commit.
2023-07-25 17:08:40 +01:00
Dan Brown
45e75edf05 Notifications: Started activity->notification core framework 2023-07-19 11:03:05 +01:00
Dan Brown
1c922be4c7 Comments: Added text for new activity types 2023-07-19 10:11:53 +01:00
Dan Brown
0359e2490a Comments: Updated testing to check for new activities 2023-07-19 10:09:08 +01:00
Dan Brown
422e50302a Comments: Added extra comment-specific activities
Kept existing "COMMENTED_ON" activity for upgrade compatibility,
specifically for existing webhook usage and for showing comment
activities in activity lists.

Precursor to content notifications.
Currently untested.
Also applied some type updates.
2023-07-18 15:07:31 +01:00
Dan Brown
f563a005f5 Updated version and assets for release v23.06.2 2023-07-12 22:34:25 +01:00
Dan Brown
a14d8e30cc Merge branch 'development' into release 2023-07-12 22:34:15 +01:00
Dan Brown
7504ad32a7 Updated translator attribution before release v23.06.2 2023-07-12 22:34:04 +01:00
Dan Brown
fca18862d2 Updated translations with latest Crowdin changes (#4367) 2023-07-12 22:22:43 +01:00
Dan Brown
ae834050f5 Shelf permissions: reverted create removal
Reverted work in 847a57a49a.
Left test in but updated to new expectation.
Left migration in but removed content to prevent new pre-v23.06
upgraders loosing shelf create permission status.
Added note to permission to describe use-case.

For #4375
2023-07-12 22:04:05 +01:00
Dan Brown
a83150131a Webhooks: Fixed failing delete-based events
Due to queue serialization.
Added a test to check a couple of delete events.
Added ApiTokenFactory to support.
Also made a couple of typing/doc updates while there.

Related to #4373
2023-07-12 16:16:12 +01:00
Jean-René ROUET
3a36d3c847 add tests for priority 2023-07-11 14:11:13 +02:00
Jean-René ROUET
4d399f6ba7 add priority on page and chapter create 2023-07-11 13:28:20 +02:00
Jean-René Rouet
b1b8067cbe Merge branch 'BookStackApp:development' into add-priority 2023-07-11 08:57:14 +02:00
Dan Brown
a9194ffb63 Updated version and assets for release v23.06.1 2023-07-05 13:04:51 +01:00
Dan Brown
2f9c1b7127 Merge branch 'development' into release 2023-07-05 13:04:30 +01:00
Dan Brown
18979e84d6 Updated tranlsator attribution and sponsors 2023-07-05 12:40:49 +01:00
Dan Brown
bf5e886d76 Updated translations with latest Crowdin changes (#4352) 2023-07-05 12:28:19 +01:00
Dan Brown
e04a1af444 Merge pull request #4344 from devdot/update-api-docs
Update API Docs
2023-07-05 12:08:51 +01:00
Dan Brown
eb2c5d00cb Audit log: Added IP address wrapping
Primarily to support long ipv6 addresses which would overflow over the
activity date.
For #4349
2023-07-05 11:37:49 +01:00
Dan Brown
96819b7bd9 Images: Updated image timestamp upon file change
For #4354
2023-07-05 11:28:03 +01:00
Dan Brown
18ee80a743 Roles: fixed error upon created_at sorting
Added test to cover core role sorting functionality.
For #4350
2023-07-04 21:52:46 +01:00
Dan Brown
1a56de6cb4 Testing: Split out role tests to management and permissions 2023-07-04 21:40:05 +01:00
Dan Brown
465989efa9 Mail: Updated to forked symfony/mailer to allow assurance of tls
Related to #4358
2023-07-04 15:21:31 +01:00
Thomas Kuschan
d293171da2 API Docs: Add Missing Fields in Example Responses 2023-06-30 09:36:46 +02:00
Thomas Kuschan
174cd5a893 API Docs: Add Missing editor fields in Example Responses 2023-06-30 09:35:47 +02:00
Thomas Kuschan
ccfe38e963 API Docs: Add book_slug to Example Responses
Remove the book attribute in responses because it is never returned by the API. Currently, Chapters Create does not return book_slug! (The example response is consistent with the inconsistent API behavior)
2023-06-30 09:33:53 +02:00
Thomas Kuschan
23ae332c1b API Docs: Sort a few example responses 2023-06-30 09:27:18 +02:00
Thomas Kuschan
3a39f13420 API Docs: Remove Dates from Tags in Example Responses 2023-06-30 09:24:46 +02:00
Thomas Kuschan
ca2d2c97d4 API Docs: Add User Slugs to Example Responses 2023-06-30 09:23:02 +02:00
Thomas Kuschan
e47870794d API Docs: Add Missing Type in Response
Type is always returned when pages/chapters are in a contents array.
2023-06-26 10:14:10 +02:00
Thomas Kuschan
e43d85b801 API Docs: Remove id from Tag in Response 2023-06-26 10:13:02 +02:00
Jean-René Rouet
bb3ce845b4 Merge branch 'BookStackApp:development' into add-priority 2023-06-15 16:55:14 +02:00
Jean-René ROUET
458cea3644 [API] add priority in book read
[API] add priority in chapter create and update
[API] add priority in page create and update
2023-06-12 15:12:46 +02:00
458 changed files with 8148 additions and 2416 deletions

View File

@@ -359,6 +359,15 @@ ALLOWED_IFRAME_HOSTS=null
# Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"
# A list of the sources/hostnames that can be reached by application SSR calls.
# This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
# Host-specific functionality (usually controlled via other options) like auth
# or user avatars for example, won't use this list.
# Space seperated if multiple. Can use '*' as a wildcard.
# Values will be compared prefix-matched, case-insensitive, against called SSR urls.
# Defaults to allow all hosts.
ALLOWED_SSR_HOSTS="*"
# The default and maximum item-counts for listing API requests.
API_DEFAULT_ITEM_COUNT=100
API_MAX_ITEM_COUNT=500

View File

@@ -341,3 +341,17 @@ Ingus Rūķis (ingus.rukis) :: Latvian
Eugene Pershin (SilentEugene) :: Russian
周盛道 (zhoushengdao) :: Chinese Simplified
hamidreza amini (hamidrezaamini2022) :: Persian
Tomislav Kraljević (tomislav.kraljevic) :: Croatian
Taygun Yıldırım (yildirimtaygun) :: Turkish
robing29 :: German
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
Igor V Belousov (biv) :: Russian
David Bauer (davbauer) :: German
Guttorm Hveem (guttormhveem) :: Norwegian Bokmal
Minh Giang Truong (minhgiang1204) :: Vietnamese
Ioannis Ioannides (i.ioannides) :: Greek
Vadim (vadrozh) :: Russian
Flip333 :: German Informal; German
Paulo Henrique (paulohsantos114) :: Portuguese, Brazilian
Dženan (Dzenan) :: Swedish
Péter Péli (peter.peli) :: Hungarian

View File

@@ -27,6 +27,10 @@ class ActivityType
const BOOKSHELF_DELETE = 'bookshelf_delete';
const COMMENTED_ON = 'commented_on';
const COMMENT_CREATE = 'comment_create';
const COMMENT_UPDATE = 'comment_update';
const COMMENT_DELETE = 'comment_delete';
const PERMISSIONS_UPDATE = 'permissions_update';
const REVISION_RESTORE = 'revision_restore';

View File

@@ -33,6 +33,7 @@ class CommentRepo
$comment->parent_id = $parent_id;
$entity->comments()->save($comment);
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
return $comment;
@@ -48,6 +49,8 @@ class CommentRepo
$comment->html = $this->commentToHtml($text);
$comment->save();
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
return $comment;
}
@@ -57,6 +60,8 @@ class CommentRepo
public function delete(Comment $comment): void
{
$comment->delete();
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
}
/**

View File

@@ -0,0 +1,65 @@
<?php
namespace BookStack\Activity\Controllers;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\App\Model;
use BookStack\Entities\Models\Entity;
use BookStack\Http\Controller;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class WatchController extends Controller
{
public function update(Request $request)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();
$requestData = $this->validate($request, [
'level' => ['required', 'string'],
]);
$watchable = $this->getValidatedModelFromRequest($request);
$watchOptions = new UserEntityWatchOptions(user(), $watchable);
$watchOptions->updateLevelByName($requestData['level']);
$this->showSuccessNotification(trans('activities.watch_update_level_notification'));
return redirect()->back();
}
/**
* @throws ValidationException
* @throws Exception
*/
protected function getValidatedModelFromRequest(Request $request): Entity
{
$modelInfo = $this->validate($request, [
'type' => ['required', 'string'],
'id' => ['required', 'integer'],
]);
if (!class_exists($modelInfo['type'])) {
throw new Exception('Model not found');
}
/** @var Model $model */
$model = new $modelInfo['type']();
if (!$model instanceof Entity) {
throw new Exception('Model not an entity');
}
$modelInstance = $model->newQuery()
->where('id', '=', $modelInfo['id'])
->first(['id', 'name', 'owned_by']);
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
if (is_null($modelInstance) || $inaccessibleEntity) {
throw new Exception('Model instance not found');
}
return $modelInstance;
}
}

View File

@@ -8,6 +8,7 @@ use BookStack\Activity\Tools\WebhookFormatter;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use BookStack\Util\SsrUrlValidator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -24,27 +25,23 @@ class DispatchWebhookJob implements ShouldQueue
use SerializesModels;
protected Webhook $webhook;
protected string $event;
protected User $initiator;
protected int $initiatedTime;
/**
* @var string|Loggable
*/
protected $detail;
protected array $webhookData;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Webhook $webhook, string $event, $detail)
public function __construct(Webhook $webhook, string $event, Loggable|string $detail)
{
$this->webhook = $webhook;
$this->event = $event;
$this->detail = $detail;
$this->initiator = user();
$this->initiatedTime = time();
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $event, $this->webhook, $detail, $this->initiator, $this->initiatedTime);
$this->webhookData = $themeResponse ?? WebhookFormatter::getDefault($event, $this->webhook, $detail, $this->initiator, $this->initiatedTime)->format();
}
/**
@@ -54,15 +51,15 @@ class DispatchWebhookJob implements ShouldQueue
*/
public function handle()
{
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime);
$webhookData = $themeResponse ?? WebhookFormatter::getDefault($this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime)->format();
$lastError = null;
try {
(new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);
$response = Http::asJson()
->withOptions(['allow_redirects' => ['strict' => true]])
->timeout($this->webhook->timeout)
->post($this->webhook->endpoint, $webhookData);
->post($this->webhook->endpoint, $this->webhookData);
} catch (\Exception $exception) {
$lastError = $exception->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");

View File

@@ -5,6 +5,7 @@ namespace BookStack\Activity\Models;
use BookStack\App\Model;
use BookStack\Users\Models\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
@@ -13,8 +14,10 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
* @property string $html
* @property int|null $parent_id
* @property int $local_id
* @property string $entity_type
* @property int $entity_id
*/
class Comment extends Model
class Comment extends Model implements Loggable
{
use HasFactory;
use HasCreatorAndUpdater;
@@ -30,6 +33,14 @@ class Comment extends Model
return $this->morphTo('entity');
}
/**
* Get the parent comment this is in reply to (if existing).
*/
public function parent(): BelongsTo
{
return $this->belongsTo(Comment::class);
}
/**
* Check if a comment has been updated since creation.
*/
@@ -40,21 +51,22 @@ class Comment extends Model
/**
* Get created date as a relative diff.
*
* @return mixed
*/
public function getCreatedAttribute()
public function getCreatedAttribute(): string
{
return $this->created_at->diffForHumans();
}
/**
* Get updated date as a relative diff.
*
* @return mixed
*/
public function getUpdatedAttribute()
public function getUpdatedAttribute(): string
{
return $this->updated_at->diffForHumans();
}
public function logDescriptor(): string
{
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace BookStack\Activity\Models;
use BookStack\Activity\WatchLevels;
use BookStack\Permissions\Models\JointPermission;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property int $user_id
* @property int $watchable_id
* @property string $watchable_type
* @property int $level
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Watch extends Model
{
protected $guarded = [];
public function watchable(): MorphTo
{
return $this->morphTo();
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'watchable_id')
->whereColumn('watches.watchable_type', '=', 'joint_permissions.entity_type');
}
public function getLevelName(): string
{
return WatchLevels::levelValueToName($this->level);
}
public function ignoring(): bool
{
return $this->level === WatchLevels::IGNORE;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
use BookStack\Entities\Models\Entity;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User;
abstract class BaseNotificationHandler implements NotificationHandler
{
/**
* @param class-string<BaseActivityNotification> $notification
* @param int[] $userIds
*/
protected function sendNotificationToUserIds(string $notification, array $userIds, User $initiator, string|Loggable $detail, Entity $relatedModel): void
{
$users = User::query()->whereIn('id', array_unique($userIds))->get();
foreach ($users as $user) {
// Prevent sending to the user that initiated the activity
if ($user->id === $initiator->id) {
continue;
}
// Prevent sending of the user does not have notification permissions
if (!$user->can('receive-notifications')) {
continue;
}
// Prevent sending if the user does not have access to the related content
$permissions = new PermissionApplicator($user);
if (!$permissions->checkOwnableUserAccess($relatedModel, 'view')) {
continue;
}
// Send the notification
$user->notify(new $notification($detail, $initiator));
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\CommentCreationNotification;
use BookStack\Activity\Tools\EntityWatchers;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;
class CommentCreationNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Comment)) {
throw new \InvalidArgumentException("Detail for comment creation notifications must be a comment");
}
// Main watchers
/** @var Page $page */
$page = $detail->entity;
$watchers = new EntityWatchers($page, WatchLevels::COMMENTS);
$watcherIds = $watchers->getWatcherUserIds();
// Page owner if user preferences allow
if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageComments()) {
$watcherIds[] = $page->owned_by;
}
}
// Parent comment creator if preferences allow
$parentComment = $detail->parent()->first();
if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
$watcherIds[] = $parentComment->created_by;
}
}
$this->sendNotificationToUserIds(CommentCreationNotification::class, $watcherIds, $user, $detail, $page);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;
interface NotificationHandler
{
/**
* Run this handler.
* Provides the activity, related activity detail/model
* along with the user that triggered the activity.
*/
public function handle(Activity $activity, string|Loggable $detail, User $user): void;
}

View File

@@ -0,0 +1,24 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\PageCreationNotification;
use BookStack\Activity\Tools\EntityWatchers;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
class PageCreationNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Page)) {
throw new \InvalidArgumentException("Detail for page create notifications must be a page");
}
$watchers = new EntityWatchers($detail, WatchLevels::NEW);
$this->sendNotificationToUserIds(PageCreationNotification::class, $watchers->getWatcherUserIds(), $user, $detail, $detail);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\PageUpdateNotification;
use BookStack\Activity\Tools\EntityWatchers;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;
class PageUpdateNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Page)) {
throw new \InvalidArgumentException("Detail for page update notifications must be a page");
}
// Get last update from activity
$lastUpdate = $detail->activity()
->where('type', '=', ActivityType::PAGE_UPDATE)
->where('id', '!=', $activity->id)
->latest('created_at')
->first();
// Return if the same user has already updated the page in the last 15 mins
if ($lastUpdate && $lastUpdate->user_id === $user->id) {
if ($lastUpdate->created_at->gt(now()->subMinutes(15))) {
return;
}
}
// Get active watchers
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
$watcherIds = $watchers->getWatcherUserIds();
// Add page owner if preferences allow
if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
$watcherIds[] = $detail->owned_by;
}
}
$this->sendNotificationToUserIds(PageUpdateNotification::class, $watcherIds, $user, $detail, $detail);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use Illuminate\Contracts\Support\Htmlable;
/**
* A line of text with linked text included, intended for use
* in MailMessages. The line should have a ':link' placeholder for
* where the link should be inserted within the line.
*/
class LinkedMailMessageLine implements Htmlable
{
public function __construct(
protected string $url,
protected string $line,
protected string $linkText,
) {
}
public function toHtml(): string
{
$link = '<a href="' . e($this->url) . '">' . e($this->linkText) . '</a>';
return str_replace(':link', $link, e($this->line));
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use Illuminate\Contracts\Support\Htmlable;
/**
* A bullet point list of content, where the keys of the given list array
* are bolded header elements, and the values follow.
*/
class ListMessageLine implements Htmlable
{
public function __construct(
protected array $list
) {
}
public function toHtml(): string
{
$list = [];
foreach ($this->list as $header => $content) {
$list[] = '<strong>' . e($header) . '</strong> ' . e($content);
}
return implode("<br>\n", $list);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
abstract class BaseActivityNotification extends Notification
{
use Queueable;
public function __construct(
protected Loggable|string $detail,
protected User $user,
) {
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
abstract public function toMail(mixed $notifiable): MailMessage;
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
'activity_detail' => $this->detail,
'activity_creator' => $this->user,
];
}
/**
* Build the common reason footer line used in mail messages.
*/
protected function buildReasonFooterLine(): LinkedMailMessageLine
{
return new LinkedMailMessageLine(
url('/preferences/notifications'),
trans('notifications.footer_reason'),
trans('notifications.footer_reason_link'),
);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use Illuminate\Notifications\Messages\MailMessage;
class CommentCreationNotification extends BaseActivityNotification
{
public function toMail(mixed $notifiable): MailMessage
{
/** @var Comment $comment */
$comment = $this->detail;
/** @var Page $page */
$page = $comment->entity;
return (new MailMessage())
->subject(trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()]))
->line(trans('notifications.new_comment_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine([
trans('notifications.detail_page_name') => $page->name,
trans('notifications.detail_commenter') => $this->user->name,
trans('notifications.detail_comment') => strip_tags($comment->html),
]))
->action(trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
->line($this->buildReasonFooterLine());
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use Illuminate\Notifications\Messages\MailMessage;
class PageCreationNotification extends BaseActivityNotification
{
public function toMail(mixed $notifiable): MailMessage
{
/** @var Page $page */
$page = $this->detail;
return (new MailMessage())
->subject(trans('notifications.new_page_subject', ['pageName' => $page->getShortName()]))
->line(trans('notifications.new_page_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine([
trans('notifications.detail_page_name') => $page->name,
trans('notifications.detail_created_by') => $this->user->name,
]))
->action(trans('notifications.action_view_page'), $page->getUrl())
->line($this->buildReasonFooterLine());
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use Illuminate\Notifications\Messages\MailMessage;
class PageUpdateNotification extends BaseActivityNotification
{
public function toMail(mixed $notifiable): MailMessage
{
/** @var Page $page */
$page = $this->detail;
return (new MailMessage())
->subject(trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()]))
->line(trans('notifications.updated_page_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine([
trans('notifications.detail_page_name') => $page->name,
trans('notifications.detail_updated_by') => $this->user->name,
]))
->line(trans('notifications.updated_page_debounce'))
->action(trans('notifications.action_view_page'), $page->getUrl())
->line($this->buildReasonFooterLine());
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace BookStack\Activity\Notifications;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
use BookStack\Users\Models\User;
class NotificationManager
{
/**
* @var class-string<NotificationHandler>[]
*/
protected array $handlers = [];
public function handle(Activity $activity, string|Loggable $detail, User $user): void
{
$activityType = $activity->type;
$handlersToRun = $this->handlers[$activityType] ?? [];
foreach ($handlersToRun as $handlerClass) {
/** @var NotificationHandler $handler */
$handler = new $handlerClass();
$handler->handle($activity, $detail, $user);
}
}
/**
* @param class-string<NotificationHandler> $handlerClass
*/
public function registerHandler(string $activityType, string $handlerClass): void
{
if (!isset($this->handlers[$activityType])) {
$this->handlers[$activityType] = [];
}
if (!in_array($handlerClass, $this->handlers[$activityType])) {
$this->handlers[$activityType][] = $handlerClass;
}
}
public function loadDefaultHandlers(): void
{
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
}
}

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\DispatchWebhookJob;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Notifications\NotificationManager;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
@@ -14,12 +15,16 @@ use Illuminate\Support\Facades\Log;
class ActivityLogger
{
public function __construct(
protected NotificationManager $notifications
) {
$this->notifications->loadDefaultHandlers();
}
/**
* Add a generic activity event to the database.
*
* @param string|Loggable $detail
*/
public function add(string $type, $detail = '')
public function add(string $type, string|Loggable $detail = ''): void
{
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
@@ -35,6 +40,7 @@ class ActivityLogger
$this->setNotification($type);
$this->dispatchWebhooks($type, $detail);
$this->notifications->handle($activity, $detail, user());
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
}
@@ -55,7 +61,7 @@ class ActivityLogger
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
*/
public function removeEntity(Entity $entity)
public function removeEntity(Entity $entity): void
{
$entity->activity()->update([
'detail' => $entity->name,
@@ -76,10 +82,7 @@ class ActivityLogger
}
}
/**
* @param string|Loggable $detail
*/
protected function dispatchWebhooks(string $type, $detail): void
protected function dispatchWebhooks(string $type, string|Loggable $detail): void
{
$webhooks = Webhook::query()
->whereHas('trackedEvents', function (Builder $query) use ($type) {
@@ -98,7 +101,7 @@ class ActivityLogger
* 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)
public function logFailedLogin(string $username): void
{
$message = config('logging.failed_login.message');
if (!$message) {

View File

@@ -0,0 +1,86 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Watch;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder;
class EntityWatchers
{
/**
* @var int[]
*/
protected array $watchers = [];
/**
* @var int[]
*/
protected array $ignorers = [];
public function __construct(
protected Entity $entity,
protected int $watchLevel,
) {
$this->build();
}
public function getWatcherUserIds(): array
{
return $this->watchers;
}
public function isUserIgnoring(int $userId): bool
{
return in_array($userId, $this->ignorers);
}
protected function build(): void
{
$watches = $this->getRelevantWatches();
// Sort before de-duping, so that the order looped below follows book -> chapter -> page ordering
usort($watches, function (Watch $watchA, Watch $watchB) {
$entityTypeDiff = $watchA->watchable_type <=> $watchB->watchable_type;
return $entityTypeDiff === 0 ? ($watchA->user_id <=> $watchB->user_id) : $entityTypeDiff;
});
// De-dupe by user id to get their most relevant level
$levelByUserId = [];
foreach ($watches as $watch) {
$levelByUserId[$watch->user_id] = $watch->level;
}
// Populate the class arrays
$this->watchers = array_keys(array_filter($levelByUserId, fn(int $level) => $level >= $this->watchLevel));
$this->ignorers = array_keys(array_filter($levelByUserId, fn(int $level) => $level === 0));
}
/**
* @return Watch[]
*/
protected function getRelevantWatches(): array
{
/** @var Entity[] $entitiesInvolved */
$entitiesInvolved = array_filter([
$this->entity,
$this->entity instanceof BookChild ? $this->entity->book : null,
$this->entity instanceof Page ? $this->entity->chapter : null,
]);
$query = Watch::query()->where(function (Builder $query) use ($entitiesInvolved) {
foreach ($entitiesInvolved as $entity) {
$query->orWhere(function (Builder $query) use ($entity) {
$query->where('watchable_type', '=', $entity->getMorphClass())
->where('watchable_id', '=', $entity->id);
});
}
});
return $query->get([
'level', 'watchable_id', 'watchable_type', 'user_id'
])->all();
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Watch;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
class UserEntityWatchOptions
{
protected ?array $watchMap = null;
public function __construct(
protected User $user,
protected Entity $entity,
) {
}
public function canWatch(): bool
{
return $this->user->can('receive-notifications') && !$this->user->isDefault();
}
public function getWatchLevel(): string
{
return WatchLevels::levelValueToName($this->getWatchLevelValue());
}
public function isWatching(): bool
{
return $this->getWatchLevelValue() !== WatchLevels::DEFAULT;
}
public function getWatchedParent(): ?WatchedParentDetails
{
$watchMap = $this->getWatchMap();
unset($watchMap[$this->entity->getMorphClass()]);
if (isset($watchMap['chapter'])) {
return new WatchedParentDetails('chapter', $watchMap['chapter']);
}
if (isset($watchMap['book'])) {
return new WatchedParentDetails('book', $watchMap['book']);
}
return null;
}
public function updateLevelByName(string $level): void
{
$levelValue = WatchLevels::levelNameToValue($level);
$this->updateLevelByValue($levelValue);
}
public function updateLevelByValue(int $level): void
{
if ($level < 0) {
$this->remove();
return;
}
$this->updateLevel($level);
}
public function getWatchMap(): array
{
if (!is_null($this->watchMap)) {
return $this->watchMap;
}
$entities = [$this->entity];
if ($this->entity instanceof BookChild) {
$entities[] = $this->entity->book;
}
if ($this->entity instanceof Page && $this->entity->chapter) {
$entities[] = $this->entity->chapter;
}
$query = Watch::query()
->where('user_id', '=', $this->user->id)
->where(function (Builder $subQuery) use ($entities) {
foreach ($entities as $entity) {
$subQuery->orWhere(function (Builder $whereQuery) use ($entity) {
$whereQuery->where('watchable_type', '=', $entity->getMorphClass())
->where('watchable_id', '=', $entity->id);
});
}
});
$this->watchMap = $query->get(['watchable_type', 'level'])
->pluck('level', 'watchable_type')
->toArray();
return $this->watchMap;
}
protected function getWatchLevelValue()
{
return $this->getWatchMap()[$this->entity->getMorphClass()] ?? WatchLevels::DEFAULT;
}
protected function updateLevel(int $levelValue): void
{
Watch::query()->updateOrCreate([
'watchable_id' => $this->entity->id,
'watchable_type' => $this->entity->getMorphClass(),
'user_id' => $this->user->id,
], [
'level' => $levelValue,
]);
$this->watchMap = null;
}
protected function remove(): void
{
$this->entityQuery()->delete();
$this->watchMap = null;
}
protected function entityQuery(): Builder
{
return Watch::query()->where('watchable_id', '=', $this->entity->id)
->where('watchable_type', '=', $this->entity->getMorphClass())
->where('user_id', '=', $this->user->id);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\WatchLevels;
class WatchedParentDetails
{
public function __construct(
public string $type,
public int $level,
) {
}
public function ignoring(): bool
{
return $this->level === WatchLevels::IGNORE;
}
}

View File

@@ -17,18 +17,14 @@ class WebhookFormatter
protected string $event;
protected User $initiator;
protected int $initiatedTime;
/**
* @var string|Loggable
*/
protected $detail;
protected string|Loggable $detail;
/**
* @var array{condition: callable(string, Model):bool, format: callable(Model):void}[]
*/
protected $modelFormatters = [];
public function __construct(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime)
public function __construct(string $event, Webhook $webhook, string|Loggable $detail, User $initiator, int $initiatedTime)
{
$this->webhook = $webhook;
$this->event = $event;

View File

@@ -0,0 +1,91 @@
<?php
namespace BookStack\Activity;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
class WatchLevels
{
/**
* Default level, No specific option set
* Typically not a stored status
*/
const DEFAULT = -1;
/**
* Ignore all notifications.
*/
const IGNORE = 0;
/**
* Watch for new content.
*/
const NEW = 1;
/**
* Watch for updates and new content
*/
const UPDATES = 2;
/**
* Watch for comments, updates and new content.
*/
const COMMENTS = 3;
/**
* Get all the possible values as an option_name => value array.
* @returns array<string, int>
*/
public static function all(): array
{
$options = [];
foreach ((new \ReflectionClass(static::class))->getConstants() as $name => $value) {
$options[strtolower($name)] = $value;
}
return $options;
}
/**
* Get the watch options suited for the given entity.
* @returns array<string, int>
*/
public static function allSuitedFor(Entity $entity): array
{
$options = static::all();
if ($entity instanceof Page) {
unset($options['new']);
} elseif ($entity instanceof Bookshelf) {
return [];
}
return $options;
}
/**
* Convert the given name to a level value.
* Defaults to default value if the level does not exist.
*/
public static function levelNameToValue(string $level): int
{
return static::all()[$level] ?? static::DEFAULT;
}
/**
* Convert the given int level value to a level name.
* Defaults to 'default' level name if not existing.
*/
public static function levelValueToName(int $level): string
{
foreach (static::all() as $name => $value) {
if ($level === $value) {
return $name;
}
}
return 'default';
}
}

View File

@@ -7,6 +7,7 @@ use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
@@ -27,13 +28,16 @@ class ApiDocsGenerator
{
$appVersion = trim(file_get_contents(base_path('version')));
$cacheKey = 'api-docs::' . $appVersion;
if (Cache::has($cacheKey) && config('app.env') === 'production') {
$docs = Cache::get($cacheKey);
} else {
$docs = (new ApiDocsGenerator())->generate();
Cache::put($cacheKey, $docs, 60 * 24);
$isProduction = config('app.env') === 'production';
$cacheVal = $isProduction ? Cache::get($cacheKey) : null;
if (!is_null($cacheVal)) {
return $cacheVal;
}
$docs = (new ApiDocsGenerator())->generate();
Cache::put($cacheKey, $docs, 60 * 24);
return $docs;
}

View File

@@ -10,7 +10,7 @@ class ApiEntityListFormatter
* The list to be formatted.
* @var Entity[]
*/
protected $list = [];
protected array $list = [];
/**
* The fields to show in the formatted data.
@@ -19,9 +19,9 @@ class ApiEntityListFormatter
* will be used for the resultant value. A null return value will omit the property.
* @var array<string|int, string|callable>
*/
protected $fields = [
'id', 'name', 'slug', 'book_id', 'chapter_id',
'draft', 'template', 'created_at', 'updated_at',
protected array $fields = [
'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft',
'template', 'priority', 'created_at', 'updated_at',
];
public function __construct(array $list)

View File

@@ -4,6 +4,7 @@ namespace BookStack\Api;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
@@ -20,6 +21,8 @@ use Illuminate\Support\Carbon;
*/
class ApiToken extends Model implements Loggable
{
use HasFactory;
protected $fillable = ['name', 'expires_at'];
protected $casts = [
'expires_at' => 'date:Y-m-d',

View File

@@ -9,6 +9,7 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\BookStackExceptionHandlerPage;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService;
use BookStack\Util\CspService;
use GuzzleHttp\Client;
@@ -79,5 +80,9 @@ class AppServiceProvider extends ServiceProvider
'timeout' => 3,
]);
});
$this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null);
});
}
}

View File

@@ -66,6 +66,15 @@ return [
// Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),
// A list of the sources/hostnames that can be reached by application SSR calls.
// This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
// Host-specific functionality (usually controlled via other options) like auth
// or user avatars for example, won't use this list.
// Space seperated if multiple. Can use '*' as a wildcard.
// Values will be compared prefix-matched, case-insensitive, against called SSR urls.
// Defaults to allow all hosts.
'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'),
// Alter the precision of IP addresses stored by BookStack.
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),

View File

@@ -31,7 +31,7 @@ return [
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl') ? 'smtps' : null,
'scheme' => null,
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
'port' => env('MAIL_PORT', 587),
'username' => env('MAIL_USERNAME'),
@@ -39,6 +39,7 @@ return [
'verify_peer' => env('MAIL_VERIFY_SSL', true),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN'),
'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'),
],
'sendmail' => [

View File

@@ -5,6 +5,7 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityQueries;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
@@ -138,6 +139,7 @@ class BookController extends Controller
'current' => $book,
'bookChildren' => $bookChildren,
'bookParentShelves' => $bookParentShelves,
'watchOptions' => new UserEntityWatchOptions(user(), $book),
'activity' => $activities->entityActivity($book, 20, 1),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book),
]);

View File

@@ -19,12 +19,14 @@ class ChapterApiController extends ApiController
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'priority' => ['integer'],
],
'update' => [
'book_id' => ['integer'],
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'priority' => ['integer'],
],
];

View File

@@ -3,6 +3,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Tools\BookContents;
@@ -81,6 +82,7 @@ class ChapterController extends Controller
'chapter' => $chapter,
'current' => $chapter,
'sidebarTree' => $sidebarTree,
'watchOptions' => new UserEntityWatchOptions(user(), $chapter),
'pages' => $pages,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),

View File

@@ -21,6 +21,7 @@ class PageApiController extends ApiController
'html' => ['required_without:markdown', 'string'],
'markdown' => ['required_without:html', 'string'],
'tags' => ['array'],
'priority' => ['integer'],
],
'update' => [
'book_id' => ['integer'],
@@ -29,6 +30,7 @@ class PageApiController extends ApiController
'html' => ['string'],
'markdown' => ['string'],
'tags' => ['array'],
'priority' => ['integer'],
],
];

View File

@@ -4,6 +4,7 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\BookContents;
@@ -151,6 +152,7 @@ class PageController extends Controller
'sidebarTree' => $sidebarTree,
'commentTree' => $commentTree,
'pageNav' => $pageNav,
'watchOptions' => new UserEntityWatchOptions(user(), $page),
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($page),

View File

@@ -10,7 +10,7 @@ class TopFavourites extends EntityQuery
public function run(int $count, int $skip = 0)
{
$user = user();
if (is_null($user) || $user->isDefault()) {
if ($user->isDefault()) {
return collect();
}

View File

@@ -16,14 +16,9 @@ use Exception;
class ChapterRepo
{
protected $baseRepo;
/**
* ChapterRepo constructor.
*/
public function __construct(BaseRepo $baseRepo)
{
$this->baseRepo = $baseRepo;
public function __construct(
protected BaseRepo $baseRepo
) {
}
/**

View File

@@ -23,24 +23,12 @@ use Illuminate\Pagination\LengthAwarePaginator;
class PageRepo
{
protected BaseRepo $baseRepo;
protected RevisionRepo $revisionRepo;
protected ReferenceStore $referenceStore;
protected ReferenceUpdater $referenceUpdater;
/**
* PageRepo constructor.
*/
public function __construct(
BaseRepo $baseRepo,
RevisionRepo $revisionRepo,
ReferenceStore $referenceStore,
ReferenceUpdater $referenceUpdater
protected BaseRepo $baseRepo,
protected RevisionRepo $revisionRepo,
protected ReferenceStore $referenceStore,
protected ReferenceUpdater $referenceUpdater
) {
$this->baseRepo = $baseRepo;
$this->revisionRepo = $revisionRepo;
$this->referenceStore = $referenceStore;
$this->referenceUpdater = $referenceUpdater;
}
/**
@@ -159,13 +147,11 @@ class PageRepo
*/
public function publishDraft(Page $draft, array $input): Page
{
$this->updateTemplateStatusAndContentFromInput($draft, $input);
$this->baseRepo->update($draft, $input);
$draft->draft = false;
$draft->revision_count = 1;
$draft->priority = $this->getNewPriority($draft);
$draft->save();
$this->updateTemplateStatusAndContentFromInput($draft, $input);
$this->baseRepo->update($draft, $input);
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
$this->referenceStore->updateForPage($draft);

View File

@@ -66,6 +66,16 @@ abstract class Controller extends BaseController
}
}
/**
* Prevent access for guest users beyond this point.
*/
protected function preventGuestAccess(): void
{
if (!signedInUser()) {
$this->showPermissionError();
}
}
/**
* Check the current user's permissions against an ownable item otherwise throw an exception.
*/

View File

@@ -8,7 +8,6 @@ use BookStack\Entities\Models\Page;
use BookStack\Permissions\Models\EntityPermission;
use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Users\Models\HasOwner;
use BookStack\Users\Models\Role;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
@@ -16,6 +15,11 @@ use InvalidArgumentException;
class PermissionApplicator
{
public function __construct(
protected ?User $user = null
) {
}
/**
* Checks if an entity has a restriction set upon it.
*
@@ -173,7 +177,7 @@ class PermissionApplicator
*/
protected function currentUser(): User
{
return user();
return $this->user ?? user();
}
/**

View File

@@ -12,12 +12,11 @@ use Illuminate\Database\Eloquent\Collection;
class PermissionsRepo
{
protected JointPermissionBuilder $permissionBuilder;
protected array $systemRoles = ['admin', 'public'];
public function __construct(JointPermissionBuilder $permissionBuilder)
{
$this->permissionBuilder = $permissionBuilder;
public function __construct(
protected JointPermissionBuilder $permissionBuilder
) {
}
/**

View File

@@ -20,10 +20,11 @@ class StatusController extends Controller
return DB::table('migrations')->count() > 0;
}),
'cache' => $this->trueWithoutError(function () {
$rand = Str::random();
Cache::add('status_test', $rand);
$rand = Str::random(12);
$key = "status_test_{$rand}";
Cache::add($key, $rand);
return Cache::pull('status_test') === $rand;
return Cache::pull($key) === $rand;
}),
'session' => $this->trueWithoutError(function () {
$rand = Str::random();

View File

@@ -0,0 +1,46 @@
<?php
namespace BookStack\Settings;
use BookStack\Users\Models\User;
class UserNotificationPreferences
{
public function __construct(
protected User $user
) {
}
public function notifyOnOwnPageChanges(): bool
{
return $this->getNotificationSetting('own-page-changes');
}
public function notifyOnOwnPageComments(): bool
{
return $this->getNotificationSetting('own-page-comments');
}
public function notifyOnCommentReplies(): bool
{
return $this->getNotificationSetting('comment-replies');
}
public function updateFromSettingsArray(array $settings)
{
$allowList = ['own-page-changes', 'own-page-comments', 'comment-replies'];
foreach ($settings as $setting => $status) {
if (!in_array($setting, $allowList)) {
continue;
}
$value = $status === 'true' ? 'true' : 'false';
setting()->putUser($this->user, 'notifications#' . $setting, $value);
}
}
protected function getNotificationSetting(string $key): bool
{
return setting()->getUser($this->user, 'notifications#' . $key);
}
}

View File

@@ -132,11 +132,12 @@ class ThemeEvents
* If the listener returns a non-null value, that will be used as the POST data instead
* of the system default.
*
* @param string $event
* @param \BookStack\Activity\Models\Webhook $webhook
* @param string $event
* @param \BookStack\Activity\Models\Webhook $webhook
* @param string|\BookStack\Activity\Models\Loggable $detail
* @param \BookStack\Users\Models\User $initiator
* @param int $initiatedTime
* @param \BookStack\Users\Models\User $initiator
* @param int $initiatedTime
* @returns array|null
*/
const WEBHOOK_CALL_BEFORE = 'webhook_call_before';
}

View File

@@ -177,6 +177,7 @@ class ImageRepo
$image->refresh();
$image->updated_by = user()->id;
$image->touch();
$image->save();
$this->imageService->replaceExistingFromUpload($image->path, $image->type, $file);
$this->loadThumbs($image, true);

View File

@@ -13,11 +13,9 @@ use Illuminate\Http\Request;
class RoleController extends Controller
{
protected PermissionsRepo $permissionsRepo;
public function __construct(PermissionsRepo $permissionsRepo)
{
$this->permissionsRepo = $permissionsRepo;
public function __construct(
protected PermissionsRepo $permissionsRepo
) {
}
/**

View File

@@ -103,6 +103,7 @@ class UserController extends Controller
*/
public function edit(int $id, SocialAuthService $socialAuthService)
{
$this->preventGuestAccess();
$this->checkPermissionOrCurrentUser('users-manage', $id);
$user = $this->userRepo->getById($id);
@@ -133,6 +134,7 @@ class UserController extends Controller
public function update(Request $request, int $id)
{
$this->preventAccessInDemoMode();
$this->preventGuestAccess();
$this->checkPermissionOrCurrentUser('users-manage', $id);
$validated = $this->validate($request, [
@@ -176,6 +178,7 @@ class UserController extends Controller
*/
public function delete(int $id)
{
$this->preventGuestAccess();
$this->checkPermissionOrCurrentUser('users-manage', $id);
$user = $this->userRepo->getById($id);
@@ -192,6 +195,7 @@ class UserController extends Controller
public function destroy(Request $request, int $id)
{
$this->preventAccessInDemoMode();
$this->preventGuestAccess();
$this->checkPermissionOrCurrentUser('users-manage', $id);
$user = $this->userRepo->getById($id);

View File

@@ -2,18 +2,27 @@
namespace BookStack\Users\Controllers;
use BookStack\Activity\Models\Watch;
use BookStack\Http\Controller;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Settings\UserShortcutMap;
use BookStack\Users\UserRepo;
use Illuminate\Http\Request;
class UserPreferencesController extends Controller
{
protected UserRepo $userRepo;
public function __construct(
protected UserRepo $userRepo
) {
}
public function __construct(UserRepo $userRepo)
/**
* Show the overview for user preferences.
*/
public function index()
{
$this->userRepo = $userRepo;
return view('users.preferences.index');
}
/**
@@ -24,6 +33,8 @@ class UserPreferencesController extends Controller
$shortcuts = UserShortcutMap::fromUserPreferences();
$enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
$this->setPageTitle(trans('preferences.shortcuts_interface'));
return view('users.preferences.shortcuts', [
'shortcuts' => $shortcuts,
'enabled' => $enabled,
@@ -47,6 +58,46 @@ class UserPreferencesController extends Controller
return redirect('/preferences/shortcuts');
}
/**
* Show the notification preferences for the current user.
*/
public function showNotifications(PermissionApplicator $permissions)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();
$preferences = (new UserNotificationPreferences(user()));
$query = Watch::query()->where('user_id', '=', user()->id);
$query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
$watches = $query->with('watchable')->paginate(20);
$this->setPageTitle(trans('preferences.notifications'));
return view('users.preferences.notifications', [
'preferences' => $preferences,
'watches' => $watches,
]);
}
/**
* Update the notification preferences for the current user.
*/
public function updateNotifications(Request $request)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();
$data = $this->validate($request, [
'preferences' => ['required', 'array'],
'preferences.*' => ['required', 'string'],
]);
$preferences = (new UserNotificationPreferences(user()));
$preferences->updateFromSettingsArray($data['preferences']);
$this->showSuccessNotification(trans('preferences.notifications_update_success'));
return redirect('/preferences/notifications');
}
/**
* Update the preferred view format for a list view of the given type.
*/
@@ -123,7 +174,7 @@ class UserPreferencesController extends Controller
{
$validated = $this->validate($request, [
'language' => ['required', 'string', 'max:20'],
'active' => ['required', 'bool'],
'active' => ['required', 'bool'],
]);
$currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', '');

View File

@@ -88,8 +88,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* This holds the default user when loaded.
*
* @var null|User
*/
protected static ?User $defaultUser = null;
@@ -107,6 +105,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return static::$defaultUser;
}
public static function clearDefault(): void
{
static::$defaultUser = null;
}
/**
* Check if the user is the default public user.
*/

View File

@@ -15,7 +15,7 @@ class RolesAllPaginatedAndSorted
{
$sort = $listOptions->getSort();
if ($sort === 'created_at') {
$sort = 'users.created_at';
$sort = 'roles.created_at';
}
$query = Role::query()->select(['*'])

View File

@@ -0,0 +1,64 @@
<?php
namespace BookStack\Util;
use BookStack\Exceptions\HttpFetchException;
class SsrUrlValidator
{
protected string $config;
public function __construct(string $config = null)
{
$this->config = $config ?? config('app.ssr_hosts') ?? '';
}
/**
* @throws HttpFetchException
*/
public function ensureAllowed(string $url): void
{
if (!$this->allowed($url)) {
throw new HttpFetchException(trans('errors.http_ssr_url_no_match'));
}
}
/**
* Check if the given URL is allowed by the configured SSR host values.
*/
public function allowed(string $url): bool
{
$allowed = $this->getHostPatterns();
foreach ($allowed as $pattern) {
if ($this->urlMatchesPattern($url, $pattern)) {
return true;
}
}
return false;
}
protected function urlMatchesPattern($url, $pattern): bool
{
$pattern = rtrim(trim($pattern), '/');
$url = trim($url);
if (empty($pattern) || empty($url)) {
return false;
}
$quoted = preg_quote($pattern, '/');
$regexPattern = str_replace('\*', '.*', $quoted);
return preg_match('/^' . $regexPattern . '($|\/.*$|#.*$)/i', $url);
}
/**
* @return string[]
*/
protected function getHostPatterns(): array
{
return explode(' ', strtolower($this->config));
}
}

View File

@@ -49,7 +49,8 @@
"nunomaduro/larastan": "^2.4",
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "^3.7",
"ssddanbrown/asserthtml": "^2.0"
"ssddanbrown/asserthtml": "^2.0",
"ssddanbrown/symfony-mailer": "6.0.x-dev"
},
"autoload": {
"psr-4": {

600
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
<?php
namespace Database\Factories\Api;
use BookStack\Api\ApiToken;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class ApiTokenFactory extends Factory
{
protected $model = ApiToken::class;
public function definition(): array
{
return [
'token_id' => Str::random(10),
'secret' => Str::random(12),
'name' => $this->faker->name(),
'expires_at' => Carbon::now()->addYear(),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
'user_id' => User::factory(),
];
}
}

View File

@@ -12,9 +12,10 @@ return new class extends Migration
*/
public function up()
{
DB::table('entity_permissions')
->where('entity_type', '=', 'bookshelf')
->update(['create' => 0]);
// Note: v23.06.2
// Migration removed since change to remove bookshelf create permissions was reverted.
// Create permissions were removed as incorrectly thought to be unused, but they did
// have a use via shelf permission copy-down to books.
}
/**

View File

@@ -0,0 +1,51 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// Create new receive-notifications permission and assign to admin role
$permissionId = DB::table('role_permissions')->insertGetId([
'name' => 'receive-notifications',
'display_name' => 'Receive & Manage Notifications',
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
$adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
'permission_id' => $permissionId,
]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
$permission = DB::table('role_permissions')
->where('name', '=', 'receive-notifications')
->first();
if ($permission) {
DB::table('permission_role')->where([
'permission_id' => $permission->id,
])->delete();
}
DB::table('role_permissions')
->where('name', '=', 'receive-notifications')
->delete();
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('watches', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->index();
$table->integer('watchable_id');
$table->string('watchable_type', 100);
$table->tinyInteger('level', false, true)->index();
$table->timestamps();
$table->index(['watchable_id', 'watchable_type'], 'watchable_index');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('watches');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('cache', function (Blueprint $table) {
$table->mediumText('value')->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('cache', function (Blueprint $table) {
$table->text('value')->change();
});
}
};

View File

@@ -27,6 +27,8 @@ class DummyContentSeeder extends Seeder
// Create an editor user
$editorUser = User::factory()->create();
$editorRole = Role::getRole('editor');
$additionalEditorPerms = ['receive-notifications', 'comment-create-all'];
$editorRole->permissions()->syncWithoutDetaching(RolePermission::whereIn('name', $additionalEditorPerms)->pluck('id'));
$editorUser->attachRole($editorRole);
// Create a viewer user

View File

@@ -2,8 +2,9 @@
"book_id": 1,
"name": "My fantastic new chapter",
"description": "This is a great new chapter that I've created via the API",
"priority": 15,
"tags": [
{"name": "Category", "value": "Top Content"},
{"name": "Rating", "value": "Highest"}
]
}
}

View File

@@ -2,8 +2,9 @@
"book_id": 1,
"name": "My fantastic updated chapter",
"description": "This is an updated chapter that I've altered via the API",
"priority": 16,
"tags": [
{"name": "Category", "value": "Kinda Good Content"},
{"name": "Rating", "value": "Medium"}
]
}
}

View File

@@ -2,8 +2,9 @@
"book_id": 1,
"name": "My API Page",
"html": "<p>my new API page</p>",
"priority": 15,
"tags": [
{"name": "Category", "value": "Not Bad Content"},
{"name": "Rating", "value": "Average"}
]
}
}

View File

@@ -2,8 +2,9 @@
"chapter_id": 1,
"name": "My updated API Page",
"html": "<p>my new API page - Updated</p>",
"priority": 16,
"tags": [
{"name": "Category", "value": "API Examples"},
{"name": "Rating", "value": "Alright"}
]
}
}

View File

@@ -1,11 +1,11 @@
{
"id": 15,
"name": "My new book",
"slug": "my-new-book",
"description": "This is a book created via the API",
"created_by": 1,
"updated_by": 1,
"owned_by": 1,
"slug": "my-new-book",
"updated_at": "2020-01-12T14:05:11.000000Z",
"created_at": "2020-01-12T14:05:11.000000Z",
"id": 15
"created_at": "2020-01-12T14:05:11.000000Z"
}

View File

@@ -7,15 +7,18 @@
"updated_at": "2020-01-12T14:11:51.000000Z",
"created_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"updated_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"owned_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"contents": [
{
@@ -52,12 +55,12 @@
"template": false,
"created_at": "2021-12-19T18:22:11.000000Z",
"updated_at": "2022-07-29T13:44:15.000000Z",
"url": "https://example.com/books/my-own-book/page/cool-animals"
"url": "https://example.com/books/my-own-book/page/cool-animals",
"type": "page"
}
],
"tags": [
{
"id": 13,
"name": "Category",
"value": "Guide",
"order": 0

View File

@@ -1,39 +1,25 @@
{
"id": 74,
"book_id": 1,
"priority": 6,
"slug": "my-fantastic-new-chapter",
"name": "My fantastic new chapter",
"description": "This is a great new chapter that I've created via the API",
"priority": 15,
"created_by": 1,
"updated_by": 1,
"owned_by": 1,
"slug": "my-fantastic-new-chapter",
"updated_at": "2020-05-22T22:59:55.000000Z",
"created_at": "2020-05-22T22:59:55.000000Z",
"id": 74,
"book": {
"id": 1,
"name": "BookStack User Guide",
"slug": "bookstack-user-guide",
"description": "This is a general guide on using BookStack on a day-to-day basis.",
"created_at": "2019-05-05T21:48:46.000000Z",
"updated_at": "2019-12-11T20:57:31.000000Z",
"created_by": 1,
"updated_by": 1
},
"tags": [
{
"name": "Category",
"value": "Top Content",
"order": 0,
"created_at": "2020-05-22T22:59:55.000000Z",
"updated_at": "2020-05-22T22:59:55.000000Z"
"order": 0
},
{
"name": "Rating",
"value": "Highest",
"order": 0,
"created_at": "2020-05-22T22:59:55.000000Z",
"updated_at": "2020-05-22T22:59:55.000000Z"
"order": 1
}
]
}

View File

@@ -11,7 +11,8 @@
"updated_at": "2019-09-28T11:24:23.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1
"owned_by": 1,
"book_slug": "example-book"
},
{
"id": 2,
@@ -24,7 +25,8 @@
"updated_at": "2019-10-17T15:05:34.000000Z",
"created_by": 3,
"updated_by": 3,
"owned_by": 3
"owned_by": 3,
"book_slug": "example-book"
}
],
"total": 40

View File

@@ -9,16 +9,20 @@
"updated_at": "2019-09-28T11:24:23.000000Z",
"created_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"updated_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"owned_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"book_slug": "example-book",
"tags": [
{
"name": "Category",
@@ -38,9 +42,12 @@
"updated_at": "2019-08-26T14:32:59.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1,
"draft": false,
"revision_count": 2,
"template": false
"template": false,
"editor": "wysiwyg",
"book_slug": "example-book"
},
{
"id": 7,
@@ -53,9 +60,12 @@
"updated_at": "2019-06-06T12:03:04.000000Z",
"created_by": 3,
"updated_by": 3,
"owned_by": 1,
"draft": false,
"revision_count": 1,
"template": false
"template": false,
"editor": "wysiwyg",
"book_slug": "example-book"
}
]
}

View File

@@ -4,36 +4,23 @@
"slug": "my-fantastic-updated-chapter",
"name": "My fantastic updated chapter",
"description": "This is an updated chapter that I've altered via the API",
"priority": 7,
"priority": 16,
"created_at": "2020-05-22T23:03:35.000000Z",
"updated_at": "2020-05-22T23:07:20.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1,
"book": {
"id": 1,
"name": "BookStack User Guide",
"slug": "bookstack-user-guide",
"description": "This is a general guide on using BookStack on a day-to-day basis.",
"created_at": "2019-05-05T21:48:46.000000Z",
"updated_at": "2019-12-11T20:57:31.000000Z",
"created_by": 1,
"updated_by": 1
},
"book_slug": "bookstack-demo-site",
"tags": [
{
"name": "Category",
"value": "Kinda Good Content",
"order": 0,
"created_at": "2020-05-22T23:07:20.000000Z",
"updated_at": "2020-05-22T23:07:20.000000Z"
"order": 0
},
{
"name": "Rating",
"value": "Medium",
"order": 0,
"created_at": "2020-05-22T23:07:20.000000Z",
"updated_at": "2020-05-22T23:07:20.000000Z"
"order": 1
}
]
}

View File

@@ -6,25 +6,29 @@
"slug": "my-api-page",
"html": "<p id=\"bkmrk-my-new-api-page\">my new API page</p>",
"raw_html": "<p id=\"bkmrk-my-new-api-page\">my new API page</p>",
"priority": 14,
"priority": 15,
"created_at": "2020-11-28T15:01:39.000000Z",
"updated_at": "2020-11-28T15:01:39.000000Z",
"created_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"updated_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"owned_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"draft": false,
"markdown": "",
"revision_count": 1,
"template": false,
"editor": "wysiwyg",
"tags": [
{
"name": "Category",

View File

@@ -8,12 +8,15 @@
"slug": "how-to-create-page-content",
"priority": 0,
"draft": false,
"revision_count": 3,
"template": false,
"created_at": "2019-05-05T21:49:58.000000Z",
"updated_at": "2020-07-04T15:50:58.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1
"owned_by": 1,
"editor": "wysiwyg",
"book_slug": "example-book"
},
{
"id": 2,
@@ -23,12 +26,15 @@
"slug": "how-to-use-images",
"priority": 2,
"draft": false,
"revision_count": 3,
"template": false,
"created_at": "2019-05-05T21:53:30.000000Z",
"updated_at": "2019-06-06T12:03:04.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1
"owned_by": 1,
"editor": "wysiwyg",
"book_slug": "example-book"
},
{
"id": 3,
@@ -38,12 +44,15 @@
"slug": "drawings-via-drawio",
"priority": 3,
"draft": false,
"revision_count": 3,
"template": false,
"created_at": "2019-05-05T21:53:49.000000Z",
"updated_at": "2019-12-18T21:56:52.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1
"owned_by": 1,
"editor": "wysiwyg",
"book_slug": "example-book"
}
],
"total": 322

View File

@@ -11,20 +11,24 @@
"updated_at": "2020-11-28T14:43:20.000000Z",
"created_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"updated_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"owned_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"draft": false,
"markdown": "# How this is built\r\n\r\nThis page is written in markdown. BookStack stores the page data in HTML.\r\n\r\nHere's a cute picture of my cat:\r\n\r\n[![yXSrubes.jpg](http://example.com/uploads/images/gallery/2020-04/scaled-1680-/yXSrubes.jpg)](http://example.com/uploads/images/gallery/2020-04/yXSrubes.jpg)",
"revision_count": 5,
"template": false,
"editor": "wysiwyg",
"tags": [
{
"name": "Category",

View File

@@ -11,20 +11,24 @@
"updated_at": "2020-11-28T15:13:03.000000Z",
"created_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"updated_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"owned_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"draft": false,
"markdown": "",
"revision_count": 5,
"template": false,
"editor": "wysiwyg",
"tags": [
{
"name": "Category",

View File

@@ -1,11 +1,11 @@
{
"id": 14,
"name": "My shelf",
"slug": "my-shelf",
"description": "This is my shelf with some books",
"created_by": 1,
"updated_by": 1,
"owned_by": 1,
"slug": "my-shelf",
"updated_at": "2020-04-10T13:24:09.000000Z",
"created_at": "2020-04-10T13:24:09.000000Z",
"id": 14
"updated_at": "2020-04-10T13:24:09.000000Z"
}

View File

@@ -5,21 +5,23 @@
"description": "This is my shelf with some books",
"created_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"updated_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"owned_by": {
"id": 1,
"name": "Admin"
"name": "Admin",
"slug": "admin"
},
"created_at": "2020-04-10T13:24:09.000000Z",
"updated_at": "2020-04-10T13:31:04.000000Z",
"tags": [
{
"id": 16,
"name": "Category",
"value": "Guide",
"order": 0
@@ -41,17 +43,35 @@
{
"id": 5,
"name": "Sint explicabo alias sunt.",
"slug": "jbsQrzuaXe"
"slug": "jbsQrzuaXe",
"description": "Hic forum est.",
"created_at": "2020-04-10T13:31:04.000000Z",
"updated_at": "2020-04-10T13:31:04.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1
},
{
"id": 1,
"name": "BookStack User Guide",
"slug": "bookstack-user-guide"
"slug": "bookstack-user-guide",
"description": "The Bookstack User Guide Book.",
"created_at": "2020-04-10T15:30:32.000000Z",
"updated_at": "2020-04-13T09:01:04.000000Z",
"created_by": 1,
"updated_by": 2,
"owned_by": 1
},
{
"id": 3,
"name": "Molestiae doloribus sint velit suscipit dolorem.",
"slug": "H99QxALaoG"
"slug": "H99QxALaoG",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"created_at": "2020-04-10T13:31:04.000000Z",
"updated_at": "2020-04-10T13:31:04.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1
}
]
}

View File

@@ -3,7 +3,7 @@
All development on BookStack is currently done on the `development` branch.
When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
* [Node.js](https://nodejs.org/en/) v16.0+
* [Node.js](https://nodejs.org/en/) v18.0+
## Building CSS & JavaScript Assets

View File

@@ -58,6 +58,9 @@ return [
'favourite_add_notification' => 'تم إضافة ":name" إلى المفضلة لديك',
'favourite_remove_notification' => 'تم إزالة ":name" من المفضلة لديك',
// Watching
'watch_update_level_notification' => 'Watch preferences successfully updated',
// Auth
'auth_login' => 'logged in',
'auth_register' => 'registered as new user',
@@ -110,7 +113,12 @@ return [
'recycle_bin_restore' => 'restored from recycle bin',
'recycle_bin_destroy' => 'removed from recycle bin',
// Other
// Comments
'commented_on' => 'تم التعليق',
'comment_create' => 'added comment',
'comment_update' => 'updated comment',
'comment_delete' => 'deleted comment',
// Other
'permissions_update' => 'تحديث الأذونات',
];

View File

@@ -42,6 +42,7 @@ return [
'remove' => 'إزالة',
'add' => 'إضافة',
'configure' => 'Configure',
'manage' => 'Manage',
'fullscreen' => 'شاشة كاملة',
'favourite' => 'Favourite',
'unfavourite' => 'Unfavourite',

View File

@@ -106,6 +106,7 @@ return [
'shelves_permissions_updated' => 'Shelf Permissions Updated',
'shelves_permissions_active' => 'Shelf Permissions Active',
'shelves_permissions_cascade_warning' => 'Permissions on shelves 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_create' => 'Shelf create permissions are only used for copying permissions to child books using the action below. They do not control the ability to create books.',
'shelves_copy_permissions_to_books' => 'نسخ أذونات الوصول إلى الكتب',
'shelves_copy_permissions' => 'نسخ الأذونات',
'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.',
@@ -238,6 +239,8 @@ return [
'pages_md_insert_drawing' => 'إدخال رسمة',
'pages_md_show_preview' => 'Show preview',
'pages_md_sync_scroll' => 'Sync preview scroll',
'pages_drawing_unsaved' => 'Unsaved Drawing Found',
'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
'pages_not_in_chapter' => 'صفحة ليست في فصل',
'pages_move' => 'نقل الصفحة',
'pages_copy' => 'نسخ الصفحة',
@@ -402,4 +405,28 @@ return [
'references' => 'References',
'references_none' => 'There are no tracked references to this item.',
'references_to_desc' => 'Shown below are all the known pages in the system that link to this item.',
// Watch Options
'watch' => 'Watch',
'watch_title_default' => 'Default Preferences',
'watch_desc_default' => 'Revert watching to just your default notification preferences.',
'watch_title_ignore' => 'Ignore',
'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.',
'watch_title_new' => 'New Pages',
'watch_desc_new' => 'Notify when any new page is created within this item.',
'watch_title_updates' => 'All Page Updates',
'watch_desc_updates' => 'Notify upon all new pages and page changes.',
'watch_desc_updates_page' => 'Notify upon all page changes.',
'watch_title_comments' => 'All Page Updates & Comments',
'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.',
'watch_desc_comments_page' => 'Notify upon page changes and new comments.',
'watch_change_default' => 'Change default notification preferences',
'watch_detail_ignore' => 'Ignoring notifications',
'watch_detail_new' => 'Watching for new pages',
'watch_detail_updates' => 'Watching new pages and updates',
'watch_detail_comments' => 'Watching new pages, updates & comments',
'watch_detail_parent_book' => 'Watching via parent book',
'watch_detail_parent_book_ignore' => 'Ignoring via parent book',
'watch_detail_parent_chapter' => 'Watching via parent chapter',
'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter',
];

View File

@@ -111,4 +111,6 @@ return [
// Settings & Maintenance
'maintenance_test_email_failure' => 'حدث خطأ عند إرسال بريد إلكتروني تجريبي:',
// HTTP errors
'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts',
];

26
lang/ar/notifications.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
/**
* Text used for activity-based notifications.
*/
return [
'new_comment_subject' => 'New comment on page: :pageName',
'new_comment_intro' => 'A user has commented on a page in :appName:',
'new_page_subject' => 'New page: :pageName',
'new_page_intro' => 'A new page has been created in :appName:',
'updated_page_subject' => 'Updated page: :pageName',
'updated_page_intro' => 'A page has been updated in :appName:',
'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.',
'detail_page_name' => 'Page Name:',
'detail_commenter' => 'Commenter:',
'detail_comment' => 'Comment:',
'detail_created_by' => 'Created By:',
'detail_updated_by' => 'Updated By:',
'action_view_comment' => 'View Comment',
'action_view_page' => 'View Page',
'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.',
'footer_reason_link' => 'your notification preferences',
];

View File

@@ -5,6 +5,8 @@
*/
return [
'preferences' => 'Preferences',
'shortcuts' => 'Shortcuts',
'shortcuts_interface' => 'Interface Keyboard Shortcuts',
'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.',
@@ -15,4 +17,17 @@ return [
'shortcuts_save' => 'Save Shortcuts',
'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.',
'shortcuts_update_success' => 'Shortcut preferences have been updated!',
];
'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.',
'notifications' => 'Notification Preferences',
'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.',
'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own',
'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own',
'notifications_opt_comment_replies' => 'Notify upon replies to my comments',
'notifications_save' => 'Save Preferences',
'notifications_update_success' => 'Notification preferences have been updated!',
'notifications_watched' => 'Watched & Ignored Items',
'notifications_watched_desc' => ' Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.',
'profile_overview_desc' => ' Manage your user profile details including preferred language and authentication options.',
];

View File

@@ -163,6 +163,7 @@ return [
'role_manage_settings' => 'إدارة إعدادات التطبيق',
'role_export_content' => 'Export content',
'role_editor_change' => 'Change page editor',
'role_notifications' => 'Receive & manage notifications',
'role_asset' => 'أذونات الأصول',
'roles_system_warning' => 'اعلم أن الوصول إلى أي من الأذونات الثلاثة المذكورة أعلاه يمكن أن يسمح للمستخدم بتغيير امتيازاته الخاصة أو امتيازات الآخرين في النظام. قم بتعيين الأدوار مع هذه الأذونات فقط للمستخدمين الموثوق بهم.',
'role_asset_desc' => 'تتحكم هذه الأذونات في الوصول الافتراضي إلى الأصول داخل النظام. ستتجاوز الأذونات الخاصة بالكتب والفصول والصفحات هذه الأذونات.',

View File

@@ -58,6 +58,9 @@ return [
'favourite_add_notification' => '":name" е добавен към любими успешно',
'favourite_remove_notification' => '":name" е премахнат от любими успешно',
// Watching
'watch_update_level_notification' => 'Watch preferences successfully updated',
// Auth
'auth_login' => 'logged in',
'auth_register' => 'registered as new user',
@@ -110,7 +113,12 @@ return [
'recycle_bin_restore' => 'restored from recycle bin',
'recycle_bin_destroy' => 'removed from recycle bin',
// Other
// Comments
'commented_on' => 'коментирано на',
'comment_create' => 'added comment',
'comment_update' => 'updated comment',
'comment_delete' => 'deleted comment',
// Other
'permissions_update' => 'обновени права',
];

View File

@@ -42,6 +42,7 @@ return [
'remove' => 'Премахване',
'add' => 'Добавяне',
'configure' => 'Конфигуриране',
'manage' => 'Manage',
'fullscreen' => 'Цял екран',
'favourite' => 'Любимо',
'unfavourite' => 'Не е любимо',

View File

@@ -106,6 +106,7 @@ return [
'shelves_permissions_updated' => 'Shelf Permissions Updated',
'shelves_permissions_active' => 'Shelf Permissions Active',
'shelves_permissions_cascade_warning' => 'Permissions on shelves 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_create' => 'Shelf create permissions are only used for copying permissions to child books using the action below. They do not control the ability to create books.',
'shelves_copy_permissions_to_books' => 'Копирай настойките за достъп към книгите',
'shelves_copy_permissions' => 'Копирай настройките за достъп',
'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.',
@@ -238,6 +239,8 @@ return [
'pages_md_insert_drawing' => 'Вмъкни рисунка',
'pages_md_show_preview' => 'Show preview',
'pages_md_sync_scroll' => 'Sync preview scroll',
'pages_drawing_unsaved' => 'Unsaved Drawing Found',
'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
'pages_not_in_chapter' => 'Страницата не принадлежи в никоя глава',
'pages_move' => 'Премести страницата',
'pages_copy' => 'Копиране на страницата',
@@ -402,4 +405,28 @@ return [
'references' => 'References',
'references_none' => 'There are no tracked references to this item.',
'references_to_desc' => 'Shown below are all the known pages in the system that link to this item.',
// Watch Options
'watch' => 'Watch',
'watch_title_default' => 'Default Preferences',
'watch_desc_default' => 'Revert watching to just your default notification preferences.',
'watch_title_ignore' => 'Ignore',
'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.',
'watch_title_new' => 'New Pages',
'watch_desc_new' => 'Notify when any new page is created within this item.',
'watch_title_updates' => 'All Page Updates',
'watch_desc_updates' => 'Notify upon all new pages and page changes.',
'watch_desc_updates_page' => 'Notify upon all page changes.',
'watch_title_comments' => 'All Page Updates & Comments',
'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.',
'watch_desc_comments_page' => 'Notify upon page changes and new comments.',
'watch_change_default' => 'Change default notification preferences',
'watch_detail_ignore' => 'Ignoring notifications',
'watch_detail_new' => 'Watching for new pages',
'watch_detail_updates' => 'Watching new pages and updates',
'watch_detail_comments' => 'Watching new pages, updates & comments',
'watch_detail_parent_book' => 'Watching via parent book',
'watch_detail_parent_book_ignore' => 'Ignoring via parent book',
'watch_detail_parent_chapter' => 'Watching via parent chapter',
'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter',
];

View File

@@ -111,4 +111,6 @@ return [
// Settings & Maintenance
'maintenance_test_email_failure' => 'Беше върната грешка, когато се изпрати тестовият емейл:',
// HTTP errors
'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts',
];

26
lang/bg/notifications.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
/**
* Text used for activity-based notifications.
*/
return [
'new_comment_subject' => 'New comment on page: :pageName',
'new_comment_intro' => 'A user has commented on a page in :appName:',
'new_page_subject' => 'New page: :pageName',
'new_page_intro' => 'A new page has been created in :appName:',
'updated_page_subject' => 'Updated page: :pageName',
'updated_page_intro' => 'A page has been updated in :appName:',
'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.',
'detail_page_name' => 'Page Name:',
'detail_commenter' => 'Commenter:',
'detail_comment' => 'Comment:',
'detail_created_by' => 'Created By:',
'detail_updated_by' => 'Updated By:',
'action_view_comment' => 'View Comment',
'action_view_page' => 'View Page',
'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.',
'footer_reason_link' => 'your notification preferences',
];

View File

@@ -5,6 +5,8 @@
*/
return [
'preferences' => 'Preferences',
'shortcuts' => 'Преки пътища',
'shortcuts_interface' => 'Interface Keyboard Shortcuts',
'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.',
@@ -15,4 +17,17 @@ return [
'shortcuts_save' => 'Запазване на преките пътища',
'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.',
'shortcuts_update_success' => 'Обновени предпочитания за преки пътища!',
];
'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.',
'notifications' => 'Notification Preferences',
'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.',
'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own',
'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own',
'notifications_opt_comment_replies' => 'Notify upon replies to my comments',
'notifications_save' => 'Save Preferences',
'notifications_update_success' => 'Notification preferences have been updated!',
'notifications_watched' => 'Watched & Ignored Items',
'notifications_watched_desc' => ' Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.',
'profile_overview_desc' => ' Manage your user profile details including preferred language and authentication options.',
];

View File

@@ -163,6 +163,7 @@ return [
'role_manage_settings' => 'Управление на настройките на приложението',
'role_export_content' => 'Експортирай съдържанието',
'role_editor_change' => 'Change page editor',
'role_notifications' => 'Receive & manage notifications',
'role_asset' => 'Настройки за достъп до активи',
'roles_system_warning' => 'Важно: Добавянето на потребител в някое от горните три роли може да му позволи да промени собствените си права или правата на другите в системата. Възлагайте тези роли само на доверени потребители.',
'role_asset_desc' => 'Тези настройки за достъп контролират достъпа по подразбиране до активите в системата. Настройките за достъп до книги, глави и страници ще отменят тези настройки.',

View File

@@ -58,6 +58,9 @@ return [
'favourite_add_notification' => '":name" je dodan u tvoje favorite',
'favourite_remove_notification' => '":name" je uklonjen iz tvojih favorita',
// Watching
'watch_update_level_notification' => 'Watch preferences successfully updated',
// Auth
'auth_login' => 'logged in',
'auth_register' => 'registered as new user',
@@ -110,7 +113,12 @@ return [
'recycle_bin_restore' => 'restored from recycle bin',
'recycle_bin_destroy' => 'removed from recycle bin',
// Other
// Comments
'commented_on' => 'je komentarisao/la na',
'comment_create' => 'added comment',
'comment_update' => 'updated comment',
'comment_delete' => 'deleted comment',
// Other
'permissions_update' => 'je ažurirao/la dozvole',
];

View File

@@ -42,6 +42,7 @@ return [
'remove' => 'Ukloni',
'add' => 'Dodaj',
'configure' => 'Configure',
'manage' => 'Manage',
'fullscreen' => 'Prikaz preko čitavog ekrana',
'favourite' => 'Favorit',
'unfavourite' => 'Ukloni favorit',

View File

@@ -106,6 +106,7 @@ return [
'shelves_permissions_updated' => 'Shelf Permissions Updated',
'shelves_permissions_active' => 'Shelf Permissions Active',
'shelves_permissions_cascade_warning' => 'Permissions on shelves 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_create' => 'Shelf create permissions are only used for copying permissions to child books using the action below. They do not control the ability to create books.',
'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 shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.',
@@ -238,6 +239,8 @@ return [
'pages_md_insert_drawing' => 'Insert Drawing',
'pages_md_show_preview' => 'Show preview',
'pages_md_sync_scroll' => 'Sync preview scroll',
'pages_drawing_unsaved' => 'Unsaved Drawing Found',
'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
'pages_not_in_chapter' => 'Page is not in a chapter',
'pages_move' => 'Move Page',
'pages_copy' => 'Copy Page',
@@ -402,4 +405,28 @@ return [
'references' => 'References',
'references_none' => 'There are no tracked references to this item.',
'references_to_desc' => 'Shown below are all the known pages in the system that link to this item.',
// Watch Options
'watch' => 'Watch',
'watch_title_default' => 'Default Preferences',
'watch_desc_default' => 'Revert watching to just your default notification preferences.',
'watch_title_ignore' => 'Ignore',
'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.',
'watch_title_new' => 'New Pages',
'watch_desc_new' => 'Notify when any new page is created within this item.',
'watch_title_updates' => 'All Page Updates',
'watch_desc_updates' => 'Notify upon all new pages and page changes.',
'watch_desc_updates_page' => 'Notify upon all page changes.',
'watch_title_comments' => 'All Page Updates & Comments',
'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.',
'watch_desc_comments_page' => 'Notify upon page changes and new comments.',
'watch_change_default' => 'Change default notification preferences',
'watch_detail_ignore' => 'Ignoring notifications',
'watch_detail_new' => 'Watching for new pages',
'watch_detail_updates' => 'Watching new pages and updates',
'watch_detail_comments' => 'Watching new pages, updates & comments',
'watch_detail_parent_book' => 'Watching via parent book',
'watch_detail_parent_book_ignore' => 'Ignoring via parent book',
'watch_detail_parent_chapter' => 'Watching via parent chapter',
'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter',
];

View File

@@ -111,4 +111,6 @@ return [
// Settings & Maintenance
'maintenance_test_email_failure' => 'Došlo je do greške prilikom slanja testnog e-maila:',
// HTTP errors
'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts',
];

26
lang/bs/notifications.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
/**
* Text used for activity-based notifications.
*/
return [
'new_comment_subject' => 'New comment on page: :pageName',
'new_comment_intro' => 'A user has commented on a page in :appName:',
'new_page_subject' => 'New page: :pageName',
'new_page_intro' => 'A new page has been created in :appName:',
'updated_page_subject' => 'Updated page: :pageName',
'updated_page_intro' => 'A page has been updated in :appName:',
'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.',
'detail_page_name' => 'Page Name:',
'detail_commenter' => 'Commenter:',
'detail_comment' => 'Comment:',
'detail_created_by' => 'Created By:',
'detail_updated_by' => 'Updated By:',
'action_view_comment' => 'View Comment',
'action_view_page' => 'View Page',
'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.',
'footer_reason_link' => 'your notification preferences',
];

View File

@@ -5,6 +5,8 @@
*/
return [
'preferences' => 'Preferences',
'shortcuts' => 'Shortcuts',
'shortcuts_interface' => 'Interface Keyboard Shortcuts',
'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.',
@@ -15,4 +17,17 @@ return [
'shortcuts_save' => 'Save Shortcuts',
'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.',
'shortcuts_update_success' => 'Shortcut preferences have been updated!',
];
'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.',
'notifications' => 'Notification Preferences',
'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.',
'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own',
'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own',
'notifications_opt_comment_replies' => 'Notify upon replies to my comments',
'notifications_save' => 'Save Preferences',
'notifications_update_success' => 'Notification preferences have been updated!',
'notifications_watched' => 'Watched & Ignored Items',
'notifications_watched_desc' => ' Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.',
'profile_overview_desc' => ' Manage your user profile details including preferred language and authentication options.',
];

View File

@@ -163,6 +163,7 @@ return [
'role_manage_settings' => 'Manage app settings',
'role_export_content' => 'Export content',
'role_editor_change' => 'Change page editor',
'role_notifications' => 'Receive & manage notifications',
'role_asset' => 'Asset Permissions',
'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.',

View File

@@ -58,6 +58,9 @@ return [
'favourite_add_notification' => '":name" has been added to your favourites',
'favourite_remove_notification' => '":name" has been removed from your favourites',
// Watching
'watch_update_level_notification' => 'Watch preferences successfully updated',
// Auth
'auth_login' => 'logged in',
'auth_register' => 'registered as new user',
@@ -110,7 +113,12 @@ return [
'recycle_bin_restore' => 'restored from recycle bin',
'recycle_bin_destroy' => 'removed from recycle bin',
// Other
// Comments
'commented_on' => 'ha comentat a',
'comment_create' => 'added comment',
'comment_update' => 'updated comment',
'comment_delete' => 'deleted comment',
// Other
'permissions_update' => 'ha actualitzat els permisos',
];

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