mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-07 19:06:05 +03:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1d30341e7 | ||
|
|
80d2b4913b | ||
|
|
45b8d6cd0c | ||
|
|
99eb3e5f71 | ||
|
|
15da4b98ef | ||
|
|
21cd2d17f6 | ||
|
|
3f473528b1 | ||
|
|
d0dcd4f61b | ||
|
|
ad60517536 | ||
|
|
2c20abc872 | ||
|
|
bde66a1396 | ||
|
|
4de5a2d9bf | ||
|
|
2abbcf5c0f | ||
|
|
7a48516bf4 | ||
|
|
e31b50dabd | ||
|
|
817581aa0c | ||
|
|
1cd19c76ba | ||
|
|
5d38ae3c97 | ||
|
|
a720b3725d | ||
|
|
3847a76134 | ||
|
|
f91049a3f2 | ||
|
|
4e6b74f2a1 | ||
|
|
976f241ae0 | ||
|
|
415dab9936 | ||
|
|
54715d40ef | ||
|
|
27bf4299cf | ||
|
|
164f01bb25 | ||
|
|
c6d0e690f9 | ||
|
|
77d65d1ca1 | ||
|
|
dc77233ec3 | ||
|
|
3622c440d7 | ||
|
|
642210ab4c | ||
|
|
e176aae940 | ||
|
|
903895814a | ||
|
|
c324ad928d | ||
|
|
9100a82b47 | ||
|
|
32516f7b68 | ||
|
|
69ac425903 | ||
|
|
3917e50c90 | ||
|
|
dd71658d70 | ||
|
|
a4fbde9185 | ||
|
|
cbcec189fd | ||
|
|
0628c28f66 | ||
|
|
391478465a | ||
|
|
9ca1139ab0 | ||
|
|
7bf5425c6b | ||
|
|
e44ef57219 | ||
|
|
fef433a9cb | ||
|
|
e709caa005 | ||
|
|
38829f8a38 | ||
|
|
ee9e342b58 | ||
|
|
79470ea4b7 | ||
|
|
565908ef52 | ||
|
|
bc6e19b2a1 | ||
|
|
615741af9d | ||
|
|
371779205a | ||
|
|
d9fdecd902 | ||
|
|
c47b3f805a | ||
|
|
ecab2c8e42 | ||
|
|
18ae67a138 | ||
|
|
9779c1a357 | ||
|
|
9d149e4d36 | ||
|
|
8cdf3203ef | ||
|
|
6100b99828 | ||
|
|
730f539029 | ||
|
|
ff2674c464 | ||
|
|
100b28707c | ||
|
|
45e75edf05 | ||
|
|
1c922be4c7 | ||
|
|
0359e2490a | ||
|
|
422e50302a | ||
|
|
f563a005f5 | ||
|
|
a14d8e30cc | ||
|
|
7504ad32a7 | ||
|
|
fca18862d2 | ||
|
|
ae834050f5 | ||
|
|
a83150131a | ||
|
|
3a36d3c847 | ||
|
|
4d399f6ba7 | ||
|
|
b1b8067cbe | ||
|
|
a9194ffb63 | ||
|
|
2f9c1b7127 | ||
|
|
18979e84d6 | ||
|
|
bf5e886d76 | ||
|
|
e04a1af444 | ||
|
|
eb2c5d00cb | ||
|
|
96819b7bd9 | ||
|
|
18ee80a743 | ||
|
|
1a56de6cb4 | ||
|
|
465989efa9 | ||
|
|
d293171da2 | ||
|
|
174cd5a893 | ||
|
|
ccfe38e963 | ||
|
|
23ae332c1b | ||
|
|
3a39f13420 | ||
|
|
ca2d2c97d4 | ||
|
|
e47870794d | ||
|
|
e43d85b801 | ||
|
|
bb3ce845b4 | ||
|
|
458cea3644 |
@@ -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
|
||||
|
||||
16
.github/translators.txt
vendored
16
.github/translators.txt
vendored
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
65
app/Activity/Controllers/WatchController.php
Normal file
65
app/Activity/Controllers/WatchController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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}\"");
|
||||
|
||||
@@ -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})";
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Activity/Models/Watch.php
Normal file
45
app/Activity/Models/Watch.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
17
app/Activity/Notifications/Handlers/NotificationHandler.php
Normal file
17
app/Activity/Notifications/Handlers/NotificationHandler.php
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
36
app/Activity/Notifications/MessageParts/ListMessageLine.php
Normal file
36
app/Activity/Notifications/MessageParts/ListMessageLine.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
52
app/Activity/Notifications/NotificationManager.php
Normal file
52
app/Activity/Notifications/NotificationManager.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
86
app/Activity/Tools/EntityWatchers.php
Normal file
86
app/Activity/Tools/EntityWatchers.php
Normal 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();
|
||||
}
|
||||
}
|
||||
131
app/Activity/Tools/UserEntityWatchOptions.php
Normal file
131
app/Activity/Tools/UserEntityWatchOptions.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
app/Activity/Tools/WatchedParentDetails.php
Normal file
19
app/Activity/Tools/WatchedParentDetails.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
91
app/Activity/WatchLevels.php
Normal file
91
app/Activity/WatchLevels.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -376,6 +376,7 @@ class TrashCan
|
||||
$entity->searchTerms()->delete();
|
||||
$entity->deletions()->delete();
|
||||
$entity->favourites()->delete();
|
||||
$entity->watches()->delete();
|
||||
$entity->referencesTo()->delete();
|
||||
$entity->referencesFrom()->delete();
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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')];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')]))
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
46
app/Settings/UserNotificationPreferences.php
Normal file
46
app/Settings/UserNotificationPreferences.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', '');
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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(['*'])
|
||||
|
||||
@@ -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
|
||||
|
||||
64
app/Util/SsrUrlValidator.php
Normal file
64
app/Util/SsrUrlValidator.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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
600
composer.lock
generated
File diff suppressed because it is too large
Load Diff
27
database/factories/Api/ApiTokenFactory.php
Normal file
27
database/factories/Api/ApiTokenFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[](http://example.com/uploads/images/gallery/2020-04/yXSrubes.jpg)",
|
||||
"revision_count": 5,
|
||||
"template": false,
|
||||
"editor": "wysiwyg",
|
||||
"tags": [
|
||||
{
|
||||
"name": "Category",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' => 'تحديث الأذونات',
|
||||
];
|
||||
|
||||
@@ -42,6 +42,7 @@ return [
|
||||
'remove' => 'إزالة',
|
||||
'add' => 'إضافة',
|
||||
'configure' => 'Configure',
|
||||
'manage' => 'Manage',
|
||||
'fullscreen' => 'شاشة كاملة',
|
||||
'favourite' => 'Favourite',
|
||||
'unfavourite' => 'Unfavourite',
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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
26
lang/ar/notifications.php
Normal 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',
|
||||
];
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
@@ -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' => 'تتحكم هذه الأذونات في الوصول الافتراضي إلى الأصول داخل النظام. ستتجاوز الأذونات الخاصة بالكتب والفصول والصفحات هذه الأذونات.',
|
||||
|
||||
@@ -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' => 'обновени права',
|
||||
];
|
||||
|
||||
@@ -42,6 +42,7 @@ return [
|
||||
'remove' => 'Премахване',
|
||||
'add' => 'Добавяне',
|
||||
'configure' => 'Конфигуриране',
|
||||
'manage' => 'Manage',
|
||||
'fullscreen' => 'Цял екран',
|
||||
'favourite' => 'Любимо',
|
||||
'unfavourite' => 'Не е любимо',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user