Merge branch 'BookStackApp:development' into basic-pwa-support

This commit is contained in:
JonatanRek 2023-09-22 10:48:48 +02:00 committed by GitHub
commit effc03e99e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
508 changed files with 8513 additions and 2216 deletions

View File

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

View File

@ -1,7 +1,14 @@
name: Bug Report
description: Create a report to help us improve or fix things
description: Create a report to help us fix bugs & issues in existing supported functionality
labels: [":bug: Bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out a bug report!
Please note that this form is for reporting bugs in existing supported functionality.
If you are reporting something that's not an issue in functionality we've previously supported and/or is simply something different to your expectations, then it may be more appropriate to raise via a feature or support request instead.
- type: textarea
id: description
attributes:
@ -13,7 +20,7 @@ body:
id: reproduction
attributes:
label: Steps to Reproduce
description: Detail the steps that would replicate this issue
description: Detail the steps that would replicate this issue.
placeholder: |
1. Go to '...'
2. Click on '....'
@ -32,7 +39,7 @@ body:
id: context
attributes:
label: Screenshots or Additional Context
description: Provide any additional context and screenshots here to help us solve this issue
description: Provide any additional context and screenshots here to help us solve this issue.
validations:
required: false
- type: input
@ -48,23 +55,7 @@ body:
id: bsversion
attributes:
label: Exact BookStack Version
description: This can be found in the settings view of BookStack. Please provide an exact version.
placeholder: (eg. v21.08.5)
validations:
required: true
- type: input
id: phpversion
attributes:
label: PHP Version
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue.
placeholder: (eg. 7.4)
validations:
required: false
- type: textarea
id: hosting
attributes:
label: Hosting Environment
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
description: This can be found in the settings view of BookStack. Please provide an exact version(s) you've tested on.
placeholder: (eg. v23.06.7)
validations:
required: true

View File

@ -33,9 +33,9 @@ body:
attributes:
label: Have you searched for an existing open/closed issue?
description: |
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundemental benefit/goal of your request.
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundamental benefit/goal of your request.
options:
- label: I have searched for existing issues and none cover my fundemental request
- label: I have searched for existing issues and none cover my fundamental request
required: true
- type: dropdown
id: existing_usage
@ -43,8 +43,8 @@ body:
label: How long have you been using BookStack?
options:
- Not using yet, just scoping
- 0 to 6 months
- 6 months to 1 year
- Under 3 months
- 3 months to 1 year
- 1 to 5 years
- Over 5 years
validations:

View File

@ -33,7 +33,7 @@ body:
attributes:
label: Exact BookStack Version
description: This can be found in the settings view of BookStack. Please provide an exact version.
placeholder: (eg. v21.08.5)
placeholder: (eg. v23.06.7)
validations:
required: true
- type: textarea
@ -44,19 +44,11 @@ body:
placeholder: Be sure to remove any confidential details in your logs
validations:
required: false
- type: input
id: phpversion
attributes:
label: PHP Version
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that most relevant to the issue.
placeholder: (eg. 7.4)
validations:
required: false
- type: textarea
id: hosting
attributes:
label: Hosting Environment
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
placeholder: (eg. PHP8.1 on Ubuntu 22.04 VPS, installed using official installation script)
validations:
required: true

View File

@ -57,6 +57,7 @@ Name :: Languages
@Jokuna :: Korean
@smartshogu :: German; German Informal
@samadha56 :: Persian
@mrmuminov :: Uzbek
cipi1965 :: Italian
Mykola Ronik (Mantikor) :: Ukrainian
furkanoyk :: Turkish
@ -289,7 +290,7 @@ Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
LiZerui (CNLiZerui) :: Chinese Traditional
Fabrice Boyer (FabriceBoyer) :: French
mikael (bitcanon) :: Swedish
Matthias Mai (schnapsidee) :: German; German Informal
Matthias Mai (schnapsidee) :: German Informal; German
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
Jan Mitrof (jan.kachlik) :: Czech
edwardsmirnov :: Russian
@ -344,3 +345,18 @@ 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; Norwegian Nynorsk
Minh Giang Truong (minhgiang1204) :: Vietnamese
Ioannis Ioannides (i.ioannides) :: Greek
Vadim (vadrozh) :: Russian
Flip333 :: German Informal; German
Paulo Henrique (paulohsantos114) :: Portuguese, Brazilian
Dženan (Dzenan) :: Swedish
Péter Péli (peter.peli) :: Hungarian
TWME :: Chinese Traditional
Sascha (Man-in-Black) :: German
Mohammadreza Madadi (madadi.efl) :: Persian
Konstantin Kovacheli (kkovacheli) :: Ukrainian

View File

@ -71,7 +71,7 @@ trait ThrottlesLogins
*/
protected function limiter(): RateLimiter
{
return app(RateLimiter::class);
return app()->make(RateLimiter::class);
}
/**

View File

@ -2,8 +2,8 @@
namespace BookStack\Access;
use BookStack\Access\Notifications\ConfirmEmailNotification;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Notifications\ConfirmEmail;
use BookStack\Users\Models\User;
class EmailConfirmationService extends UserTokenService
@ -26,7 +26,7 @@ class EmailConfirmationService extends UserTokenService
$this->deleteByUser($user);
$token = $this->createTokenForUser($user);
$user->notify(new ConfirmEmail($token));
$user->notify(new ConfirmEmailNotification($token));
}
/**

View File

@ -0,0 +1,26 @@
<?php
namespace BookStack\Access\Notifications;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class ConfirmEmailNotification extends MailNotification
{
public function __construct(
public string $token
) {
}
public function toMail(User $notifiable): MailMessage
{
$appName = ['appName' => setting('app-name')];
return $this->newMailMessage()
->subject(trans('auth.email_confirm_subject', $appName))
->greeting(trans('auth.email_confirm_greeting', $appName))
->line(trans('auth.email_confirm_text'))
->action(trans('auth.email_confirm_action'), url('/register/confirm/' . $this->token));
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace BookStack\Access\Notifications;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class ResetPasswordNotification extends MailNotification
{
public function __construct(
public string $token
) {
}
public function toMail(User $notifiable): MailMessage
{
return $this->newMailMessage()
->subject(trans('auth.email_reset_subject', ['appName' => setting('app-name')]))
->line(trans('auth.email_reset_text'))
->action(trans('auth.reset_password'), url('password/reset/' . $this->token))
->line(trans('auth.email_reset_not_requested'));
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace BookStack\Access\Notifications;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class UserInviteNotification extends MailNotification
{
public function __construct(
public string $token
) {
}
public function toMail(User $notifiable): MailMessage
{
$appName = ['appName' => setting('app-name')];
$locale = $notifiable->getLocale();
return $this->newMailMessage($locale)
->subject($locale->trans('auth.user_invite_email_subject', $appName))
->greeting($locale->trans('auth.user_invite_email_greeting', $appName))
->line($locale->trans('auth.user_invite_email_text'))
->action($locale->trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
}
}

View File

@ -20,15 +20,8 @@ class OidcOAuthProvider extends AbstractProvider
{
use BearerAuthorizationTrait;
/**
* @var string
*/
protected $authorizationEndpoint;
/**
* @var string
*/
protected $tokenEndpoint;
protected string $authorizationEndpoint;
protected string $tokenEndpoint;
/**
* Scopes to use for the OIDC authorization call.
@ -60,7 +53,7 @@ class OidcOAuthProvider extends AbstractProvider
}
/**
* Add an additional scope to this provider upon the default.
* Add another scope to this provider upon the default.
*/
public function addScope(string $scope): void
{

View File

@ -59,7 +59,7 @@ class OidcProviderSettings
}
}
if (strpos($this->issuer, 'https://') !== 0) {
if (!str_starts_with($this->issuer, 'https://')) {
throw new InvalidArgumentException('Issuer value must start with https://');
}
}

