Compare commits

...

100 Commits

Author SHA1 Message Date
Dan Brown
c1d30341e7 Updated version and assets for release v23.08.3 2023-09-15 13:49:40 +01:00
Dan Brown
80d2b4913b Merge branch 'v23-08' into release 2023-09-15 13:49:12 +01:00
Dan Brown
45b8d6cd0c Comments: Fixed wrong identification of parent comment
Would cause comment reply notifications to not be sent to expected user.
Updated test to cover problem case.

For #4548
2023-09-15 13:38:02 +01:00
Dan Brown
99eb3e5f71 Comments: Fixed JS error when lacking commenting permissions
The page comments component would throw an error due to references to
form elements/content, when form elements may not exist due to
permisisons.

For #4531
2023-09-11 18:40:40 +01:00
Dan Brown
15da4b98ef Updated translations with latest Crowdin changes (#4512)
Last translation merge for possible continued v23.08 branch
2023-09-07 15:57:59 +01:00
Dan Brown
21cd2d17f6 Updated sponsors and dev version 2023-09-07 14:43:29 +01:00
Dan Brown
3f473528b1 Updated version and assets for release v23.08.2 2023-09-04 12:06:50 +01:00
Dan Brown
d0dcd4f61b Merge branch 'development' into release 2023-09-04 12:06:15 +01:00
Dan Brown
ad60517536 Updated translations with latest Crowdin changes (#4506) 2023-09-04 11:48:25 +01:00
Dan Brown
2c20abc872 WYSIWYG: Fixed filtering issue causing broken page edits
Could error upon div elements without classes, including drawings.

Related to #4510 and #4509
2023-09-04 11:25:05 +01:00
Dan Brown
bde66a1396 Updated version and assets for release v23.08.1 2023-09-03 17:40:19 +01:00
Dan Brown
4de5a2d9bf Merge branch 'development' into release 2023-09-03 17:39:56 +01:00
Dan Brown
2abbcf5c0f Updated translator attribution before release v23.08.1 2023-09-03 17:35:57 +01:00
Dan Brown
7a48516bf4 Updated translations with latest Crowdin changes (#4481) 2023-09-03 17:23:40 +01:00
Dan Brown
e31b50dabd Preferences: Fixed section screen flexibility
Improved wrapping and flex control to prevent button text force wrapping
to newlines.

For #4502
2023-09-03 16:58:29 +01:00
Dan Brown
817581aa0c Watching: Prevent issues when watchable or user is deleted
- Adds filtering to the watched items list in notification preferences
  so that deleted (recycle bin) items are removed via query.
- Adds relations and logic to properly remove watches upon user and
  entity delete events, to old watches in database do not linger.
- Adds testing to cover the above.

Did not add migration for existing data, since patch will be close to
introduction, and lingering DB entries don't open a security concern,
just some potential confusion in specific potential scenarios.
Probably not work extra migration risk, although could add in future if
concerns/issues are found.

Related to #4499
2023-09-03 14:19:43 +01:00
Dan Brown
1cd19c76ba Merge pull request #4497 from BookStackApp/notification_language
Notifications: User language for notification text
2023-09-02 15:47:26 +01:00
Dan Brown
5d38ae3c97 Merge pull request #4484 from omahs/patch-1
Fix typos
2023-09-02 15:44:01 +01:00
Dan Brown
a720b3725d Testing: Added entity decode flag and phpunit env option
- Passed decode flags to provide consistent behaviour across PHP
  versions during testing.
- Added env option to prevent local option taking action in PHPunit
  tests.
2023-09-02 15:39:45 +01:00
Dan Brown
3847a76134 Notifications: Aligned how user language is used
- This ensures content notifications are not translated to receiver
  language.
- This adds actual plaintext support for content notifications (Was
  previously just HTML as text view).
- Shares same base class across all mail notifications.
- Also cleaned up existing notification classes.

Future cleanup requested via #4501
2023-09-02 15:11:42 +01:00
Dan Brown
f91049a3f2 Notifications: Add test to check notification language 2023-09-01 16:30:37 +01:00
Dan Brown
4e6b74f2a1 WYSIWYG: Added filtering of page pointer elements
For #4474
2023-09-01 13:50:55 +01:00
omahs
976f241ae0 fix typo 2023-08-31 10:01:56 +02:00
omahs
415dab9936 fix typos 2023-08-31 10:00:45 +02:00
omahs
54715d40ef fix typo 2023-08-31 09:58:59 +02:00
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
476 changed files with 8501 additions and 2578 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,19 @@ 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
TWME :: Chinese Traditional
Sascha (Man-in-Black) :: German

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,16 +5,21 @@ 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;
/**
* @property int $id
* @property string $text
* @property string $html
* @property int|null $parent_id
* @property int|null $parent_id - Relates to local_id, not id
* @property int $local_id
* @property string $entity_type
* @property int $entity_id
* @property int $created_by
* @property int $updated_by
*/
class Comment extends Model
class Comment extends Model implements Loggable
{
use HasFactory;
use HasCreatorAndUpdater;
@@ -30,6 +35,16 @@ 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, 'parent_id', 'local_id', 'parent')
->where('entity_type', '=', $this->entity_type)
->where('entity_id', '=', $this->entity_id);
}
/**
* Check if a comment has been updated since creation.
*/
@@ -40,21 +55,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,33 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use Illuminate\Contracts\Support\Htmlable;
use Stringable;
/**
* 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, Stringable
{
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));
}
public function __toString(): string
{
$link = "{$this->linkText} ({$this->url})";
return str_replace(':link', $link, $this->line);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use Illuminate\Contracts\Support\Htmlable;
use Stringable;
/**
* 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, Stringable
{
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);
}
public function __toString(): string
{
$list = [];
foreach ($this->list as $header => $content) {
$list[] = $header . ' ' . $content;
}
return implode("\n", $list);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
use BookStack\Notifications\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable;
abstract class BaseActivityNotification extends MailNotification
{
use Queueable;
public function __construct(
protected Loggable|string $detail,
protected User $user,
) {
}
/**
* 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(string $language): LinkedMailMessageLine
{
return new LinkedMailMessageLine(
url('/preferences/notifications'),
trans('notifications.footer_reason', [], $language),
trans('notifications.footer_reason_link', [], $language),
);
}
}

View File

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

View File

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

View File

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

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

@@ -37,7 +37,7 @@ class EntityProvider
* Fetch all core entity types as an associated array
* with their basic names as the keys.
*
* @return array<Entity>
* @return array<string, Entity>
*/
public function all(): array
{

View File

@@ -10,6 +10,7 @@ use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Tag;
use BookStack\Activity\Models\View;
use BookStack\Activity\Models\Viewable;
use BookStack\Activity\Models\Watch;
use BookStack\App\Model;
use BookStack\App\Sluggable;
use BookStack\Entities\Tools\SlugGenerator;
@@ -330,6 +331,14 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
->exists();
}
/**
* Get the related watches for this entity.
*/
public function watches(): MorphMany
{
return $this->morphMany(Watch::class, 'watchable');
}
/**
* {@inheritdoc}
*/

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

@@ -376,6 +376,7 @@ class TrashCan
$entity->searchTerms()->delete();
$entity->deletions()->delete();
$entity->favourites()->delete();
$entity->watches()->delete();
$entity->referencesTo()->delete();
$entity->referencesFrom()->delete();

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

@@ -2,28 +2,17 @@
namespace BookStack\Notifications;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class ConfirmEmail extends MailNotification
{
public $token;
/**
* Create a new notification instance.
*
* @param string $token
*/
public function __construct($token)
{
$this->token = $token;
public function __construct(
public string $token
) {
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
*
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
public function toMail(User $notifiable): MailMessage
{
$appName = ['appName' => setting('app-name')];

View File

@@ -2,15 +2,21 @@
namespace BookStack\Notifications;
use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class MailNotification extends Notification implements ShouldQueue
abstract class MailNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Get the mail representation of the notification.
*/
abstract public function toMail(User $notifiable): MailMessage;
/**
* Get the notification's channels.
*
@@ -25,14 +31,14 @@ class MailNotification extends Notification implements ShouldQueue
/**
* Create a new mail message.
*
* @return MailMessage
*/
protected function newMailMessage()
protected function newMailMessage(string $language = ''): MailMessage
{
$data = ['language' => $language ?: null];
return (new MailMessage())->view([
'html' => 'vendor.notifications.email',
'text' => 'vendor.notifications.email-plain',
]);
], $data);
}
}

View File

@@ -2,31 +2,17 @@
namespace BookStack\Notifications;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class ResetPassword extends MailNotification
{
/**
* The password reset token.
*
* @var string
*/
public $token;
/**
* Create a notification instance.
*
* @param string $token
*/
public function __construct($token)
{
$this->token = $token;
public function __construct(
public string $token
) {
}
/**
* Build the mail representation of the notification.
*
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail()
public function toMail(User $notifiable): MailMessage
{
return $this->newMailMessage()
->subject(trans('auth.email_reset_subject', ['appName' => setting('app-name')]))

View File

@@ -2,16 +2,12 @@
namespace BookStack\Notifications;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class TestEmail extends MailNotification
{
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
*
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
public function toMail(User $notifiable): MailMessage
{
return $this->newMailMessage()
->subject(trans('settings.maint_send_test_email_mail_subject'))

View File

@@ -7,25 +7,17 @@ use Illuminate\Notifications\Messages\MailMessage;
class UserInvite extends MailNotification
{
public $token;
/**
* Create a new notification instance.
*/
public function __construct(string $token)
{
$this->token = $token;
public function __construct(
public string $token
) {
}
/**
* Get the mail representation of the notification.
*/
public function toMail(User $notifiable): MailMessage
{
$appName = ['appName' => setting('app-name')];
$language = setting()->getUser($notifiable, 'language');
$language = $notifiable->getLanguage();
return $this->newMailMessage()
return $this->newMailMessage($language)
->subject(trans('auth.user_invite_email_subject', $appName, $language))
->greeting(trans('auth.user_invite_email_greeting', $appName, $language))
->line(trans('auth.user_invite_email_text', [], $language))

View File

@@ -3,19 +3,25 @@
namespace BookStack\Permissions;
use BookStack\App\Model;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
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;
use Illuminate\Database\Query\JoinClause;
use InvalidArgumentException;
class PermissionApplicator
{
public function __construct(
protected ?User $user = null
) {
}
/**
* Checks if an entity has a restriction set upon it.
*
@@ -143,6 +149,42 @@ class PermissionApplicator
});
}
/**
* Filter out items that have related entity relations where
* the entity is marked as deleted.
*/
public function filterDeletedFromEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$entityProvider = new EntityProvider();
$joinQuery = function ($query) use ($entityProvider) {
$first = true;
/** @var Builder $query */
foreach ($entityProvider->all() as $entity) {
$entityQuery = function ($query) use ($entity) {
/** @var Builder $query */
$query->select(['id', 'deleted_at'])
->selectRaw("'{$entity->getMorphClass()}' as type")
->from($entity->getTable())
->whereNotNull('deleted_at');
};
if ($first) {
$entityQuery($query);
$first = false;
} else {
$query->union($entityQuery);
}
}
};
return $query->leftJoinSub($joinQuery, 'deletions', function (JoinClause $join) use ($tableDetails) {
$join->on($tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'], '=', 'deletions.id')
->on($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', 'deletions.type');
})->whereNull('deletions.deleted_at');
}
/**
* Add conditions to a query for a model that's a relation of a page, so only the model results
* on visible pages are returned by the query.
@@ -173,7 +215,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

@@ -2,6 +2,7 @@
namespace BookStack\Translation;
use BookStack\Users\Models\User;
use Illuminate\Http\Request;
class LanguageManager
@@ -80,6 +81,15 @@ class LanguageManager
return setting()->getUser($user, 'language', $default);
}
/**
* Get the language for the given user.
*/
public function getLanguageForUser(User $user): string
{
$default = config('app.locale');
return setting()->getUser($user, 'language', $default);
}
/**
* Check if the given BookStack language value is a right-to-left language.
*/

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

@@ -3,17 +3,25 @@
namespace BookStack\Users\Controllers;
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 +32,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 +57,47 @@ 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 = user()->watches()->getQuery();
$query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
$query = $permissions->filterDeletedFromEntityRelationQuery($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

@@ -6,11 +6,13 @@ use BookStack\Access\Mfa\MfaValue;
use BookStack\Access\SocialAccount;
use BookStack\Activity\Models\Favourite;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Watch;
use BookStack\Api\ApiToken;
use BookStack\App\Model;
use BookStack\App\Sluggable;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Notifications\ResetPassword;
use BookStack\Translation\LanguageManager;
use BookStack\Uploads\Image;
use Carbon\Carbon;
use Exception;
@@ -88,8 +90,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 +107,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.
*/
@@ -287,6 +292,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $this->hasMany(MfaValue::class);
}
/**
* Get the tracked entity watches for this user.
*/
public function watches(): HasMany
{
return $this->hasMany(Watch::class);
}
/**
* Get the last activity time for this user.
*/
@@ -335,6 +348,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return '';
}
/**
* Get the system language for this user.
*/
public function getLanguage(): string
{
return app()->make(LanguageManager::class)->getLanguageForUser($this);
}
/**
* Send the password reset notification.
*

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

@@ -18,18 +18,13 @@ use Illuminate\Support\Str;
class UserRepo
{
protected UserAvatars $userAvatar;
protected UserInviteService $inviteService;
/**
* UserRepo constructor.
*/
public function __construct(UserAvatars $userAvatar, UserInviteService $inviteService)
{
$this->userAvatar = $userAvatar;
$this->inviteService = $inviteService;
public function __construct(
protected UserAvatars $userAvatar,
protected UserInviteService $inviteService
) {
}
/**
* Get a user by their email address.
*/
@@ -155,6 +150,7 @@ class UserRepo
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->watches()->delete();
$user->delete();
// Delete user profile images

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

@@ -85,18 +85,18 @@ Will result with `this.$opts` being:
A component has the below shown properties & methods available for use. As mentioned above, most of these should be used within the `setup()` function to make the requirements/dependencies of the component clear.
```javascript
// The root element that the compontent has been applied to.
// The root element that the component has been applied to.
this.$el
// A map of defined element references within the compontent.
// A map of defined element references within the component.
// See "Element References" above.
this.$refs
// A map of defined multi-element references within the compontent.
// A map of defined multi-element references within the component.
// See "Element References" above.
this.$manyRefs
// Options defined for the compontent.
// Options defined for the component.
this.$opts
// The registered name of the component, usually kebab-case.
@@ -160,4 +160,4 @@ window.$components.firstOnElement(element, name);
There are a range of available events that are emitted as part of a public & supported API for accessing or extending JavaScript libraries & components used in the system.
Details on these events can be found in the [JavaScript Public Events file](javascript-public-events.md).
Details on these events can be found in the [JavaScript Public Events file](javascript-public-events.md).

View File

@@ -42,7 +42,7 @@ This event is called before the markdown input editor CodeMirror instance is cre
#### Event Data
- `editorViewConfig` - An [EditorViewConfig](https://codemirror.net/docs/ref/#view.EditorViewConfig) object that will eventially be passed when creating the CodeMirror EditorView instance.
- `editorViewConfig` - An [EditorViewConfig](https://codemirror.net/docs/ref/#view.EditorViewConfig) object that will eventually be passed when creating the CodeMirror EditorView instance.
##### Example
@@ -252,4 +252,4 @@ window.addEventListener('library-cm6::configure-theme', event => {
detail.registerHighlightStyle(highlightStyleBuilder);
});
```
</details>
</details>

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

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