mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-07 11:19:38 +03:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
14
.github/translators.txt
vendored
14
.github/translators.txt
vendored
@@ -341,3 +341,17 @@ Ingus Rūķis (ingus.rukis) :: Latvian
|
||||
Eugene Pershin (SilentEugene) :: Russian
|
||||
周盛道 (zhoushengdao) :: Chinese Simplified
|
||||
hamidreza amini (hamidrezaamini2022) :: Persian
|
||||
Tomislav Kraljević (tomislav.kraljevic) :: Croatian
|
||||
Taygun Yıldırım (yildirimtaygun) :: Turkish
|
||||
robing29 :: German
|
||||
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
|
||||
Igor V Belousov (biv) :: Russian
|
||||
David Bauer (davbauer) :: German
|
||||
Guttorm Hveem (guttormhveem) :: Norwegian Bokmal
|
||||
Minh Giang Truong (minhgiang1204) :: Vietnamese
|
||||
Ioannis Ioannides (i.ioannides) :: Greek
|
||||
Vadim (vadrozh) :: Russian
|
||||
Flip333 :: German Informal; German
|
||||
Paulo Henrique (paulohsantos114) :: Portuguese, Brazilian
|
||||
Dženan (Dzenan) :: Swedish
|
||||
Péter Péli (peter.peli) :: Hungarian
|
||||
|
||||
@@ -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,6 +5,7 @@ namespace BookStack\Activity\Models;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
@@ -13,8 +14,10 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
* @property string $html
|
||||
* @property int|null $parent_id
|
||||
* @property int $local_id
|
||||
* @property string $entity_type
|
||||
* @property int $entity_id
|
||||
*/
|
||||
class Comment extends Model
|
||||
class Comment extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
use HasCreatorAndUpdater;
|
||||
@@ -30,6 +33,14 @@ class Comment extends Model
|
||||
return $this->morphTo('entity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent comment this is in reply to (if existing).
|
||||
*/
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Comment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a comment has been updated since creation.
|
||||
*/
|
||||
@@ -40,21 +51,22 @@ class Comment extends Model
|
||||
|
||||
/**
|
||||
* Get created date as a relative diff.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getCreatedAttribute()
|
||||
public function getCreatedAttribute(): string
|
||||
{
|
||||
return $this->created_at->diffForHumans();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get updated date as a relative diff.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUpdatedAttribute()
|
||||
public function getUpdatedAttribute(): string
|
||||
{
|
||||
return $this->updated_at->diffForHumans();
|
||||
}
|
||||
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
|
||||
}
|
||||
}
|
||||
|
||||
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,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\MessageParts;
|
||||
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
|
||||
/**
|
||||
* A line of text with linked text included, intended for use
|
||||
* in MailMessages. The line should have a ':link' placeholder for
|
||||
* where the link should be inserted within the line.
|
||||
*/
|
||||
class LinkedMailMessageLine implements Htmlable
|
||||
{
|
||||
public function __construct(
|
||||
protected string $url,
|
||||
protected string $line,
|
||||
protected string $linkText,
|
||||
) {
|
||||
}
|
||||
|
||||
public function toHtml(): string
|
||||
{
|
||||
$link = '<a href="' . e($this->url) . '">' . e($this->linkText) . '</a>';
|
||||
return str_replace(':link', $link, e($this->line));
|
||||
}
|
||||
}
|
||||
26
app/Activity/Notifications/MessageParts/ListMessageLine.php
Normal file
26
app/Activity/Notifications/MessageParts/ListMessageLine.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\MessageParts;
|
||||
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
|
||||
/**
|
||||
* A bullet point list of content, where the keys of the given list array
|
||||
* are bolded header elements, and the values follow.
|
||||
*/
|
||||
class ListMessageLine implements Htmlable
|
||||
{
|
||||
public function __construct(
|
||||
protected array $list
|
||||
) {
|
||||
}
|
||||
|
||||
public function toHtml(): string
|
||||
{
|
||||
$list = [];
|
||||
foreach ($this->list as $header => $content) {
|
||||
$list[] = '<strong>' . e($header) . '</strong> ' . e($content);
|
||||
}
|
||||
return implode("<br>\n", $list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
abstract class BaseActivityNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
protected Loggable|string $detail,
|
||||
protected User $user,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
abstract public function toMail(mixed $notifiable): MailMessage;
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function toArray($notifiable)
|
||||
{
|
||||
return [
|
||||
'activity_detail' => $this->detail,
|
||||
'activity_creator' => $this->user,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the common reason footer line used in mail messages.
|
||||
*/
|
||||
protected function buildReasonFooterLine(): LinkedMailMessageLine
|
||||
{
|
||||
return new LinkedMailMessageLine(
|
||||
url('/preferences/notifications'),
|
||||
trans('notifications.footer_reason'),
|
||||
trans('notifications.footer_reason_link'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class CommentCreationNotification extends BaseActivityNotification
|
||||
{
|
||||
public function toMail(mixed $notifiable): MailMessage
|
||||
{
|
||||
/** @var Comment $comment */
|
||||
$comment = $this->detail;
|
||||
/** @var Page $page */
|
||||
$page = $comment->entity;
|
||||
|
||||
return (new MailMessage())
|
||||
->subject(trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()]))
|
||||
->line(trans('notifications.new_comment_intro', ['appName' => setting('app-name')]))
|
||||
->line(new ListMessageLine([
|
||||
trans('notifications.detail_page_name') => $page->name,
|
||||
trans('notifications.detail_commenter') => $this->user->name,
|
||||
trans('notifications.detail_comment') => strip_tags($comment->html),
|
||||
]))
|
||||
->action(trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
|
||||
->line($this->buildReasonFooterLine());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class PageCreationNotification extends BaseActivityNotification
|
||||
{
|
||||
public function toMail(mixed $notifiable): MailMessage
|
||||
{
|
||||
/** @var Page $page */
|
||||
$page = $this->detail;
|
||||
|
||||
return (new MailMessage())
|
||||
->subject(trans('notifications.new_page_subject', ['pageName' => $page->getShortName()]))
|
||||
->line(trans('notifications.new_page_intro', ['appName' => setting('app-name')]))
|
||||
->line(new ListMessageLine([
|
||||
trans('notifications.detail_page_name') => $page->name,
|
||||
trans('notifications.detail_created_by') => $this->user->name,
|
||||
]))
|
||||
->action(trans('notifications.action_view_page'), $page->getUrl())
|
||||
->line($this->buildReasonFooterLine());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class PageUpdateNotification extends BaseActivityNotification
|
||||
{
|
||||
public function toMail(mixed $notifiable): MailMessage
|
||||
{
|
||||
/** @var Page $page */
|
||||
$page = $this->detail;
|
||||
|
||||
return (new MailMessage())
|
||||
->subject(trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()]))
|
||||
->line(trans('notifications.updated_page_intro', ['appName' => setting('app-name')]))
|
||||
->line(new ListMessageLine([
|
||||
trans('notifications.detail_page_name') => $page->name,
|
||||
trans('notifications.detail_updated_by') => $this->user->name,
|
||||
]))
|
||||
->line(trans('notifications.updated_page_debounce'))
|
||||
->action(trans('notifications.action_view_page'), $page->getUrl())
|
||||
->line($this->buildReasonFooterLine());
|
||||
}
|
||||
}
|
||||
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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,6 @@ use BookStack\Entities\Models\Page;
|
||||
use BookStack\Permissions\Models\EntityPermission;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Users\Models\HasOwner;
|
||||
use BookStack\Users\Models\Role;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
@@ -16,6 +15,11 @@ use InvalidArgumentException;
|
||||
|
||||
class PermissionApplicator
|
||||
{
|
||||
public function __construct(
|
||||
protected ?User $user = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an entity has a restriction set upon it.
|
||||
*
|
||||
@@ -173,7 +177,7 @@ class PermissionApplicator
|
||||
*/
|
||||
protected function currentUser(): User
|
||||
{
|
||||
return user();
|
||||
return $this->user ?? user();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,18 +2,27 @@
|
||||
|
||||
namespace BookStack\Users\Controllers;
|
||||
|
||||
use BookStack\Activity\Models\Watch;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Settings\UserNotificationPreferences;
|
||||
use BookStack\Settings\UserShortcutMap;
|
||||
use BookStack\Users\UserRepo;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserPreferencesController extends Controller
|
||||
{
|
||||
protected UserRepo $userRepo;
|
||||
public function __construct(
|
||||
protected UserRepo $userRepo
|
||||
) {
|
||||
}
|
||||
|
||||
public function __construct(UserRepo $userRepo)
|
||||
/**
|
||||
* Show the overview for user preferences.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
return view('users.preferences.index');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,6 +33,8 @@ class UserPreferencesController extends Controller
|
||||
$shortcuts = UserShortcutMap::fromUserPreferences();
|
||||
$enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
|
||||
|
||||
$this->setPageTitle(trans('preferences.shortcuts_interface'));
|
||||
|
||||
return view('users.preferences.shortcuts', [
|
||||
'shortcuts' => $shortcuts,
|
||||
'enabled' => $enabled,
|
||||
@@ -47,6 +58,46 @@ class UserPreferencesController extends Controller
|
||||
return redirect('/preferences/shortcuts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the notification preferences for the current user.
|
||||
*/
|
||||
public function showNotifications(PermissionApplicator $permissions)
|
||||
{
|
||||
$this->checkPermission('receive-notifications');
|
||||
$this->preventGuestAccess();
|
||||
|
||||
$preferences = (new UserNotificationPreferences(user()));
|
||||
|
||||
$query = Watch::query()->where('user_id', '=', user()->id);
|
||||
$query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
|
||||
$watches = $query->with('watchable')->paginate(20);
|
||||
|
||||
$this->setPageTitle(trans('preferences.notifications'));
|
||||
return view('users.preferences.notifications', [
|
||||
'preferences' => $preferences,
|
||||
'watches' => $watches,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the notification preferences for the current user.
|
||||
*/
|
||||
public function updateNotifications(Request $request)
|
||||
{
|
||||
$this->checkPermission('receive-notifications');
|
||||
$this->preventGuestAccess();
|
||||
$data = $this->validate($request, [
|
||||
'preferences' => ['required', 'array'],
|
||||
'preferences.*' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$preferences = (new UserNotificationPreferences(user()));
|
||||
$preferences->updateFromSettingsArray($data['preferences']);
|
||||
$this->showSuccessNotification(trans('preferences.notifications_update_success'));
|
||||
|
||||
return redirect('/preferences/notifications');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the preferred view format for a list view of the given type.
|
||||
*/
|
||||
@@ -123,7 +174,7 @@ class UserPreferencesController extends Controller
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'language' => ['required', 'string', 'max:20'],
|
||||
'active' => ['required', 'bool'],
|
||||
'active' => ['required', 'bool'],
|
||||
]);
|
||||
|
||||
$currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', '');
|
||||
|
||||
@@ -88,8 +88,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
|
||||
/**
|
||||
* This holds the default user when loaded.
|
||||
*
|
||||
* @var null|User
|
||||
*/
|
||||
protected static ?User $defaultUser = null;
|
||||
|
||||
@@ -107,6 +105,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
return static::$defaultUser;
|
||||
}
|
||||
|
||||
public static function clearDefault(): void
|
||||
{
|
||||
static::$defaultUser = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is the default public user.
|
||||
*/
|
||||
|
||||
@@ -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(['*'])
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -111,4 +111,6 @@ return [
|
||||
// Settings & Maintenance
|
||||
'maintenance_test_email_failure' => 'Беше върната грешка, когато се изпрати тестовият емейл:',
|
||||
|
||||
// HTTP errors
|
||||
'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts',
|
||||
];
|
||||
|
||||
26
lang/bg/notifications.php
Normal file
26
lang/bg/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_interface' => 'Interface Keyboard Shortcuts',
|
||||
'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.',
|
||||
@@ -15,4 +17,17 @@ return [
|
||||
'shortcuts_save' => 'Запазване на преките пътища',
|
||||
'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.',
|
||||
'shortcuts_update_success' => 'Обновени предпочитания за преки пътища!',
|
||||
];
|
||||
'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.',
|
||||
|
||||
'notifications' => 'Notification Preferences',
|
||||
'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.',
|
||||
'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own',
|
||||
'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own',
|
||||
'notifications_opt_comment_replies' => 'Notify upon replies to my comments',
|
||||
'notifications_save' => 'Save Preferences',
|
||||
'notifications_update_success' => 'Notification preferences have been updated!',
|
||||
'notifications_watched' => 'Watched & Ignored Items',
|
||||
'notifications_watched_desc' => ' Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.',
|
||||
|
||||
'profile_overview_desc' => ' Manage your user profile details including preferred language and authentication options.',
|
||||
];
|
||||
|
||||
@@ -163,6 +163,7 @@ return [
|
||||
'role_manage_settings' => 'Управление на настройките на приложението',
|
||||
'role_export_content' => 'Експортирай съдържанието',
|
||||
'role_editor_change' => 'Change page editor',
|
||||
'role_notifications' => 'Receive & manage notifications',
|
||||
'role_asset' => 'Настройки за достъп до активи',
|
||||
'roles_system_warning' => 'Важно: Добавянето на потребител в някое от горните три роли може да му позволи да промени собствените си права или правата на другите в системата. Възлагайте тези роли само на доверени потребители.',
|
||||
'role_asset_desc' => 'Тези настройки за достъп контролират достъпа по подразбиране до активите в системата. Настройките за достъп до книги, глави и страници ще отменят тези настройки.',
|
||||
|
||||
@@ -58,6 +58,9 @@ return [
|
||||
'favourite_add_notification' => '":name" je dodan u tvoje favorite',
|
||||
'favourite_remove_notification' => '":name" je uklonjen iz tvojih favorita',
|
||||
|
||||
// Watching
|
||||
'watch_update_level_notification' => 'Watch preferences successfully updated',
|
||||
|
||||
// Auth
|
||||
'auth_login' => 'logged in',
|
||||
'auth_register' => 'registered as new user',
|
||||
@@ -110,7 +113,12 @@ return [
|
||||
'recycle_bin_restore' => 'restored from recycle bin',
|
||||
'recycle_bin_destroy' => 'removed from recycle bin',
|
||||
|
||||
// Other
|
||||
// Comments
|
||||
'commented_on' => 'je komentarisao/la na',
|
||||
'comment_create' => 'added comment',
|
||||
'comment_update' => 'updated comment',
|
||||
'comment_delete' => 'deleted comment',
|
||||
|
||||
// Other
|
||||
'permissions_update' => 'je ažurirao/la dozvole',
|
||||
];
|
||||
|
||||
@@ -42,6 +42,7 @@ return [
|
||||
'remove' => 'Ukloni',
|
||||
'add' => 'Dodaj',
|
||||
'configure' => 'Configure',
|
||||
'manage' => 'Manage',
|
||||
'fullscreen' => 'Prikaz preko čitavog ekrana',
|
||||
'favourite' => 'Favorit',
|
||||
'unfavourite' => 'Ukloni favorit',
|
||||
|
||||
@@ -106,6 +106,7 @@ return [
|
||||
'shelves_permissions_updated' => 'Shelf Permissions Updated',
|
||||
'shelves_permissions_active' => 'Shelf Permissions Active',
|
||||
'shelves_permissions_cascade_warning' => 'Permissions on shelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
|
||||
'shelves_permissions_create' => 'Shelf create permissions are only used for copying permissions to child books using the action below. They do not control the ability to create books.',
|
||||
'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
|
||||
'shelves_copy_permissions' => 'Copy Permissions',
|
||||
'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.',
|
||||
@@ -238,6 +239,8 @@ return [
|
||||
'pages_md_insert_drawing' => 'Insert Drawing',
|
||||
'pages_md_show_preview' => 'Show preview',
|
||||
'pages_md_sync_scroll' => 'Sync preview scroll',
|
||||
'pages_drawing_unsaved' => 'Unsaved Drawing Found',
|
||||
'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
|
||||
'pages_not_in_chapter' => 'Page is not in a chapter',
|
||||
'pages_move' => 'Move Page',
|
||||
'pages_copy' => 'Copy Page',
|
||||
@@ -402,4 +405,28 @@ return [
|
||||
'references' => 'References',
|
||||
'references_none' => 'There are no tracked references to this item.',
|
||||
'references_to_desc' => 'Shown below are all the known pages in the system that link to this item.',
|
||||
|
||||
// Watch Options
|
||||
'watch' => 'Watch',
|
||||
'watch_title_default' => 'Default Preferences',
|
||||
'watch_desc_default' => 'Revert watching to just your default notification preferences.',
|
||||
'watch_title_ignore' => 'Ignore',
|
||||
'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.',
|
||||
'watch_title_new' => 'New Pages',
|
||||
'watch_desc_new' => 'Notify when any new page is created within this item.',
|
||||
'watch_title_updates' => 'All Page Updates',
|
||||
'watch_desc_updates' => 'Notify upon all new pages and page changes.',
|
||||
'watch_desc_updates_page' => 'Notify upon all page changes.',
|
||||
'watch_title_comments' => 'All Page Updates & Comments',
|
||||
'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.',
|
||||
'watch_desc_comments_page' => 'Notify upon page changes and new comments.',
|
||||
'watch_change_default' => 'Change default notification preferences',
|
||||
'watch_detail_ignore' => 'Ignoring notifications',
|
||||
'watch_detail_new' => 'Watching for new pages',
|
||||
'watch_detail_updates' => 'Watching new pages and updates',
|
||||
'watch_detail_comments' => 'Watching new pages, updates & comments',
|
||||
'watch_detail_parent_book' => 'Watching via parent book',
|
||||
'watch_detail_parent_book_ignore' => 'Ignoring via parent book',
|
||||
'watch_detail_parent_chapter' => 'Watching via parent chapter',
|
||||
'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter',
|
||||
];
|
||||
|
||||
@@ -111,4 +111,6 @@ return [
|
||||
// Settings & Maintenance
|
||||
'maintenance_test_email_failure' => 'Došlo je do greške prilikom slanja testnog e-maila:',
|
||||
|
||||
// HTTP errors
|
||||
'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts',
|
||||
];
|
||||
|
||||
26
lang/bs/notifications.php
Normal file
26
lang/bs/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' => 'Manage app settings',
|
||||
'role_export_content' => 'Export content',
|
||||
'role_editor_change' => 'Change page editor',
|
||||
'role_notifications' => 'Receive & manage notifications',
|
||||
'role_asset' => 'Asset Permissions',
|
||||
'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
|
||||
'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.',
|
||||
|
||||
@@ -58,6 +58,9 @@ return [
|
||||
'favourite_add_notification' => '":name" has been added to your favourites',
|
||||
'favourite_remove_notification' => '":name" has been removed from your favourites',
|
||||
|
||||
// Watching
|
||||
'watch_update_level_notification' => 'Watch preferences successfully updated',
|
||||
|
||||
// Auth
|
||||
'auth_login' => 'logged in',
|
||||
'auth_register' => 'registered as new user',
|
||||
@@ -110,7 +113,12 @@ return [
|
||||
'recycle_bin_restore' => 'restored from recycle bin',
|
||||
'recycle_bin_destroy' => 'removed from recycle bin',
|
||||
|
||||
// Other
|
||||
// Comments
|
||||
'commented_on' => 'ha comentat a',
|
||||
'comment_create' => 'added comment',
|
||||
'comment_update' => 'updated comment',
|
||||
'comment_delete' => 'deleted comment',
|
||||
|
||||
// Other
|
||||
'permissions_update' => 'ha actualitzat els permisos',
|
||||
];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user