View File

@ -9,13 +9,13 @@ use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Http\Client\ClientInterface as HttpClient;
/**
* Class OpenIdConnectService
@ -26,7 +26,7 @@ class OidcService
public function __construct(
protected RegistrationService $registrationService,
protected LoginService $loginService,
protected HttpClient $httpClient,
protected HttpRequestService $http,
protected GroupSyncService $groupService
) {
}
@ -94,7 +94,7 @@ class OidcService
// Run discovery
if ($config['discover'] ?? false) {
try {
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
$settings->discoverFromIssuer($this->http->buildClient(5), Cache::store(null), 15);
} catch (OidcIssuerDiscoveryException $exception) {
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
}
@ -111,7 +111,7 @@ class OidcService
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
'httpClient' => $this->httpClient,
'httpClient' => $this->http->buildClient(5),
'optionProvider' => new HttpBasicAuthOptionProvider(),
]);
@ -142,10 +142,11 @@ class OidcService
*/
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
{
$displayNameAttr = $this->config()['display_name_claims'];
$displayNameAttrString = $this->config()['display_name_claims'] ?? '';
$displayNameAttrs = explode('|', $displayNameAttrString);
$displayName = [];
foreach ($displayNameAttr as $dnAttr) {
foreach ($displayNameAttrs as $dnAttr) {
$dnComponent = $token->getClaim($dnAttr) ?? '';
if ($dnComponent !== '') {
$displayName[] = $dnComponent;

View File

@ -2,7 +2,7 @@
namespace BookStack\Access;
use BookStack\Notifications\UserInvite;
use BookStack\Access\Notifications\UserInviteNotification;
use BookStack\Users\Models\User;
class UserInviteService extends UserTokenService
@ -18,6 +18,6 @@ class UserInviteService extends UserTokenService
{
$this->deleteByUser($user);
$token = $this->createTokenForUser($user);
$user->notify(new UserInvite($token));
$user->notify(new UserInviteNotification($token));
}
}

View File

@ -6,11 +6,17 @@ use BookStack\Activity\Models\Favouritable;
use BookStack\App\Model;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
class FavouriteController extends Controller
{
public function __construct(
protected MixedEntityRequestHelper $entityHelper,
) {
}
/**
* Show a listing of all favourite items for the current user.
*/
@ -36,13 +42,14 @@ class FavouriteController extends Controller
*/
public function add(Request $request)
{
$favouritable = $this->getValidatedModelFromRequest($request);
$favouritable->favourites()->firstOrCreate([
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
$entity->favourites()->firstOrCreate([
'user_id' => user()->id,
]);
$this->showSuccessNotification(trans('activities.favourite_add_notification', [
'name' => $favouritable->name,
'name' => $entity->name,
]));
return redirect()->back();
@ -53,48 +60,16 @@ class FavouriteController extends Controller
*/
public function remove(Request $request)
{
$favouritable = $this->getValidatedModelFromRequest($request);
$favouritable->favourites()->where([
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
$entity->favourites()->where([
'user_id' => user()->id,
])->delete();
$this->showSuccessNotification(trans('activities.favourite_remove_notification', [
'name' => $favouritable->name,
'name' => $entity->name,
]));
return redirect()->back();
}
/**
* @throws \Illuminate\Validation\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 Favouritable) {
throw new \Exception('Model not favouritable');
}
$modelInstance = $model->newQuery()
->where('id', '=', $modelInfo['id'])
->first(['id', 'name', 'owned_by']);
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
if (is_null($modelInstance) || $inaccessibleEntity) {
throw new \Exception('Model instance not found');
}
return $modelInstance;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace BookStack\Activity\Controllers;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
class WatchController extends Controller
{
public function update(Request $request, MixedEntityRequestHelper $entityHelper)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();
$requestData = $this->validate($request, array_merge([
'level' => ['required', 'string'],
], $entityHelper->validationRules()));
$watchable = $entityHelper->getVisibleEntityFromRequestData($requestData);
$watchOptions = new UserEntityWatchOptions(user(), $watchable);
$watchOptions->updateLevelByName($requestData['level']);
$this->showSuccessNotification(trans('activities.watch_update_level_notification'));
return redirect()->back();
}
}

View File

@ -6,14 +6,15 @@ use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Tools\WebhookFormatter;
use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
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;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class DispatchWebhookJob implements ShouldQueue
@ -48,23 +49,28 @@ class DispatchWebhookJob implements ShouldQueue
*
* @return void
*/
public function handle()
public function handle(HttpRequestService $http)
{
$lastError = null;
try {
$response = Http::asJson()
->withOptions(['allow_redirects' => ['strict' => true]])
->timeout($this->webhook->timeout)
->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}\"");
}
(new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);
if (isset($response) && $response->failed()) {
$lastError = "Response status from endpoint was {$response->status()}";
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
$client = $http->buildClient($this->webhook->timeout, [
'connect_timeout' => 10,
'allow_redirects' => ['strict' => true],
]);
$response = $client->sendRequest($http->jsonRequest('POST', $this->webhook->endpoint, $this->webhookData));
$statusCode = $response->getStatusCode();
if ($statusCode >= 400) {
$lastError = "Response status from endpoint was {$statusCode}";
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$statusCode}");
}
} catch (\Exception $error) {
$lastError = $error->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
}
$this->webhook->last_called_at = now();

View File

@ -5,16 +5,19 @@ namespace BookStack\Activity\Models;
use BookStack\App\Model;
use BookStack\Users\Models\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property string $text
* @property string $html
* @property int|null $parent_id
* @property int|null $parent_id - Relates to local_id, not id
* @property int $local_id
* @property string $entity_type
* @property int $entity_id
* @property int $created_by
* @property int $updated_by
*/
class Comment extends Model implements Loggable
{
@ -32,6 +35,16 @@ class Comment extends Model implements Loggable
return $this->morphTo('entity');
}
/**
* Get the parent comment this is in reply to (if existing).
*/
public function parent(): BelongsTo
{
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
->where('entity_type', '=', $this->entity_type)
->where('entity_id', '=', $this->entity_id);
}
/**
* Check if a comment has been updated since creation.
*/
@ -42,20 +55,16 @@ class Comment extends Model implements Loggable
/**
* 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();
}

View File

@ -41,7 +41,7 @@ class View extends Model
public static function incrementFor(Viewable $viewable): int
{
$user = user();
if (is_null($user) || $user->isDefault()) {
if ($user->isGuest()) {
return 0;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@ use BookStack\Activity\DispatchWebhookJob;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Webhook;
use BookStack\App\Model;
use BookStack\Activity\Notifications\NotificationManager;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
@ -15,10 +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.
*/
public function add(string $type, $detail = '')
public function add(string $type, string|Loggable $detail = ''): void
{
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
@ -34,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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -78,14 +78,14 @@ class HomeController extends Controller
}
if ($homepageOption === 'bookshelves') {
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$shelves = app()->make(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$data = array_merge($commonData, ['shelves' => $shelves]);
return view('home.shelves', $data);
}
if ($homepageOption === 'books') {
$books = app(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$books = app()->make(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$data = array_merge($commonData, ['books' => $books]);
return view('home.books', $data);

View File

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

View File

@ -9,15 +9,15 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\BookStackExceptionHandlerPage;
use BookStack\Http\HttpRequestService;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService;
use BookStack\Util\CspService;
use GuzzleHttp\Client;
use Illuminate\Contracts\Foundation\ExceptionRenderer;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Psr\Http\Client\ClientInterface as HttpClientInterface;
class AppServiceProvider extends ServiceProvider
{
@ -38,6 +38,7 @@ class AppServiceProvider extends ServiceProvider
SettingService::class => SettingService::class,
SocialAuthService::class => SocialAuthService::class,
CspService::class => CspService::class,
HttpRequestService::class => HttpRequestService::class,
];
/**
@ -50,7 +51,7 @@ class AppServiceProvider extends ServiceProvider
// Set root URL
$appUrl = config('app.url');
if ($appUrl) {
$isHttps = (strpos($appUrl, 'https://') === 0);
$isHttps = str_starts_with($appUrl, 'https://');
URL::forceRootUrl($appUrl);
URL::forceScheme($isHttps ? 'https' : 'http');
}
@ -74,10 +75,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function register()
{
$this->app->bind(HttpClientInterface::class, function ($app) {
return new Client([
'timeout' => 3,
]);
$this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null);
});
}
}

View File

@ -9,6 +9,7 @@ use BookStack\Access\LdapService;
use BookStack\Access\LoginService;
use BookStack\Access\RegistrationService;
use BookStack\Api\ApiTokenGuard;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
@ -65,5 +66,11 @@ class AuthServiceProvider extends ServiceProvider
Auth::provider('external-users', function ($app, array $config) {
return new ExternalBaseUserProvider($config['model']);
});
// Bind and provide the default system user as a singleton to the app instance when needed.
// This effectively "caches" fetching the user at an app-instance level.
$this->app->singleton('users.default', function () {
return User::query()->where('system_name', '=', 'public')->first();
});
}
}

View File

@ -3,7 +3,12 @@
namespace BookStack\App\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use SocialiteProviders\Azure\AzureExtendSocialite;
use SocialiteProviders\Discord\DiscordExtendSocialite;
use SocialiteProviders\GitLab\GitLabExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled;
use SocialiteProviders\Okta\OktaExtendSocialite;
use SocialiteProviders\Twitch\TwitchExtendSocialite;
class EventServiceProvider extends ServiceProvider
{
@ -14,12 +19,11 @@ class EventServiceProvider extends ServiceProvider
*/
protected $listen = [
SocialiteWasCalled::class => [
'SocialiteProviders\Slack\SlackExtendSocialite@handle',
'SocialiteProviders\Azure\AzureExtendSocialite@handle',
'SocialiteProviders\Okta\OktaExtendSocialite@handle',
'SocialiteProviders\GitLab\GitLabExtendSocialite@handle',
'SocialiteProviders\Twitch\TwitchExtendSocialite@handle',
'SocialiteProviders\Discord\DiscordExtendSocialite@handle',
AzureExtendSocialite::class . '@handle',
OktaExtendSocialite::class . '@handle',
GitLabExtendSocialite::class . '@handle',
TwitchExtendSocialite::class . '@handle',
DiscordExtendSocialite::class . '@handle',
],
];

View File

@ -25,7 +25,7 @@ class ViewTweaksServiceProvider extends ServiceProvider
// Custom blade view directives
Blade::directive('icon', function ($expression) {
return "<?php echo icon($expression); ?>";
return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>";
});
}
}

View File

@ -35,23 +35,7 @@ function versioned_asset(string $file = ''): string
*/
function user(): User
{
return auth()->user() ?: User::getDefault();
}
/**
* Check if current user is a signed in user.
*/
function signedInUser(): bool
{
return auth()->user() && !auth()->user()->isDefault();
}
/**
* Check if the current user has general access.
*/
function hasAppAccess(): bool
{
return !auth()->guest() || setting('app-public');
return auth()->user() ?: User::getGuest();
}
/**
@ -61,11 +45,11 @@ function hasAppAccess(): bool
function userCan(string $permission, Model $ownable = null): bool
{
if ($ownable === null) {
return user() && user()->can($permission);
return user()->can($permission);
}
// Check permission on ownable item
$permissions = app(PermissionApplicator::class);
$permissions = app()->make(PermissionApplicator::class);
return $permissions->checkOwnableUserAccess($ownable, $permission);
}
@ -76,7 +60,7 @@ function userCan(string $permission, Model $ownable = null): bool
*/
function userCanOnAny(string $action, string $entityClass = ''): bool
{
$permissions = app(PermissionApplicator::class);
$permissions = app()->make(PermissionApplicator::class);
return $permissions->checkUserHasEntityPermissionOnAny($action, $entityClass);
}
@ -88,7 +72,7 @@ function userCanOnAny(string $action, string $entityClass = ''): bool
*/
function setting(string $key = null, $default = null)
{
$settingService = resolve(SettingService::class);
$settingService = app()->make(SettingService::class);
if (is_null($key)) {
return $settingService;
@ -113,39 +97,6 @@ function theme_path(string $path = ''): ?string
return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path));
}
/**
* Get fetch an SVG icon as a string.
* Checks for icons defined within a custom theme before defaulting back
* to the 'resources/assets/icons' folder.
*
* Returns an empty string if icon file not found.
*/
function icon(string $name, array $attrs = []): string
{
$attrs = array_merge([
'class' => 'svg-icon',
'data-icon' => $name,
'role' => 'presentation',
], $attrs);
$attrString = ' ';
foreach ($attrs as $attrName => $attr) {
$attrString .= $attrName . '="' . $attr . '" ';
}
$iconPath = resource_path('icons/' . $name . '.svg');
$themeIconPath = theme_path('icons/' . $name . '.svg');
if ($themeIconPath && file_exists($themeIconPath)) {
$iconPath = $themeIconPath;
} elseif (!file_exists($iconPath)) {
return '';
}
$fileContents = file_get_contents($iconPath);
return str_replace('<svg', '<svg' . $attrString, $fileContents);
}
/**
* Generate a URL with multiple parameters for sorting purposes.
* Works out the logic to set the correct sorting direction

View File

@ -66,6 +66,15 @@ return [
// Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),
// A list of the sources/hostnames that can be reached by application SSR calls.
// This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
// Host-specific functionality (usually controlled via other options) like auth
// or user avatars for example, won't use this list.
// Space seperated if multiple. Can use '*' as a wildcard.
// Values will be compared prefix-matched, case-insensitive, against called SSR urls.
// Defaults to allow all hosts.
'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'),
// Alter the precision of IP addresses stored by BookStack.
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
@ -74,10 +83,10 @@ return [
'timezone' => env('APP_TIMEZONE', 'UTC'),
// Default locale to use
// A default variant is also stored since Laravel can overwrite
// app.locale when dynamically setting the locale in-app.
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ka', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
'default_locale' => env('APP_LANG', 'en'),
// Application Fallback Locale
'fallback_locale' => 'en',
@ -85,9 +94,6 @@ return [
// Faker Locale
'faker_locale' => 'en_GB',
// Enable right-to-left text control.
'rtl' => false,
// Auto-detect the locale for public users
// For public users their locale can be guessed by headers sent by their
// browser. This is usually set by users in their browser settings.

View File

@ -22,7 +22,7 @@ return [
// Global "From" address & name
'from' => [
'address' => env('MAIL_FROM', 'mail@bookstackapp.com'),
'address' => env('MAIL_FROM', 'bookstack@example.com'),
'name' => env('MAIL_FROM_NAME', 'BookStack'),
],

View File

@ -9,7 +9,7 @@ return [
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
// Claim, within an OpenId token, to find the user's display name
'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
'display_name_claims' => env('OIDC_DISPLAY_NAME_CLAIMS', 'name'),
// Claim, within an OpenID token, to use to connect a BookStack user to the OIDC user.
'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'),

View File

@ -35,7 +35,7 @@ class CleanupImagesCommand extends Command
if (!$dryRun) {
$this->warn("This operation is destructive and is not guaranteed to be fully accurate.\nEnsure you have a backup of your images.\n");
$proceed = $this->confirm("Are you sure you want to proceed?");
$proceed = !$this->input->isInteractive() || $this->confirm("Are you sure you want to proceed?");
if (!$proceed) {
return 0;
}
@ -46,7 +46,7 @@ class CleanupImagesCommand extends Command
if ($dryRun) {
$this->comment('Dry run, no images have been deleted');
$this->comment($deleteCount . ' images found that would have been deleted');
$this->comment($deleteCount . ' image(s) found that would have been deleted');
$this->showDeletedImages($deleted);
$this->comment('Run with -f or --force to perform deletions');
@ -54,7 +54,8 @@ class CleanupImagesCommand extends Command
}
$this->showDeletedImages($deleted);
$this->comment($deleteCount . ' images deleted');
$this->comment("{$deleteCount} image(s) deleted");
return 0;
}
@ -65,7 +66,7 @@ class CleanupImagesCommand extends Command
}
if (count($paths) > 0) {
$this->line('Images to delete:');
$this->line('Image(s) to delete:');
}
foreach ($paths as $path) {

View File

@ -0,0 +1,40 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Users\Models\User;
use Exception;
use Illuminate\Console\Command;
/**
* @mixin Command
*/
trait HandlesSingleUser
{
/**
* Fetch a user provided to this command.
* Expects the command to accept 'id' and 'email' options.
* @throws Exception
*/
private function fetchProvidedUser(): User
{
$id = $this->option('id');
$email = $this->option('email');
if (!$id && !$email) {
throw new Exception("Either a --id=<number> or --email=<email> option must be provided.\nRun this command with `--help` to show more options.");
}
$field = $id ? 'id' : 'email';
$value = $id ?: $email;
$user = User::query()
->where($field, '=', $value)
->first();
if (!$user) {
throw new Exception("A user where {$field}={$value} could not be found.");
}
return $user;
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Users\Models\User;
use Exception;
use Illuminate\Console\Command;
use BookStack\Uploads\UserAvatars;
class RefreshAvatarCommand extends Command
{
use HandlesSingleUser;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:refresh-avatar
{--id= : Numeric ID of the user to refresh avatar for}
{--email= : Email address of the user to refresh avatar for}
{--users-without-avatars : Refresh avatars for users that currently have no avatar}
{--a|all : Refresh avatars for all users}
{--f|force : Actually run the update, Defaults to a dry-run}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Refresh avatar for the given user(s)';
public function handle(UserAvatars $userAvatar): int
{
if (!$userAvatar->avatarFetchEnabled()) {
$this->error("Avatar fetching is disabled on this instance.");
return self::FAILURE;
}
if ($this->option('users-without-avatars')) {
return $this->processUsers(User::query()->whereDoesntHave('avatar')->get()->all(), $userAvatar);
}
if ($this->option('all')) {
return $this->processUsers(User::query()->get()->all(), $userAvatar);
}
try {
$user = $this->fetchProvidedUser();
return $this->processUsers([$user], $userAvatar);
} catch (Exception $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
}
/**
* @param User[] $users
*/
private function processUsers(array $users, UserAvatars $userAvatar): int
{
$dryRun = !$this->option('force');
$this->info(count($users) . " user(s) found to update avatars for.");
if (count($users) === 0) {
return self::SUCCESS;
}
if (!$dryRun) {
$fetchHost = parse_url($userAvatar->getAvatarUrl(), PHP_URL_HOST);
$this->warn("This will destroy any existing avatar images these users have, and attempt to fetch new avatar images from {$fetchHost}.");
$proceed = !$this->input->isInteractive() || $this->confirm('Are you sure you want to proceed?');
if (!$proceed) {
return self::SUCCESS;
}
}
$this->info("");
$exitCode = self::SUCCESS;
foreach ($users as $user) {
$linePrefix = "[ID: {$user->id}] $user->email -";
if ($dryRun) {
$this->warn("{$linePrefix} Not updated");
continue;
}
if ($this->fetchAvatar($userAvatar, $user)) {
$this->info("{$linePrefix} Updated");
} else {
$this->error("{$linePrefix} Not updated");
$exitCode = self::FAILURE;
}
}
if ($dryRun) {
$this->comment("");
$this->comment("Dry run, no avatars were updated.");
$this->comment('Run with -f or --force to perform the update.');
}
return $exitCode;
}
private function fetchAvatar(UserAvatars $userAvatar, User $user): bool
{
$oldId = $user->avatar->id ?? 0;
$userAvatar->fetchAndAssignToUser($user);
$user->refresh();
$newId = $user->avatar->id ?? $oldId;
return $oldId !== $newId;
}
}

View File

@ -2,11 +2,13 @@
namespace BookStack\Console\Commands;
use BookStack\Users\Models\User;
use Exception;
use Illuminate\Console\Command;
class ResetMfaCommand extends Command
{
use HandlesSingleUser;
/**
* The name and signature of the console command.
*
@ -29,25 +31,10 @@ class ResetMfaCommand extends Command
*/
public function handle(): int
{
$id = $this->option('id');
$email = $this->option('email');
if (!$id && !$email) {
$this->error('Either a --id=<number> or --email=<email> option must be provided.');
return 1;
}
$field = $id ? 'id' : 'email';
$value = $id ?: $email;
/** @var User $user */
$user = User::query()
->where($field, '=', $value)
->first();
if (!$user) {
$this->error("A user where {$field}={$value} could not be found.");
try {
$user = $this->fetchProvidedUser();
} catch (Exception $exception) {
$this->error($exception->getMessage());
return 1;
}

View File

@ -8,29 +8,21 @@ use Illuminate\View\View;
class BreadcrumbsViewComposer
{
protected $entityContextManager;
/**
* BreadcrumbsViewComposer constructor.
*
* @param ShelfContext $entityContextManager
*/
public function __construct(ShelfContext $entityContextManager)
{
$this->entityContextManager = $entityContextManager;
public function __construct(
protected ShelfContext $shelfContext
) {
}
/**
* Modify data when the view is composed.
*
* @param View $view
*/
public function compose(View $view)
public function compose(View $view): void
{
$crumbs = $view->getData()['crumbs'];
$firstCrumb = $crumbs[0] ?? null;
if ($firstCrumb instanceof Book) {
$shelf = $this->entityContextManager->getContextualShelfForBook($firstCrumb);
$shelf = $this->shelfContext->getContextualShelfForBook($firstCrumb);
if ($shelf) {
array_unshift($crumbs, $shelf);
$view->with('crumbs', $crumbs);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ class RecentlyViewed extends EntityQuery
public function run(int $count, int $page): Collection
{
$user = user();
if ($user === null || $user->isDefault()) {
if ($user === null || $user->isGuest()) {
return collect();
}

View File

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

View File

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

View File

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

View File

@ -16,18 +16,11 @@ use Throwable;
class ExportFormatter
{
protected ImageService $imageService;
protected PdfGenerator $pdfGenerator;
protected CspService $cspService;
/**
* ExportService constructor.
*/
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
{
$this->imageService = $imageService;
$this->pdfGenerator = $pdfGenerator;
$this->cspService = $cspService;
public function __construct(
protected ImageService $imageService,
protected PdfGenerator $pdfGenerator,
protected CspService $cspService
) {
}
/**
@ -36,13 +29,14 @@ class ExportFormatter
*
* @throws Throwable
*/
public function pageToContainedHtml(Page $page)
public function pageToContainedHtml(Page $page): string
{
$page->html = (new PageContent($page))->render();
$pageHtml = view('exports.page', [
'page' => $page,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
'locale' => user()->getLocale(),
])->render();
return $this->containHtml($pageHtml);
@ -53,7 +47,7 @@ class ExportFormatter
*
* @throws Throwable
*/
public function chapterToContainedHtml(Chapter $chapter)
public function chapterToContainedHtml(Chapter $chapter): string
{
$pages = $chapter->getVisiblePages();
$pages->each(function ($page) {
@ -64,6 +58,7 @@ class ExportFormatter
'pages' => $pages,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
'locale' => user()->getLocale(),
])->render();
return $this->containHtml($html);
@ -74,7 +69,7 @@ class ExportFormatter
*
* @throws Throwable
*/
public function bookToContainedHtml(Book $book)
public function bookToContainedHtml(Book $book): string
{
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('exports.book', [
@ -82,6 +77,7 @@ class ExportFormatter
'bookChildren' => $bookTree,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
'locale' => user()->getLocale(),
])->render();
return $this->containHtml($html);
@ -92,13 +88,14 @@ class ExportFormatter
*
* @throws Throwable
*/
public function pageToPdf(Page $page)
public function pageToPdf(Page $page): string
{
$page->html = (new PageContent($page))->render();
$html = view('exports.page', [
'page' => $page,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
'locale' => user()->getLocale(),
])->render();
return $this->htmlToPdf($html);
@ -109,7 +106,7 @@ class ExportFormatter
*
* @throws Throwable
*/
public function chapterToPdf(Chapter $chapter)
public function chapterToPdf(Chapter $chapter): string
{
$pages = $chapter->getVisiblePages();
$pages->each(function ($page) {
@ -121,6 +118,7 @@ class ExportFormatter
'pages' => $pages,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
'locale' => user()->getLocale(),
])->render();
return $this->htmlToPdf($html);
@ -131,7 +129,7 @@ class ExportFormatter
*
* @throws Throwable
*/
public function bookToPdf(Book $book)
public function bookToPdf(Book $book): string
{
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('exports.book', [
@ -139,6 +137,7 @@ class ExportFormatter
'bookChildren' => $bookTree,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
'locale' => user()->getLocale(),
])->render();
return $this->htmlToPdf($html);
@ -194,7 +193,7 @@ class ExportFormatter
/** @var DOMElement $iframe */
foreach ($iframes as $iframe) {
$link = $iframe->getAttribute('src');
if (strpos($link, '//') === 0) {
if (str_starts_with($link, '//')) {
$link = 'https:' . $link;
}
@ -240,7 +239,7 @@ class ExportFormatter
foreach ($linksOutput[0] as $index => $linkMatch) {
$oldLinkString = $linkMatch;
$srcString = $linksOutput[2][$index];
if (strpos(trim($srcString), 'http') !== 0) {
if (!str_starts_with(trim($srcString), 'http')) {
$newSrcString = url($srcString);
$newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);
$htmlContent = str_replace($oldLinkString, $newLinkString, $htmlContent);

View File

@ -0,0 +1,39 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
class MixedEntityRequestHelper
{
public function __construct(
protected EntityProvider $entities,
) {
}
/**
* Query out an entity, visible to the current user, for the given
* entity request details (this provided in a request validated by
* this classes' validationRules method).
* @param array{type: string, id: string} $requestData
*/
public function getVisibleEntityFromRequestData(array $requestData): Entity
{
$entityType = $this->entities->get($requestData['type']);
return $entityType->newQuery()->scopes(['visible'])->findOrFail($requestData['id']);
}
/**
* Get the validation rules for an abstract entity request.
* @return array{type: string[], id: string[]}
*/
public function validationRules(): array
{
return [
'type' => ['required', 'string'],
'id' => ['required', 'integer'],
];
}
}

View File

@ -197,7 +197,7 @@ class TrashCan
$page->allRevisions()->delete();
// Delete Attached Files
$attachmentService = app(AttachmentService::class);
$attachmentService = app()->make(AttachmentService::class);
foreach ($page->attachments as $attachment) {
$attachmentService->deleteFile($attachment);
}
@ -376,6 +376,7 @@ class TrashCan
$entity->searchTerms()->delete();
$entity->deletions()->delete();
$entity->favourites()->delete();
$entity->watches()->delete();
$entity->referencesTo()->delete();
$entity->referencesFrom()->delete();

View File

@ -0,0 +1,7 @@
<?php
namespace BookStack\Exceptions;
class ThemeException extends \Exception
{
}

View File

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

View File

@ -0,0 +1,33 @@
<?php
namespace BookStack\Http;
use GuzzleHttp\Psr7\Request as GuzzleRequest;
class HttpClientHistory
{
public function __construct(
protected &$container
) {
}
public function requestCount(): int
{
return count($this->container);
}
public function requestAt(int $index): ?GuzzleRequest
{
return $this->container[$index]['request'] ?? null;
}
public function latestRequest(): ?GuzzleRequest
{
return $this->requestAt($this->requestCount() - 1);
}
public function all(): array
{
return $this->container;
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace BookStack\Http;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request as GuzzleRequest;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Client\ClientInterface;
class HttpRequestService
{
protected ?HandlerStack $handler = null;
/**
* Build a new http client for sending requests on.
*/
public function buildClient(int $timeout, array $options = []): ClientInterface
{
$defaultOptions = [
'timeout' => $timeout,
'handler' => $this->handler,
];
return new Client(array_merge($options, $defaultOptions));
}
/**
* Create a new JSON http request for use with a client.
*/
public function jsonRequest(string $method, string $uri, array $data): GuzzleRequest
{
$headers = ['Content-Type' => 'application/json'];
return new GuzzleRequest($method, $uri, $headers, json_encode($data));
}
/**
* Mock any http clients built from this service, and response with the given responses.
* Returns history which can then be queried.
* @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware
*/
public function mockClient(array $responses = [], bool $pad = true): HttpClientHistory
{
// By default, we pad out the responses with 10 successful values so that requests will be
// properly recorded for inspection. Otherwise, we can't later check if we're received
// too many requests.
if ($pad) {
$response = new Response(200, [], 'success');
$responses = array_merge($responses, array_fill(0, 10, $response));
}
$container = [];
$history = Middleware::history($container);
$mock = new MockHandler($responses);
$this->handler = HandlerStack::create($mock);
$this->handler->push($history, 'history');
return new HttpClientHistory($container);
}
/**
* Clear mocking that has been set up for clients.
*/
public function clearMocking(): void
{
$this->handler = null;
}
}

View File

@ -31,7 +31,7 @@ class ApiAuthenticate
{
// Return if the user is already found to be signed in via session-based auth.
// This is to make it easy to browser the API via browser after just logging into the system.
if (signedInUser() || session()->isStarted()) {
if (!user()->isGuest() || session()->isStarted()) {
if (!$this->sessionUserHasApiAccess()) {
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
}
@ -53,6 +53,6 @@ class ApiAuthenticate
{
$hasApiPermission = user()->can('access-api');
return $hasApiPermission && hasAppAccess();
return $hasApiPermission && user()->hasAppAccess();
}
}

View File

@ -12,7 +12,7 @@ class Authenticate
*/
public function handle(Request $request, Closure $next)
{
if (!hasAppAccess()) {
if (!user()->hasAppAccess()) {
if ($request->ajax()) {
return response('Unauthorized.', 401);
}

View File

@ -2,17 +2,14 @@
namespace BookStack\Http\Middleware;
use BookStack\Translation\LanguageManager;
use Carbon\Carbon;
use BookStack\Translation\LocaleManager;
use Closure;
class Localization
{
protected LanguageManager $languageManager;
public function __construct(LanguageManager $languageManager)
{
$this->languageManager = $languageManager;
public function __construct(
protected LocaleManager $localeManager
) {
}
/**
@ -25,22 +22,12 @@ class Localization
*/
public function handle($request, Closure $next)
{
// Get and record the default language in the config
$defaultLang = config('app.locale');
config()->set('app.default_locale', $defaultLang);
// Share details of the user's locale for use in views
$userLocale = $this->localeManager->getForUser(user());
view()->share('locale', $userLocale);
// Get the user's language and record that in the config for use in views
$userLang = $this->languageManager->getUserLanguage($request, $defaultLang);
config()->set('app.lang', str_replace('_', '-', $this->languageManager->getIsoName($userLang)));
// Set text direction
if ($this->languageManager->isRTL($userLang)) {
config()->set('app.rtl', true);
}
app()->setLocale($userLang);
Carbon::setLocale($userLang);
$this->languageManager->setPhpDateTimeLocale($userLang);
// Set locale for system components
app()->setLocale($userLocale->appLocale());
return $next($request);
}

View File

@ -20,7 +20,7 @@ class PreventAuthenticatedResponseCaching
/** @var Response $response */
$response = $next($request);
if (signedInUser()) {
if (!user()->isGuest()) {
$response->headers->set('Cache-Control', 'max-age=0, no-store, private');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');

View File

@ -1,36 +0,0 @@
<?php
namespace BookStack\Notifications;
class ConfirmEmail extends MailNotification
{
public $token;
/**
* Create a new notification instance.
*
* @param string $token
*/
public function __construct($token)
{
$this->token = $token;
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
*
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
$appName = ['appName' => setting('app-name')];
return $this->newMailMessage()
->subject(trans('auth.email_confirm_subject', $appName))
->greeting(trans('auth.email_confirm_greeting', $appName))
->line(trans('auth.email_confirm_text'))
->action(trans('auth.email_confirm_action'), url('/register/confirm/' . $this->token));
}
}

View File

@ -1,37 +0,0 @@
<?php
namespace BookStack\Notifications;
class ResetPassword extends MailNotification
{
/**
* The password reset token.
*
* @var string
*/
public $token;
/**
* Create a notification instance.
*
* @param string $token
*/
public function __construct($token)
{
$this->token = $token;
}
/**
* Build the mail representation of the notification.
*
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail()
{
return $this->newMailMessage()
->subject(trans('auth.email_reset_subject', ['appName' => setting('app-name')]))
->line(trans('auth.email_reset_text'))
->action(trans('auth.reset_password'), url('password/reset/' . $this->token))
->line(trans('auth.email_reset_not_requested'));
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace BookStack\Notifications;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class UserInvite extends MailNotification
{
public $token;
/**
* Create a new notification instance.
*/
public function __construct(string $token)
{
$this->token = $token;
}
/**
* Get the mail representation of the notification.
*/
public function toMail(User $notifiable): MailMessage
{
$appName = ['appName' => setting('app-name')];
$language = setting()->getUser($notifiable, 'language');
return $this->newMailMessage()
->subject(trans('auth.user_invite_email_subject', $appName, $language))
->greeting(trans('auth.user_invite_email_greeting', $appName, $language))
->line(trans('auth.user_invite_email_text', [], $language))
->action(trans('auth.user_invite_email_action', [], $language), url('/register/invite/' . $this->token));
}
}

View File

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

View File

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

View File

@ -44,8 +44,8 @@ class SearchOptions
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
$parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
$instance->searches = $parsedStandardTerms['terms'];
$instance->exacts = $parsedStandardTerms['exacts'];
$instance->searches = array_filter($parsedStandardTerms['terms']);
$instance->exacts = array_filter($parsedStandardTerms['exacts']);
array_push($instance->exacts, ...array_filter($inputs['exact'] ?? []));
@ -78,7 +78,7 @@ class SearchOptions
];
$patterns = [
'exacts' => '/"(.*?)"/',
'exacts' => '/"(.*?)(?<!\\\)"/',
'tags' => '/\[(.*?)\]/',
'filters' => '/\{(.*?)\}/',
];
@ -93,6 +93,11 @@ class SearchOptions
}
}
// Unescape exacts
foreach ($terms['exacts'] as $index => $exact) {
$terms['exacts'][$index] = str_replace('\"', '"', $exact);
}
// Parse standard terms
$parsedStandardTerms = static::parseStandardTermString($searchString);
array_push($terms['searches'], ...$parsedStandardTerms['terms']);
@ -106,12 +111,19 @@ class SearchOptions
}
$terms['filters'] = $splitFilters;
// Filter down terms where required
$terms['exacts'] = array_filter($terms['exacts']);
$terms['searches'] = array_filter($terms['searches']);
return $terms;
}
/**
* Parse a standard search term string into individual search terms and
* extract any exact terms searches to be made.
* convert any required terms to exact matches. This is done since some
* characters will never be in the standard index, since we use them as
* delimiters, and therefore we convert a term to be exact if it
* contains one of those delimiter characters.
*
* @return array{terms: array<string>, exacts: array<string>}
*/
@ -129,8 +141,8 @@ class SearchOptions
continue;
}
$parsedList = (strpbrk($searchTerm, $indexDelimiters) === false) ? 'terms' : 'exacts';
$parsed[$parsedList][] = $searchTerm;
$becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false);
$parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm;
}
return $parsed;
@ -141,20 +153,21 @@ class SearchOptions
*/
public function toString(): string
{
$string = implode(' ', $this->searches ?? []);
$parts = $this->searches;
foreach ($this->exacts as $term) {
$string .= ' "' . $term . '"';
$escaped = str_replace('"', '\"', $term);
$parts[] = '"' . $escaped . '"';
}
foreach ($this->tags as $term) {
$string .= " [{$term}]";
$parts[] = "[{$term}]";
}
foreach ($this->filters as $filterName => $filterVal) {
$string .= ' {' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
$parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
}
return $string;
return implode(' ', $parts);
}
}

View File

@ -5,7 +5,6 @@ namespace BookStack\Settings;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Http\Controller;
use BookStack\Notifications\TestEmail;
use BookStack\References\ReferenceStore;
use BookStack\Uploads\ImageService;
use Illuminate\Http\Request;
@ -69,7 +68,7 @@ class MaintenanceController extends Controller
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
try {
user()->notifyNow(new TestEmail());
user()->notifyNow(new TestEmailNotification());
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
} catch (\Exception $exception) {
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();

View File

@ -34,7 +34,7 @@ class SettingController extends Controller
return view('settings.' . $category, [
'category' => $category,
'version' => $version,
'guestUser' => User::getDefault(),
'guestUser' => User::getGuest(),
]);
}

View File

@ -47,7 +47,7 @@ class SettingService
$default = config('setting-defaults.user.' . $key, false);
}
if ($user->isDefault()) {
if ($user->isGuest()) {
return $this->getFromSession($key, $default);
}
@ -206,7 +206,7 @@ class SettingService
*/
public function putUser(User $user, string $key, string $value): bool
{
if ($user->isDefault()) {
if ($user->isGuest()) {
session()->put($key, $value);
return true;

View File

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

View File

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

View File

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

View File

@ -3,19 +3,23 @@
namespace BookStack\Theming;
use BookStack\Access\SocialAuthService;
use BookStack\Exceptions\ThemeException;
use Illuminate\Console\Application;
use Illuminate\Console\Application as Artisan;
use Symfony\Component\Console\Command\Command;
class ThemeService
{
protected $listeners = [];
/**
* @var array<string, callable[]>
*/
protected array $listeners = [];
/**
* Listen to a given custom theme event,
* setting up the action to be ran when the event occurs.
*/
public function listen(string $event, callable $action)
public function listen(string $event, callable $action): void
{
if (!isset($this->listeners[$event])) {
$this->listeners[$event] = [];
@ -31,10 +35,8 @@ class ThemeService
*
* If a callback returns a non-null value, this method will
* stop and return that value itself.
*
* @return mixed
*/
public function dispatch(string $event, ...$args)
public function dispatch(string $event, ...$args): mixed
{
foreach ($this->listeners[$event] ?? [] as $action) {
$result = call_user_func_array($action, $args);
@ -49,7 +51,7 @@ class ThemeService
/**
* Register a new custom artisan command to be available.
*/
public function registerCommand(Command $command)
public function registerCommand(Command $command): void
{
Artisan::starting(function (Application $application) use ($command) {
$application->addCommands([$command]);
@ -59,18 +61,22 @@ class ThemeService
/**
* Read any actions from the set theme path if the 'functions.php' file exists.
*/
public function readThemeActions()
public function readThemeActions(): void
{
$themeActionsFile = theme_path('functions.php');
if ($themeActionsFile && file_exists($themeActionsFile)) {
require $themeActionsFile;
try {
require $themeActionsFile;
} catch (\Error $exception) {
throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}");
}
}
}
/**
* @see SocialAuthService::addSocialDriver
*/
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null)
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null): void
{
$socialAuthService = app()->make(SocialAuthService::class);
$socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);

View File

@ -1,137 +0,0 @@
<?php
namespace BookStack\Translation;
use Illuminate\Http\Request;
class LanguageManager
{
/**
* Array of right-to-left language options.
*/
protected array $rtlLanguages = ['ar', 'fa', 'he'];
/**
* Map of BookStack language names to best-estimate ISO and windows locale names.
* Locales can often be found by running `locale -a` on a linux system.
* Windows locales can be found at:
* https://docs.microsoft.com/en-us/cpp/c-runtime-library/language-strings?view=msvc-170.
*
* @var array<string, array{iso: string, windows: string}>
*/
protected array $localeMap = [
'ar' => ['iso' => 'ar', 'windows' => 'Arabic'],
'bg' => ['iso' => 'bg_BG', 'windows' => 'Bulgarian'],
'bs' => ['iso' => 'bs_BA', 'windows' => 'Bosnian (Latin)'],
'ca' => ['iso' => 'ca', 'windows' => 'Catalan'],
'cs' => ['iso' => 'cs_CZ', 'windows' => 'Czech'],
'da' => ['iso' => 'da_DK', 'windows' => 'Danish'],
'de' => ['iso' => 'de_DE', 'windows' => 'German'],
'de_informal' => ['iso' => 'de_DE', 'windows' => 'German'],
'en' => ['iso' => 'en_GB', 'windows' => 'English'],
'el' => ['iso' => 'el_GR', 'windows' => 'Greek'],
'es' => ['iso' => 'es_ES', 'windows' => 'Spanish'],
'es_AR' => ['iso' => 'es_AR', 'windows' => 'Spanish'],
'et' => ['iso' => 'et_EE', 'windows' => 'Estonian'],
'eu' => ['iso' => 'eu_ES', 'windows' => 'Basque'],
'fa' => ['iso' => 'fa_IR', 'windows' => 'Persian'],
'fr' => ['iso' => 'fr_FR', 'windows' => 'French'],
'he' => ['iso' => 'he_IL', 'windows' => 'Hebrew'],
'hr' => ['iso' => 'hr_HR', 'windows' => 'Croatian'],
'hu' => ['iso' => 'hu_HU', 'windows' => 'Hungarian'],
'id' => ['iso' => 'id_ID', 'windows' => 'Indonesian'],
'it' => ['iso' => 'it_IT', 'windows' => 'Italian'],
'ja' => ['iso' => 'ja', 'windows' => 'Japanese'],
'ko' => ['iso' => 'ko_KR', 'windows' => 'Korean'],
'lt' => ['iso' => 'lt_LT', 'windows' => 'Lithuanian'],
'lv' => ['iso' => 'lv_LV', 'windows' => 'Latvian'],
'nl' => ['iso' => 'nl_NL', 'windows' => 'Dutch'],
'nb' => ['iso' => 'nb_NO', 'windows' => 'Norwegian (Bokmal)'],
'pl' => ['iso' => 'pl_PL', 'windows' => 'Polish'],
'pt' => ['iso' => 'pt_PT', 'windows' => 'Portuguese'],
'pt_BR' => ['iso' => 'pt_BR', 'windows' => 'Portuguese'],
'ro' => ['iso' => 'ro_RO', 'windows' => 'Romanian'],
'ru' => ['iso' => 'ru', 'windows' => 'Russian'],
'sk' => ['iso' => 'sk_SK', 'windows' => 'Slovak'],
'sl' => ['iso' => 'sl_SI', 'windows' => 'Slovenian'],
'sv' => ['iso' => 'sv_SE', 'windows' => 'Swedish'],
'uk' => ['iso' => 'uk_UA', 'windows' => 'Ukrainian'],
'vi' => ['iso' => 'vi_VN', 'windows' => 'Vietnamese'],
'zh_CN' => ['iso' => 'zh_CN', 'windows' => 'Chinese (Simplified)'],
'zh_TW' => ['iso' => 'zh_TW', 'windows' => 'Chinese (Traditional)'],
'tr' => ['iso' => 'tr_TR', 'windows' => 'Turkish'],
];
/**
* Get the language specifically for the currently logged-in user if available.
*/
public function getUserLanguage(Request $request, string $default): string
{
try {
$user = user();
} catch (\Exception $exception) {
return $default;
}
if ($user->isDefault() && config('app.auto_detect_locale')) {
return $this->autoDetectLocale($request, $default);
}
return setting()->getUser($user, 'language', $default);
}
/**
* Check if the given BookStack language value is a right-to-left language.
*/
public function isRTL(string $language): bool
{
return in_array($language, $this->rtlLanguages);
}
/**
* Autodetect the visitors locale by matching locales in their headers
* against the locales supported by BookStack.
*/
protected function autoDetectLocale(Request $request, string $default): string
{
$availableLocales = config('app.locales');
foreach ($request->getLanguages() as $lang) {
if (in_array($lang, $availableLocales)) {
return $lang;
}
}
return $default;
}
/**
* Get the ISO version of a BookStack language name.
*/
public function getIsoName(string $language): string
{
return $this->localeMap[$language]['iso'] ?? $language;
}
/**
* Set the system date locale for localized date formatting.
* Will try both the standard locale name and the UTF8 variant.
*/
public function setPhpDateTimeLocale(string $language): void
{
$isoLang = $this->localeMap[$language]['iso'] ?? '';
$isoLangPrefix = explode('_', $isoLang)[0];
$locales = array_values(array_filter([
$isoLang ? $isoLang . '.utf8' : false,
$isoLang ?: false,
$isoLang ? str_replace('_', '-', $isoLang) : false,
$isoLang ? $isoLangPrefix . '.UTF-8' : false,
$this->localeMap[$language]['windows'] ?? false,
$language,
]));
if (!empty($locales)) {
setlocale(LC_TIME, $locales[0], ...array_slice($locales, 1));
}
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace BookStack\Translation;
class LocaleDefinition
{
public function __construct(
protected string $appName,
protected string $isoName,
protected bool $isRtl
) {
}
/**
* Provide the BookStack-specific locale name.
*/
public function appLocale(): string
{
return $this->appName;
}
/**
* Provide the ISO-aligned locale name.
*/
public function isoLocale(): string
{
return $this->isoName;
}
/**
* Returns a string suitable for the HTML "lang" attribute.
*/
public function htmlLang(): string
{
return str_replace('_', '-', $this->isoName);
}
/**
* Returns a string suitable for the HTML "dir" attribute.
*/
public function htmlDirection(): string
{
return $this->isRtl ? 'rtl' : 'ltr';
}
/**
* Translate using this locate.
*/
public function trans(string $key, array $replace = []): string
{
return trans($key, $replace, $this->appLocale());
}
}

View File

@ -0,0 +1,119 @@
<?php
namespace BookStack\Translation;
use BookStack\Users\Models\User;
use Illuminate\Http\Request;
class LocaleManager
{
/**
* Array of right-to-left locale options.
*/
protected array $rtlLocales = ['ar', 'fa', 'he'];
/**
* Map of BookStack locale names to best-estimate ISO locale names.
* Locales can often be found by running `locale -a` on a linux system.
*
* @var array<string, string>
*/
protected array $localeMap = [
'ar' => 'ar',
'bg' => 'bg_BG',
'bs' => 'bs_BA',
'ca' => 'ca',
'cs' => 'cs_CZ',
'cy' => 'cy_GB',
'da' => 'da_DK',
'de' => 'de_DE',
'de_informal' => 'de_DE',
'el' => 'el_GR',
'en' => 'en_GB',
'es' => 'es_ES',
'es_AR' => 'es_AR',
'et' => 'et_EE',
'eu' => 'eu_ES',
'fa' => 'fa_IR',
'fr' => 'fr_FR',
'he' => 'he_IL',
'hr' => 'hr_HR',
'hu' => 'hu_HU',
'id' => 'id_ID',
'it' => 'it_IT',
'ja' => 'ja',
'ka' => 'ka_GE',
'ko' => 'ko_KR',
'lt' => 'lt_LT',
'lv' => 'lv_LV',
'nb' => 'nb_NO',
'nl' => 'nl_NL',
'pl' => 'pl_PL',
'pt' => 'pt_PT',
'pt_BR' => 'pt_BR',
'ro' => 'ro_RO',
'ru' => 'ru',
'sk' => 'sk_SK',
'sl' => 'sl_SI',
'sv' => 'sv_SE',
'tr' => 'tr_TR',
'uk' => 'uk_UA',
'uz' => 'uz_UZ',
'vi' => 'vi_VN',
'zh_CN' => 'zh_CN',
'zh_TW' => 'zh_TW',
];
/**
* Get the BookStack locale string for the given user.
*/
protected function getLocaleForUser(User $user): string
{
$default = config('app.default_locale');
if ($user->isGuest() && config('app.auto_detect_locale')) {
return $this->autoDetectLocale(request(), $default);
}
return setting()->getUser($user, 'language', $default);
}
/**
* Get a locale definition for the current user.
*/
public function getForUser(User $user): LocaleDefinition
{
$localeString = $this->getLocaleForUser($user);
return new LocaleDefinition(
$localeString,
$this->localeMap[$localeString] ?? $localeString,
in_array($localeString, $this->rtlLocales),
);
}
/**
* Autodetect the visitors locale by matching locales in their headers
* against the locales supported by BookStack.
*/
protected function autoDetectLocale(Request $request, string $default): string
{
$availableLocales = $this->getAllAppLocales();
foreach ($request->getLanguages() as $lang) {
if (in_array($lang, $availableLocales)) {
return $lang;
}
}
return $default;
}
/**
* Get all the available app-specific level locale strings.
*/
public function getAllAppLocales(): array
{
return array_keys($this->localeMap);
}
}

View File

@ -1,38 +0,0 @@
<?php
namespace BookStack\Uploads;
use BookStack\Exceptions\HttpFetchException;
class HttpFetcher
{
/**
* Fetch content from an external URI.
*
* @param string $uri
*
* @throws HttpFetchException
*
* @return bool|string
*/
public function fetch(string $uri)
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $uri,
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$data = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err) {
$errno = curl_errno($ch);
throw new HttpFetchException($err, $errno);
}
return $data;
}
}

View File

@ -3,20 +3,20 @@
namespace BookStack\Uploads;
use BookStack\Exceptions\HttpFetchException;
use BookStack\Http\HttpRequestService;
use BookStack\Users\Models\User;
use Exception;
use GuzzleHttp\Psr7\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Psr\Http\Client\ClientExceptionInterface;
class UserAvatars
{
protected $imageService;
protected $http;
public function __construct(ImageService $imageService, HttpFetcher $http)
{
$this->imageService = $imageService;
$this->http = $http;
public function __construct(
protected ImageService $imageService,
protected HttpRequestService $http
) {
}
/**
@ -56,7 +56,7 @@ class UserAvatars
/**
* Destroy all user avatars uploaded to the given user.
*/
public function destroyAllForUser(User $user)
public function destroyAllForUser(User $user): void
{
$profileImages = Image::query()->where('type', '=', 'user')
->where('uploaded_to', '=', $user->id)
@ -70,7 +70,7 @@ class UserAvatars
/**
* Save an avatar image from an external service.
*
* @throws Exception
* @throws HttpFetchException
*/
protected function saveAvatarImage(User $user, int $size = 500): Image
{
@ -112,28 +112,32 @@ class UserAvatars
protected function getAvatarImageData(string $url): string
{
try {
$imageData = $this->http->fetch($url);
} catch (HttpFetchException $exception) {
$client = $this->http->buildClient(5);
$response = $client->sendRequest(new Request('GET', $url));
if ($response->getStatusCode() !== 200) {
throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]));
}
return (string) $response->getBody();
} catch (ClientExceptionInterface $exception) {
throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]), $exception->getCode(), $exception);
}
return $imageData;
}
/**
* Check if fetching external avatars is enabled.
*/
protected function avatarFetchEnabled(): bool
public function avatarFetchEnabled(): bool
{
$fetchUrl = $this->getAvatarUrl();
return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
return str_starts_with($fetchUrl, 'http');
}
/**
* Get the URL to fetch avatars from.
*/
protected function getAvatarUrl(): string
public function getAvatarUrl(): string
{
$configOption = config('services.avatar_url');
if ($configOption === false) {

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ class UserSearchController extends Controller
*/
public function forSelect(Request $request)
{
$hasPermission = signedInUser() && (
$hasPermission = !user()->isGuest() && (
userCan('users-manage')
|| userCan('restrictions-manage-own')
|| userCan('restrictions-manage-all')

View File

@ -3,14 +3,17 @@
namespace BookStack\Users\Models;
use BookStack\Access\Mfa\MfaValue;
use BookStack\Access\Notifications\ResetPasswordNotification;
use BookStack\Access\SocialAccount;
use BookStack\Activity\Models\Favourite;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Watch;
use BookStack\Api\ApiToken;
use BookStack\App\Model;
use BookStack\App\Sluggable;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Notifications\ResetPassword;
use BookStack\Translation\LocaleDefinition;
use BookStack\Translation\LocaleManager;
use BookStack\Uploads\Image;
use Carbon\Carbon;
use Exception;
@ -86,35 +89,31 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected string $avatarUrl = '';
/**
* This holds the default user when loaded.
*
* @var null|User
*/
protected static ?User $defaultUser = null;
/**
* Returns the default public user.
* Fetches from the container as a singleton to effectively cache at an app level.
*/
public static function getDefault(): self
public static function getGuest(): self
{
if (!is_null(static::$defaultUser)) {
return static::$defaultUser;
}
static::$defaultUser = static::query()->where('system_name', '=', 'public')->first();
return static::$defaultUser;
return app()->make('users.default');
}
/**
* Check if the user is the default public user.
*/
public function isDefault(): bool
public function isGuest(): bool
{
return $this->system_name === 'public';
}
/**
* Check if the user has general access to the application.
*/
public function hasAppAccess(): bool
{
return !$this->isGuest() || setting('app-public');
}
/**
* The roles that belong to the user.
*
@ -287,6 +286,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $this->hasMany(MfaValue::class);
}
/**
* Get the tracked entity watches for this user.
*/
public function watches(): HasMany
{
return $this->hasMany(Watch::class);
}
/**
* Get the last activity time for this user.
*/
@ -332,7 +339,15 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $splitName[0];
}
return '';
return mb_substr($this->name, 0, max($chars - 2, 0)) . '…';
}
/**
* Get the locale for this user.
*/
public function getLocale(): LocaleDefinition
{
return app()->make(LocaleManager::class)->getForUser($this);
}
/**
@ -344,7 +359,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function sendPasswordResetNotification($token)
{
$this->notify(new ResetPassword($token));
$this->notify(new ResetPasswordNotification($token));
}
/**
@ -360,7 +375,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function refreshSlug(): string
{
$this->slug = app(SlugGenerator::class)->generate($this);
$this->slug = app()->make(SlugGenerator::class)->generate($this);
return $this->slug;
}

View File

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

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