Merge branch 'master' into release
This commit is contained in:
commit
bb455d7788
|
@ -100,8 +100,7 @@ MEMCACHED_SERVERS=127.0.0.1:11211:100
|
|||
REDIS_SERVERS=127.0.0.1:6379:0
|
||||
|
||||
# Queue driver to use
|
||||
# Queue not really currently used but may be configurable in the future.
|
||||
# Would advise not to change this for now.
|
||||
# Can be 'sync', 'database' or 'redis'
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# Storage system to use
|
||||
|
|
|
@ -126,7 +126,7 @@ Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
|
|||
tatsuya.info :: Japanese
|
||||
fadiapp :: Arabic
|
||||
Jakub Bouček (jakubboucek) :: Czech
|
||||
Marco (cdrfun) :: German
|
||||
Marco (cdrfun) :: German; German Informal
|
||||
10935336 :: Chinese Simplified
|
||||
孟繁阳 (FanyangMeng) :: Chinese Simplified
|
||||
Andrej Močan (andrejm) :: Slovenian
|
||||
|
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ActivityLogger
|
||||
{
|
||||
protected $permissionService;
|
||||
|
||||
public function __construct(PermissionService $permissionService)
|
||||
{
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a generic activity event to the database.
|
||||
*
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
public function add(string $type, $detail = '')
|
||||
{
|
||||
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
|
||||
|
||||
$activity = $this->newActivityForUser($type);
|
||||
$activity->detail = $detailToStore;
|
||||
|
||||
if ($detail instanceof Entity) {
|
||||
$activity->entity_id = $detail->id;
|
||||
$activity->entity_type = $detail->getMorphClass();
|
||||
}
|
||||
|
||||
$activity->save();
|
||||
$this->setNotification($type);
|
||||
$this->dispatchWebhooks($type, $detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new activity instance for the current user.
|
||||
*/
|
||||
protected function newActivityForUser(string $type): Activity
|
||||
{
|
||||
$ip = request()->ip() ?? '';
|
||||
|
||||
return (new Activity())->forceFill([
|
||||
'type' => strtolower($type),
|
||||
'user_id' => user()->id,
|
||||
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the entity attachment from each of its activities
|
||||
* and instead uses the 'extra' field with the entities name.
|
||||
* Used when an entity is deleted.
|
||||
*/
|
||||
public function removeEntity(Entity $entity)
|
||||
{
|
||||
$entity->activity()->update([
|
||||
'detail' => $entity->name,
|
||||
'entity_id' => null,
|
||||
'entity_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashes a notification message to the session if an appropriate message is available.
|
||||
*/
|
||||
protected function setNotification(string $type): void
|
||||
{
|
||||
$notificationTextKey = 'activities.' . $type . '_notification';
|
||||
if (trans()->has($notificationTextKey)) {
|
||||
$message = trans($notificationTextKey);
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
protected function dispatchWebhooks(string $type, $detail): void
|
||||
{
|
||||
$webhooks = Webhook::query()
|
||||
->whereHas('trackedEvents', function (Builder $query) use ($type) {
|
||||
$query->where('event', '=', $type)
|
||||
->orWhere('event', '=', 'all');
|
||||
})
|
||||
->where('active', '=', true)
|
||||
->get();
|
||||
|
||||
foreach ($webhooks as $webhook) {
|
||||
dispatch(new DispatchWebhookJob($webhook, $type, $detail));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out a failed login attempt, Providing the given username
|
||||
* as part of the message if the '%u' string is used.
|
||||
*/
|
||||
public function logFailedLogin(string $username)
|
||||
{
|
||||
$message = config('logging.failed_login.message');
|
||||
if (!$message) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = str_replace('%u', $username, $message);
|
||||
$channel = config('logging.failed_login.channel');
|
||||
Log::channel($channel)->warning($message);
|
||||
}
|
||||
}
|
|
@ -8,84 +8,25 @@ use BookStack\Entities\Models\Book;
|
|||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ActivityService
|
||||
class ActivityQueries
|
||||
{
|
||||
protected $activity;
|
||||
protected $permissionService;
|
||||
|
||||
public function __construct(Activity $activity, PermissionService $permissionService)
|
||||
public function __construct(PermissionService $permissionService)
|
||||
{
|
||||
$this->activity = $activity;
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add activity data to database for an entity.
|
||||
*/
|
||||
public function addForEntity(Entity $entity, string $type)
|
||||
{
|
||||
$activity = $this->newActivityForUser($type);
|
||||
$entity->activity()->save($activity);
|
||||
$this->setNotification($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a generic activity event to the database.
|
||||
*
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
public function add(string $type, $detail = '')
|
||||
{
|
||||
if ($detail instanceof Loggable) {
|
||||
$detail = $detail->logDescriptor();
|
||||
}
|
||||
|
||||
$activity = $this->newActivityForUser($type);
|
||||
$activity->detail = $detail;
|
||||
$activity->save();
|
||||
$this->setNotification($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new activity instance for the current user.
|
||||
*/
|
||||
protected function newActivityForUser(string $type): Activity
|
||||
{
|
||||
$ip = request()->ip() ?? '';
|
||||
|
||||
return $this->activity->newInstance()->forceFill([
|
||||
'type' => strtolower($type),
|
||||
'user_id' => user()->id,
|
||||
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the entity attachment from each of its activities
|
||||
* and instead uses the 'extra' field with the entities name.
|
||||
* Used when an entity is deleted.
|
||||
*/
|
||||
public function removeEntity(Entity $entity)
|
||||
{
|
||||
$entity->activity()->update([
|
||||
'detail' => $entity->name,
|
||||
'entity_id' => null,
|
||||
'entity_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest activity.
|
||||
*/
|
||||
public function latest(int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
|
||||
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->with(['user', 'entity'])
|
||||
->skip($count * $page)
|
||||
|
@ -111,7 +52,7 @@ class ActivityService
|
|||
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
|
||||
}
|
||||
|
||||
$query = $this->activity->newQuery();
|
||||
$query = Activity::query();
|
||||
$query->where(function (Builder $query) use ($queryIds) {
|
||||
foreach ($queryIds as $morphClass => $idArr) {
|
||||
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
|
||||
|
@ -138,7 +79,7 @@ class ActivityService
|
|||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
|
||||
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->where('user_id', '=', $user->id)
|
||||
->skip($count * $page)
|
||||
|
@ -152,8 +93,6 @@ class ActivityService
|
|||
* Filters out similar activity.
|
||||
*
|
||||
* @param Activity[] $activities
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function filterSimilar(iterable $activities): array
|
||||
{
|
||||
|
@ -170,32 +109,4 @@ class ActivityService
|
|||
|
||||
return $newActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashes a notification message to the session if an appropriate message is available.
|
||||
*/
|
||||
protected function setNotification(string $type)
|
||||
{
|
||||
$notificationTextKey = 'activities.' . $type . '_notification';
|
||||
if (trans()->has($notificationTextKey)) {
|
||||
$message = trans($notificationTextKey);
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out a failed login attempt, Providing the given username
|
||||
* as part of the message if the '%u' string is used.
|
||||
*/
|
||||
public function logFailedLogin(string $username)
|
||||
{
|
||||
$message = config('logging.failed_login.message');
|
||||
if (!$message) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = str_replace('%u', $username, $message);
|
||||
$channel = config('logging.failed_login.channel');
|
||||
Log::channel($channel)->warning($message);
|
||||
}
|
||||
}
|
|
@ -53,4 +53,16 @@ class ActivityType
|
|||
|
||||
const MFA_SETUP_METHOD = 'mfa_setup_method';
|
||||
const MFA_REMOVE_METHOD = 'mfa_remove_method';
|
||||
|
||||
const WEBHOOK_CREATE = 'webhook_create';
|
||||
const WEBHOOK_UPDATE = 'webhook_update';
|
||||
const WEBHOOK_DELETE = 'webhook_delete';
|
||||
|
||||
/**
|
||||
* Get all the possible values.
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return (new \ReflectionClass(static::class))->getConstants();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,7 +45,7 @@ class CommentRepo
|
|||
$comment->parent_id = $parent_id;
|
||||
|
||||
$entity->comments()->save($comment);
|
||||
ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
|
||||
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
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\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DispatchWebhookJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* @var Webhook
|
||||
*/
|
||||
protected $webhook;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $event;
|
||||
|
||||
/**
|
||||
* @var string|Loggable
|
||||
*/
|
||||
protected $detail;
|
||||
|
||||
/**
|
||||
* @var User
|
||||
*/
|
||||
protected $initiator;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $initiatedTime;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Webhook $webhook, string $event, $detail)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
$this->event = $event;
|
||||
$this->detail = $detail;
|
||||
$this->initiator = user();
|
||||
$this->initiatedTime = time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$response = Http::asJson()
|
||||
->withOptions(['allow_redirects' => ['strict' => true]])
|
||||
->timeout(3)
|
||||
->post($this->webhook->endpoint, $this->buildWebhookData());
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
|
||||
}
|
||||
}
|
||||
|
||||
protected function buildWebhookData(): array
|
||||
{
|
||||
$textParts = [
|
||||
$this->initiator->name,
|
||||
trans('activities.' . $this->event),
|
||||
];
|
||||
|
||||
if ($this->detail instanceof Entity) {
|
||||
$textParts[] = '"' . $this->detail->name . '"';
|
||||
}
|
||||
|
||||
$data = [
|
||||
'event' => $this->event,
|
||||
'text' => implode(' ', $textParts),
|
||||
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
|
||||
'triggered_by' => $this->initiator->attributesToArray(),
|
||||
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
|
||||
'webhook_id' => $this->webhook->id,
|
||||
'webhook_name' => $this->webhook->name,
|
||||
];
|
||||
|
||||
if (method_exists($this->detail, 'getUrl')) {
|
||||
$data['url'] = $this->detail->getUrl();
|
||||
}
|
||||
|
||||
if ($this->detail instanceof Model) {
|
||||
$data['related_item'] = $this->detail->attributesToArray();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $endpoint
|
||||
* @property Collection $trackedEvents
|
||||
* @property bool $active
|
||||
*/
|
||||
class Webhook extends Model implements Loggable
|
||||
{
|
||||
protected $fillable = ['name', 'endpoint'];
|
||||
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Define the tracked event relation a webhook.
|
||||
*/
|
||||
public function trackedEvents(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebhookTrackedEvent::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the tracked events for a webhook from the given list of event types.
|
||||
*/
|
||||
public function updateTrackedEvents(array $events): void
|
||||
{
|
||||
$this->trackedEvents()->delete();
|
||||
|
||||
$eventsToStore = array_intersect($events, array_values(ActivityType::all()));
|
||||
if (in_array('all', $events)) {
|
||||
$eventsToStore = ['all'];
|
||||
}
|
||||
|
||||
$trackedEvents = [];
|
||||
foreach ($eventsToStore as $event) {
|
||||
$trackedEvents[] = new WebhookTrackedEvent(['event' => $event]);
|
||||
}
|
||||
|
||||
$this->trackedEvents()->saveMany($trackedEvents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this webhook tracks the given event.
|
||||
*/
|
||||
public function tracksEvent(string $event): bool
|
||||
{
|
||||
return $this->trackedEvents->pluck('event')->contains($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a URL for this webhook within the settings interface.
|
||||
*/
|
||||
public function getUrl(string $path = ''): string
|
||||
{
|
||||
return url('/settings/webhooks/' . $this->id . '/' . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string descriptor for this item.
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $webhook_id
|
||||
* @property string $event
|
||||
*/
|
||||
class WebhookTrackedEvent extends Model
|
||||
{
|
||||
protected $fillable = ['event'];
|
||||
|
||||
use HasFactory;
|
||||
}
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
|
@ -218,14 +217,6 @@ class UserRepo
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest activity for a user.
|
||||
*/
|
||||
public function getActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
return Activity::userActivity($user, $count, $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recently created content for this given user.
|
||||
*/
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
return [
|
||||
|
||||
// Default driver to use for the queue
|
||||
// Options: null, sync, redis
|
||||
// Options: sync, database, redis
|
||||
'default' => env('QUEUE_CONNECTION', 'sync'),
|
||||
|
||||
// Queue connection configuration
|
||||
|
|
|
@ -4,6 +4,9 @@ namespace BookStack\Console\Commands;
|
|||
|
||||
use BookStack\Auth\UserRepo;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
use Symfony\Component\Console\Command\Command as SymfonyCommand;
|
||||
|
||||
class CreateAdmin extends Command
|
||||
|
@ -45,43 +48,33 @@ class CreateAdmin extends Command
|
|||
*/
|
||||
public function handle()
|
||||
{
|
||||
$email = trim($this->option('email'));
|
||||
if (empty($email)) {
|
||||
$email = $this->ask('Please specify an email address for the new admin user');
|
||||
$details = $this->options();
|
||||
|
||||
if (empty($details['email'])) {
|
||||
$details['email'] = $this->ask('Please specify an email address for the new admin user');
|
||||
}
|
||||
if (empty($details['name'])) {
|
||||
$details['name'] = $this->ask('Please specify a name for the new admin user');
|
||||
}
|
||||
if (empty($details['password'])) {
|
||||
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
|
||||
}
|
||||
|
||||
$validator = Validator::make($details, [
|
||||
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
|
||||
'name' => ['required', 'min:2'],
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
foreach ($validator->errors()->all() as $error) {
|
||||
$this->error($error);
|
||||
}
|
||||
if (mb_strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->error('Invalid email address provided');
|
||||
|
||||
return SymfonyCommand::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->userRepo->getByEmail($email) !== null) {
|
||||
$this->error('A user with the provided email already exists!');
|
||||
|
||||
return SymfonyCommand::FAILURE;
|
||||
}
|
||||
|
||||
$name = trim($this->option('name'));
|
||||
if (empty($name)) {
|
||||
$name = $this->ask('Please specify an name for the new admin user');
|
||||
}
|
||||
if (mb_strlen($name) < 2) {
|
||||
$this->error('Invalid name provided');
|
||||
|
||||
return SymfonyCommand::FAILURE;
|
||||
}
|
||||
|
||||
$password = trim($this->option('password'));
|
||||
if (empty($password)) {
|
||||
$password = $this->secret('Please specify a password for the new admin user');
|
||||
}
|
||||
if (mb_strlen($password) < 5) {
|
||||
$this->error('Invalid password provided, Must be at least 5 characters');
|
||||
|
||||
return SymfonyCommand::FAILURE;
|
||||
}
|
||||
|
||||
$user = $this->userRepo->create(['email' => $email, 'name' => $name, 'password' => $password]);
|
||||
$user = $this->userRepo->create($validator->validated());
|
||||
$this->userRepo->attachSystemRole($user, 'admin');
|
||||
$this->userRepo->downloadAndAssignUserAvatar($user);
|
||||
$user->email_confirmed = true;
|
||||
|
|
|
@ -18,7 +18,7 @@ class Chapter extends BookChild
|
|||
|
||||
public $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'priority', 'book_id'];
|
||||
protected $fillable = ['name', 'description', 'priority'];
|
||||
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
|
||||
|
||||
/**
|
||||
|
|
|
@ -14,6 +14,7 @@ use BookStack\Entities\Tools\SlugGenerator;
|
|||
use BookStack\Facades\Permissions;
|
||||
use BookStack\Interfaces\Deletable;
|
||||
use BookStack\Interfaces\Favouritable;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Interfaces\Sluggable;
|
||||
use BookStack\Interfaces\Viewable;
|
||||
use BookStack\Model;
|
||||
|
@ -45,7 +46,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @method static Builder withLastView()
|
||||
* @method static Builder withViewCount()
|
||||
*/
|
||||
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable
|
||||
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
|
||||
{
|
||||
use SoftDeletes;
|
||||
use HasCreatorAndUpdater;
|
||||
|
@ -321,4 +322,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
|||
->where('user_id', '=', user()->id)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -91,7 +91,7 @@ class BookRepo
|
|||
{
|
||||
$book = new Book();
|
||||
$this->baseRepo->create($book, $input);
|
||||
Activity::addForEntity($book, ActivityType::BOOK_CREATE);
|
||||
Activity::add(ActivityType::BOOK_CREATE, $book);
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
@ -102,7 +102,7 @@ class BookRepo
|
|||
public function update(Book $book, array $input): Book
|
||||
{
|
||||
$this->baseRepo->update($book, $input);
|
||||
Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
|
||||
Activity::add(ActivityType::BOOK_UPDATE, $book);
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
@ -127,7 +127,7 @@ class BookRepo
|
|||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyBook($book);
|
||||
Activity::addForEntity($book, ActivityType::BOOK_DELETE);
|
||||
Activity::add(ActivityType::BOOK_DELETE, $book);
|
||||
|
||||
$trashCan->autoClearOld();
|
||||
}
|
||||
|
|
|
@ -90,7 +90,7 @@ class BookshelfRepo
|
|||
$shelf = new Bookshelf();
|
||||
$this->baseRepo->create($shelf, $input);
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
|
||||
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
@ -106,7 +106,7 @@ class BookshelfRepo
|
|||
$this->updateBooks($shelf, $bookIds);
|
||||
}
|
||||
|
||||
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
|
||||
Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf);
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
@ -177,7 +177,7 @@ class BookshelfRepo
|
|||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyShelf($shelf);
|
||||
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE);
|
||||
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
|
||||
$trashCan->autoClearOld();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ namespace BookStack\Entities\Repos;
|
|||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
|
@ -49,7 +50,7 @@ class ChapterRepo
|
|||
$chapter->book_id = $parentBook->id;
|
||||
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
|
||||
$this->baseRepo->create($chapter, $input);
|
||||
Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE);
|
||||
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
|
@ -60,7 +61,7 @@ class ChapterRepo
|
|||
public function update(Chapter $chapter, array $input): Chapter
|
||||
{
|
||||
$this->baseRepo->update($chapter, $input);
|
||||
Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
|
||||
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
|
@ -74,7 +75,7 @@ class ChapterRepo
|
|||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyChapter($chapter);
|
||||
Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE);
|
||||
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
|
||||
$trashCan->autoClearOld();
|
||||
}
|
||||
|
||||
|
@ -87,24 +88,36 @@ class ChapterRepo
|
|||
*/
|
||||
public function move(Chapter $chapter, string $parentIdentifier): Book
|
||||
{
|
||||
$stringExploded = explode(':', $parentIdentifier);
|
||||
$entityType = $stringExploded[0];
|
||||
$entityId = intval($stringExploded[1]);
|
||||
|
||||
if ($entityType !== 'book') {
|
||||
throw new MoveOperationException('Chapters can only be moved into books');
|
||||
}
|
||||
|
||||
/** @var Book $parent */
|
||||
$parent = Book::visible()->where('id', '=', $entityId)->first();
|
||||
if ($parent === null) {
|
||||
$parent = $this->findParentByIdentifier($parentIdentifier);
|
||||
if (is_null($parent)) {
|
||||
throw new MoveOperationException('Book to move chapter into not found');
|
||||
}
|
||||
|
||||
$chapter->changeBook($parent->id);
|
||||
$chapter->rebuildPermissions();
|
||||
Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE);
|
||||
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
||||
|
||||
return $parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a page parent entity via an identifier string in the format:
|
||||
* {type}:{id}
|
||||
* Example: (book:5).
|
||||
*
|
||||
* @throws MoveOperationException
|
||||
*/
|
||||
public function findParentByIdentifier(string $identifier): ?Book
|
||||
{
|
||||
$stringExploded = explode(':', $identifier);
|
||||
$entityType = $stringExploded[0];
|
||||
$entityId = intval($stringExploded[1]);
|
||||
|
||||
if ($entityType !== 'book') {
|
||||
throw new MoveOperationException('Chapters can only be in books');
|
||||
}
|
||||
|
||||
return Book::visible()->where('id', '=', $entityId)->first();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -171,7 +171,7 @@ class PageRepo
|
|||
$draft->indexForSearch();
|
||||
$draft->refresh();
|
||||
|
||||
Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
|
||||
Activity::add(ActivityType::PAGE_CREATE, $draft);
|
||||
|
||||
return $draft;
|
||||
}
|
||||
|
@ -205,7 +205,7 @@ class PageRepo
|
|||
$this->savePageRevision($page, $summary);
|
||||
}
|
||||
|
||||
Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
|
||||
Activity::add(ActivityType::PAGE_UPDATE, $page);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
@ -281,7 +281,7 @@ class PageRepo
|
|||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyPage($page);
|
||||
Activity::addForEntity($page, ActivityType::PAGE_DELETE);
|
||||
Activity::add(ActivityType::PAGE_DELETE, $page);
|
||||
$trashCan->autoClearOld();
|
||||
}
|
||||
|
||||
|
@ -312,7 +312,7 @@ class PageRepo
|
|||
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
|
||||
$this->savePageRevision($page, $summary);
|
||||
|
||||
Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
|
||||
Activity::add(ActivityType::PAGE_RESTORE, $page);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
@ -341,56 +341,19 @@ class PageRepo
|
|||
$page->changeBook($newBookId);
|
||||
$page->rebuildPermissions();
|
||||
|
||||
Activity::addForEntity($page, ActivityType::PAGE_MOVE);
|
||||
Activity::add(ActivityType::PAGE_MOVE, $page);
|
||||
|
||||
return $parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an existing page in the system.
|
||||
* Optionally providing a new parent via string identifier and a new name.
|
||||
*
|
||||
* @throws MoveOperationException
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
|
||||
{
|
||||
$parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent();
|
||||
if ($parent === null) {
|
||||
throw new MoveOperationException('Book or chapter to move page into not found');
|
||||
}
|
||||
|
||||
if (!userCan('page-create', $parent)) {
|
||||
throw new PermissionsException('User does not have permission to create a page within the new parent');
|
||||
}
|
||||
|
||||
$copyPage = $this->getNewDraftPage($parent);
|
||||
$pageData = $page->getAttributes();
|
||||
|
||||
// Update name
|
||||
if (!empty($newName)) {
|
||||
$pageData['name'] = $newName;
|
||||
}
|
||||
|
||||
// Copy tags from previous page if set
|
||||
if ($page->tags) {
|
||||
$pageData['tags'] = [];
|
||||
foreach ($page->tags as $tag) {
|
||||
$pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->publishDraft($copyPage, $pageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a page parent entity via a identifier string in the format:
|
||||
* Find a page parent entity via an identifier string in the format:
|
||||
* {type}:{id}
|
||||
* Example: (book:5).
|
||||
*
|
||||
* @throws MoveOperationException
|
||||
*/
|
||||
protected function findParentByIdentifier(string $identifier): ?Entity
|
||||
public function findParentByIdentifier(string $identifier): ?Entity
|
||||
{
|
||||
$stringExploded = explode(':', $identifier);
|
||||
$entityType = $stringExploded[0];
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\Tag;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class Cloner
|
||||
{
|
||||
/**
|
||||
* @var PageRepo
|
||||
*/
|
||||
protected $pageRepo;
|
||||
|
||||
/**
|
||||
* @var ChapterRepo
|
||||
*/
|
||||
protected $chapterRepo;
|
||||
|
||||
/**
|
||||
* @var BookRepo
|
||||
*/
|
||||
protected $bookRepo;
|
||||
|
||||
/**
|
||||
* @var ImageService
|
||||
*/
|
||||
protected $imageService;
|
||||
|
||||
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
|
||||
{
|
||||
$this->pageRepo = $pageRepo;
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->imageService = $imageService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the given page into the given parent using the provided name.
|
||||
*/
|
||||
public function clonePage(Page $original, Entity $parent, string $newName): Page
|
||||
{
|
||||
$copyPage = $this->pageRepo->getNewDraftPage($parent);
|
||||
$pageData = $original->getAttributes();
|
||||
|
||||
// Update name & tags
|
||||
$pageData['name'] = $newName;
|
||||
$pageData['tags'] = $this->entityTagsToInputArray($original);
|
||||
|
||||
return $this->pageRepo->publishDraft($copyPage, $pageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the given page into the given parent using the provided name.
|
||||
* Clones all child pages.
|
||||
*/
|
||||
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
|
||||
{
|
||||
$chapterDetails = $original->getAttributes();
|
||||
$chapterDetails['name'] = $newName;
|
||||
$chapterDetails['tags'] = $this->entityTagsToInputArray($original);
|
||||
|
||||
$copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
|
||||
|
||||
if (userCan('page-create', $copyChapter)) {
|
||||
/** @var Page $page */
|
||||
foreach ($original->getVisiblePages() as $page) {
|
||||
$this->clonePage($page, $copyChapter, $page->name);
|
||||
}
|
||||
}
|
||||
|
||||
return $copyChapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the given book.
|
||||
* Clones all child chapters & pages.
|
||||
*/
|
||||
public function cloneBook(Book $original, string $newName): Book
|
||||
{
|
||||
$bookDetails = $original->getAttributes();
|
||||
$bookDetails['name'] = $newName;
|
||||
$bookDetails['tags'] = $this->entityTagsToInputArray($original);
|
||||
|
||||
$copyBook = $this->bookRepo->create($bookDetails);
|
||||
|
||||
$directChildren = $original->getDirectChildren();
|
||||
foreach ($directChildren as $child) {
|
||||
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
|
||||
$this->cloneChapter($child, $copyBook, $child->name);
|
||||
}
|
||||
|
||||
if ($child instanceof Page && !$child->draft && userCan('page-create', $copyBook)) {
|
||||
$this->clonePage($child, $copyBook, $child->name);
|
||||
}
|
||||
}
|
||||
|
||||
if ($original->cover) {
|
||||
try {
|
||||
$tmpImgFile = tmpfile();
|
||||
$uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile);
|
||||
$this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false);
|
||||
} catch (\Exception $exception) {
|
||||
}
|
||||
}
|
||||
|
||||
return $copyBook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an image instance to an UploadedFile instance to mimic
|
||||
* a file being uploaded.
|
||||
*/
|
||||
protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile
|
||||
{
|
||||
$imgData = $this->imageService->getImageData($image);
|
||||
$tmpImgFilePath = stream_get_meta_data($tmpFile)['uri'];
|
||||
file_put_contents($tmpImgFilePath, $imgData);
|
||||
|
||||
return new UploadedFile($tmpImgFilePath, basename($image->path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the tags on the given entity to the raw format
|
||||
* that's used for incoming request data.
|
||||
*/
|
||||
protected function entityTagsToInputArray(Entity $entity): array
|
||||
{
|
||||
$tags = [];
|
||||
|
||||
/** @var Tag $tag */
|
||||
foreach ($entity->tags as $tag) {
|
||||
$tags[] = ['name' => $tag->name, 'value' => $tag->value];
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
}
|
|
@ -35,7 +35,7 @@ class PermissionsUpdater
|
|||
$entity->save();
|
||||
$entity->rebuildPermissions();
|
||||
|
||||
Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE);
|
||||
Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -57,17 +57,17 @@ class SearchResultsFormatter
|
|||
protected function highlightTagsContainingTerms(array $tags, array $terms): void
|
||||
{
|
||||
foreach ($tags as $tag) {
|
||||
$tagName = strtolower($tag->name);
|
||||
$tagValue = strtolower($tag->value);
|
||||
$tagName = mb_strtolower($tag->name);
|
||||
$tagValue = mb_strtolower($tag->value);
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$termLower = strtolower($term);
|
||||
$termLower = mb_strtolower($term);
|
||||
|
||||
if (strpos($tagName, $termLower) !== false) {
|
||||
if (mb_strpos($tagName, $termLower) !== false) {
|
||||
$tag->setAttribute('highlight_name', true);
|
||||
}
|
||||
|
||||
if (strpos($tagValue, $termLower) !== false) {
|
||||
if (mb_strpos($tagValue, $termLower) !== false) {
|
||||
$tag->setAttribute('highlight_value', true);
|
||||
}
|
||||
}
|
||||
|
@ -84,17 +84,17 @@ class SearchResultsFormatter
|
|||
protected function getMatchPositions(string $text, array $terms): array
|
||||
{
|
||||
$matchRefs = [];
|
||||
$text = strtolower($text);
|
||||
$text = mb_strtolower($text);
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$offset = 0;
|
||||
$term = strtolower($term);
|
||||
$pos = strpos($text, $term, $offset);
|
||||
$term = mb_strtolower($term);
|
||||
$pos = mb_strpos($text, $term, $offset);
|
||||
while ($pos !== false) {
|
||||
$end = $pos + strlen($term);
|
||||
$end = $pos + mb_strlen($term);
|
||||
$matchRefs[$pos] = $end;
|
||||
$offset = $end;
|
||||
$pos = strpos($text, $term, $offset);
|
||||
$pos = mb_strpos($text, $term, $offset);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -141,7 +141,7 @@ class SearchResultsFormatter
|
|||
*/
|
||||
protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText, int $targetLength): string
|
||||
{
|
||||
$maxEnd = strlen($originalText);
|
||||
$maxEnd = mb_strlen($originalText);
|
||||
$fetchAll = ($targetLength === 0);
|
||||
$contextLength = ($fetchAll ? 0 : 32);
|
||||
|
||||
|
@ -165,7 +165,7 @@ class SearchResultsFormatter
|
|||
$contextStart = $start;
|
||||
// Trims off '$startDiff' number of characters to bring it back to the start
|
||||
// if this current match zone.
|
||||
$content = substr($content, 0, strlen($content) + $startDiff);
|
||||
$content = mb_substr($content, 0, mb_strlen($content) + $startDiff);
|
||||
$contentTextLength += $startDiff;
|
||||
}
|
||||
|
||||
|
@ -176,16 +176,16 @@ class SearchResultsFormatter
|
|||
} elseif ($fetchAll) {
|
||||
// Or fill in gap since the previous match
|
||||
$fillLength = $contextStart - $lastEnd;
|
||||
$content .= e(substr($originalText, $lastEnd, $fillLength));
|
||||
$content .= e(mb_substr($originalText, $lastEnd, $fillLength));
|
||||
$contentTextLength += $fillLength;
|
||||
}
|
||||
|
||||
// Add our content including the bolded matching text
|
||||
$content .= e(substr($originalText, $contextStart, $start - $contextStart));
|
||||
$content .= e(mb_substr($originalText, $contextStart, $start - $contextStart));
|
||||
$contentTextLength += $start - $contextStart;
|
||||
$content .= '<strong>' . e(substr($originalText, $start, $end - $start)) . '</strong>';
|
||||
$content .= '<strong>' . e(mb_substr($originalText, $start, $end - $start)) . '</strong>';
|
||||
$contentTextLength += $end - $start;
|
||||
$content .= e(substr($originalText, $end, $contextEnd - $end));
|
||||
$content .= e(mb_substr($originalText, $end, $contextEnd - $end));
|
||||
$contentTextLength += $contextEnd - $end;
|
||||
|
||||
// Update our last end position
|
||||
|
@ -204,7 +204,7 @@ class SearchResultsFormatter
|
|||
|
||||
// Just copy out the content if we haven't moved along anywhere.
|
||||
if ($lastEnd === 0) {
|
||||
$content = e(substr($originalText, 0, $targetLength));
|
||||
$content = e(mb_substr($originalText, 0, $targetLength));
|
||||
$contentTextLength = $targetLength;
|
||||
$lastEnd = $targetLength;
|
||||
}
|
||||
|
@ -213,7 +213,7 @@ class SearchResultsFormatter
|
|||
$remainder = $targetLength - $contentTextLength;
|
||||
if ($remainder > 10) {
|
||||
$padEndLength = min($maxEnd - $lastEnd, $remainder);
|
||||
$content .= e(substr($originalText, $lastEnd, $padEndLength));
|
||||
$content .= e(mb_substr($originalText, $lastEnd, $padEndLength));
|
||||
$lastEnd += $padEndLength;
|
||||
$contentTextLength += $padEndLength;
|
||||
}
|
||||
|
@ -223,7 +223,7 @@ class SearchResultsFormatter
|
|||
$firstStart = $firstStart ?: 0;
|
||||
if (!$fetchAll && $remainder > 10 && $firstStart !== 0) {
|
||||
$padStart = max(0, $firstStart - $remainder);
|
||||
$content = ($padStart === 0 ? '' : '...') . e(substr($originalText, $padStart, $firstStart - $padStart)) . substr($content, 4);
|
||||
$content = ($padStart === 0 ? '' : '...') . e(mb_substr($originalText, $padStart, $firstStart - $padStart)) . mb_substr($content, 4);
|
||||
}
|
||||
|
||||
// Add ellipsis if we're not at the end
|
||||
|
|
|
@ -4,6 +4,9 @@ namespace BookStack\Facades;
|
|||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
/**
|
||||
* @see \BookStack\Actions\ActivityLogger
|
||||
*/
|
||||
class Activity extends Facade
|
||||
{
|
||||
/**
|
||||
|
|
|
@ -20,6 +20,7 @@ class AuditLogController extends Controller
|
|||
'date_from' => $request->get('date_from', ''),
|
||||
'date_to' => $request->get('date_to', ''),
|
||||
'user' => $request->get('user', ''),
|
||||
'ip' => $request->get('ip', ''),
|
||||
];
|
||||
|
||||
$query = Activity::query()
|
||||
|
@ -44,6 +45,9 @@ class AuditLogController extends Controller
|
|||
if ($listDetails['date_to']) {
|
||||
$query->where('created_at', '<=', $listDetails['date_to']);
|
||||
}
|
||||
if ($listDetails['ip']) {
|
||||
$query->where('ip', 'like', $listDetails['ip'] . '%');
|
||||
}
|
||||
|
||||
$activities = $query->paginate(100);
|
||||
$activities->appends($listDetails);
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
|
@ -13,6 +13,7 @@ use Illuminate\Foundation\Auth\RegistersUsers;
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
|
@ -70,7 +71,7 @@ class RegisterController extends Controller
|
|||
return Validator::make($data, [
|
||||
'name' => ['required', 'min:2', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', 'min:8'],
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ use Exception;
|
|||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class UserInviteController extends Controller
|
||||
{
|
||||
|
@ -55,7 +56,7 @@ class UserInviteController extends Controller
|
|||
public function setPassword(Request $request, string $token)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'password' => ['required', 'min:8'],
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
|
||||
try {
|
||||
|
|
|
@ -2,15 +2,18 @@
|
|||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\View;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Entities\Tools\ShelfContext;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
@ -101,7 +104,7 @@ class BookController extends Controller
|
|||
|
||||
if ($bookshelf) {
|
||||
$bookshelf->appendBook($book);
|
||||
Activity::addForEntity($bookshelf, ActivityType::BOOKSHELF_UPDATE);
|
||||
Activity::add(ActivityType::BOOKSHELF_UPDATE, $bookshelf);
|
||||
}
|
||||
|
||||
return redirect($book->getUrl());
|
||||
|
@ -110,7 +113,7 @@ class BookController extends Controller
|
|||
/**
|
||||
* Display the specified book.
|
||||
*/
|
||||
public function show(Request $request, string $slug)
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$bookChildren = (new BookContents($book))->getTree(true);
|
||||
|
@ -128,7 +131,7 @@ class BookController extends Controller
|
|||
'current' => $book,
|
||||
'bookChildren' => $bookChildren,
|
||||
'bookParentShelves' => $bookParentShelves,
|
||||
'activity' => Activity::entityActivity($book, 20, 1),
|
||||
'activity' => $activities->entityActivity($book, 20, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -224,4 +227,39 @@ class BookController extends Controller
|
|||
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to copy a book.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function showCopy(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$this->checkOwnablePermission('book-view', $book);
|
||||
|
||||
session()->flashInput(['name' => $book->name]);
|
||||
|
||||
return view('books.copy', [
|
||||
'book' => $book,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy of a book within the requested target destination.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function copy(Request $request, Cloner $cloner, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$this->checkOwnablePermission('book-view', $book);
|
||||
$this->checkPermission('book-create-all');
|
||||
|
||||
$newName = $request->get('name') ?: $book->name;
|
||||
$bookCopy = $cloner->cloneBook($book, $newName);
|
||||
$this->showSuccessNotification(trans('entities.books_copy_success'));
|
||||
|
||||
return redirect($bookCopy->getUrl());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -71,7 +71,7 @@ class BookSortController extends Controller
|
|||
|
||||
// Rebuild permissions and add activity for involved books.
|
||||
$booksInvolved->each(function (Book $book) {
|
||||
Activity::addForEntity($book, ActivityType::BOOK_SORT);
|
||||
Activity::add(ActivityType::BOOK_SORT, $book);
|
||||
});
|
||||
|
||||
return redirect($book->getUrl());
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Actions\View;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
|
@ -101,7 +101,7 @@ class BookshelfController extends Controller
|
|||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function show(string $slug)
|
||||
public function show(ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$shelf = $this->bookshelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('book-view', $shelf);
|
||||
|
@ -124,7 +124,7 @@ class BookshelfController extends Controller
|
|||
'shelf' => $shelf,
|
||||
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
|
||||
'view' => $view,
|
||||
'activity' => Activity::entityActivity($shelf, 20, 1),
|
||||
'activity' => $activities->entityActivity($shelf, 20, 1),
|
||||
'order' => $order,
|
||||
'sort' => $sort,
|
||||
]);
|
||||
|
|
|
@ -6,6 +6,7 @@ use BookStack\Actions\View;
|
|||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
use BookStack\Entities\Tools\NextPreviousContentLocator;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
|
@ -190,6 +191,53 @@ class ChapterController extends Controller
|
|||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to copy a chapter.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function showCopy(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-view', $chapter);
|
||||
|
||||
session()->flashInput(['name' => $chapter->name]);
|
||||
|
||||
return view('chapters.copy', [
|
||||
'book' => $chapter->book,
|
||||
'chapter' => $chapter,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy of a chapter within the requested target destination.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-view', $chapter);
|
||||
|
||||
$entitySelection = $request->get('entity_selection') ?: null;
|
||||
$newParentBook = $entitySelection ? $this->chapterRepo->findParentByIdentifier($entitySelection) : $chapter->getParent();
|
||||
|
||||
if (is_null($newParentBook)) {
|
||||
$this->showErrorNotification(trans('errors.selected_book_not_found'));
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission('chapter-create', $newParentBook);
|
||||
|
||||
$newName = $request->get('name') ?: $chapter->name;
|
||||
$chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
|
||||
$this->showSuccessNotification(trans('entities.chapters_copy_success'));
|
||||
|
||||
return redirect($chapterCopy->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Restrictions view.
|
||||
*
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\RecentlyViewed;
|
||||
|
@ -16,9 +16,9 @@ class HomeController extends Controller
|
|||
/**
|
||||
* Display the homepage.
|
||||
*/
|
||||
public function index()
|
||||
public function index(ActivityQueries $activities)
|
||||
{
|
||||
$activity = Activity::latest(10);
|
||||
$activity = $activities->latest(10);
|
||||
$draftPages = [];
|
||||
|
||||
if ($this->isSignedIn()) {
|
||||
|
|
|
@ -67,7 +67,7 @@ class MaintenanceController extends Controller
|
|||
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
|
||||
|
||||
try {
|
||||
user()->notify(new TestEmail());
|
||||
user()->notifyNow(new TestEmail());
|
||||
$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();
|
||||
|
|
|
@ -6,6 +6,7 @@ use BookStack\Actions\View;
|
|||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
use BookStack\Entities\Tools\NextPreviousContentLocator;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditActivity;
|
||||
|
@ -447,26 +448,24 @@ class PageController extends Controller
|
|||
* @throws NotFoundException
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function copy(Request $request, string $bookSlug, string $pageSlug)
|
||||
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-view', $page);
|
||||
|
||||
$entitySelection = $request->get('entity_selection', null) ?? null;
|
||||
$newName = $request->get('name', null);
|
||||
|
||||
try {
|
||||
$pageCopy = $this->pageRepo->copy($page, $entitySelection, $newName);
|
||||
} catch (Exception $exception) {
|
||||
if ($exception instanceof PermissionsException) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
$entitySelection = $request->get('entity_selection') ?: null;
|
||||
$newParent = $entitySelection ? $this->pageRepo->findParentByIdentifier($entitySelection) : $page->getParent();
|
||||
|
||||
if (is_null($newParent)) {
|
||||
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission('page-create', $newParent);
|
||||
|
||||
$newName = $request->get('name') ?: $page->name;
|
||||
$pageCopy = $cloner->clonePage($page, $newParent, $newName);
|
||||
$this->showSuccessNotification(trans('entities.pages_copy_success'));
|
||||
|
||||
return redirect($pageCopy->getUrl());
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionsRepo;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
|
@ -23,7 +24,7 @@ class RoleController extends Controller
|
|||
/**
|
||||
* Show a listing of the roles in the system.
|
||||
*/
|
||||
public function list()
|
||||
public function index()
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$roles = $this->permissionsRepo->getAllRoles();
|
||||
|
@ -34,11 +35,21 @@ class RoleController extends Controller
|
|||
/**
|
||||
* Show the form to create a new role.
|
||||
*/
|
||||
public function create()
|
||||
public function create(Request $request)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
|
||||
return view('settings.roles.create');
|
||||
/** @var ?Role $role */
|
||||
$role = null;
|
||||
if ($request->has('copy_from')) {
|
||||
$role = Role::query()->find($request->get('copy_from'));
|
||||
}
|
||||
|
||||
if ($role) {
|
||||
$role->display_name .= ' (' . trans('common.copy') . ')';
|
||||
}
|
||||
|
||||
return view('settings.roles.create', ['role' => $role]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -49,7 +60,7 @@ class RoleController extends Controller
|
|||
$this->checkPermission('user-roles-manage');
|
||||
$this->validate($request, [
|
||||
'display_name' => ['required', 'min:3', 'max:180'],
|
||||
'description' => 'max:180',
|
||||
'description' => ['max:180'],
|
||||
]);
|
||||
|
||||
$this->permissionsRepo->saveNewRole($request->all());
|
||||
|
@ -84,7 +95,7 @@ class RoleController extends Controller
|
|||
$this->checkPermission('user-roles-manage');
|
||||
$this->validate($request, [
|
||||
'display_name' => ['required', 'min:3', 'max:180'],
|
||||
'description' => 'max:180',
|
||||
'description' => ['max:180'],
|
||||
]);
|
||||
|
||||
$this->permissionsRepo->updateRole($id, $request->all());
|
||||
|
|
|
@ -13,6 +13,7 @@ use BookStack\Uploads\ImageRepo;
|
|||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class UserController extends Controller
|
||||
|
@ -82,7 +83,7 @@ class UserController extends Controller
|
|||
$sendInvite = ($request->get('send_invite', 'false') === 'true');
|
||||
|
||||
if ($authMethod === 'standard' && !$sendInvite) {
|
||||
$validationRules['password'] = ['required', 'min:6'];
|
||||
$validationRules['password'] = ['required', Password::default()];
|
||||
$validationRules['password-confirm'] = ['required', 'same:password'];
|
||||
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
|
||||
$validationRules['external_auth_id'] = ['required'];
|
||||
|
@ -155,11 +156,11 @@ class UserController extends Controller
|
|||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
|
||||
$this->validate($request, [
|
||||
'name' => 'min:2',
|
||||
'name' => ['min:2'],
|
||||
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
|
||||
'password' => ['min:6', 'required_with:password_confirm'],
|
||||
'password' => ['required_with:password_confirm', Password::default()],
|
||||
'password-confirm' => ['same:password', 'required_with:password'],
|
||||
'setting' => 'array',
|
||||
'setting' => ['array'],
|
||||
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
]);
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Auth\UserRepo;
|
||||
|
||||
class UserProfileController extends Controller
|
||||
|
@ -9,11 +10,11 @@ class UserProfileController extends Controller
|
|||
/**
|
||||
* Show the user profile page.
|
||||
*/
|
||||
public function show(UserRepo $repo, string $slug)
|
||||
public function show(UserRepo $repo, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$user = $repo->getBySlug($slug);
|
||||
|
||||
$userActivity = $repo->getActivity($user);
|
||||
$userActivity = $activities->userActivity($user);
|
||||
$recentlyCreated = $repo->getRecentlyCreated($user, 5);
|
||||
$assetCounts = $repo->getAssetCounts($user);
|
||||
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\Webhook;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware([
|
||||
'can:settings-manage',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show all webhooks configured in the system.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$webhooks = Webhook::query()
|
||||
->orderBy('name', 'desc')
|
||||
->with('trackedEvents')
|
||||
->get();
|
||||
|
||||
return view('settings.webhooks.index', ['webhooks' => $webhooks]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view for creating a new webhook in the system.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return view('settings.webhooks.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new webhook in the system.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'max:150'],
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
'events' => ['required', 'array'],
|
||||
'active' => ['required'],
|
||||
]);
|
||||
|
||||
$webhook = new Webhook($validated);
|
||||
$webhook->active = $validated['active'] === 'true';
|
||||
$webhook->save();
|
||||
$webhook->updateTrackedEvents(array_values($validated['events']));
|
||||
|
||||
$this->logActivity(ActivityType::WEBHOOK_CREATE, $webhook);
|
||||
|
||||
return redirect('/settings/webhooks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to edit an existing webhook.
|
||||
*/
|
||||
public function edit(string $id)
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()
|
||||
->with('trackedEvents')
|
||||
->findOrFail($id);
|
||||
|
||||
return view('settings.webhooks.edit', ['webhook' => $webhook]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing webhook with the provided request data.
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'max:150'],
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
'events' => ['required', 'array'],
|
||||
'active' => ['required'],
|
||||
]);
|
||||
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->findOrFail($id);
|
||||
|
||||
$webhook->active = $validated['active'] === 'true';
|
||||
$webhook->fill($validated)->save();
|
||||
$webhook->updateTrackedEvents($validated['events']);
|
||||
|
||||
$this->logActivity(ActivityType::WEBHOOK_UPDATE, $webhook);
|
||||
|
||||
return redirect('/settings/webhooks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to delete a webhook.
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->findOrFail($id);
|
||||
|
||||
return view('settings.webhooks.delete', ['webhook' => $webhook]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a webhook from the system.
|
||||
*/
|
||||
public function destroy(string $id)
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->findOrFail($id);
|
||||
|
||||
$webhook->trackedEvents()->delete();
|
||||
$webhook->delete();
|
||||
|
||||
$this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook);
|
||||
|
||||
return redirect('/settings/webhooks');
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ use BookStack\Auth\Access\LoginService;
|
|||
use BookStack\Auth\Access\RegistrationService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
@ -21,6 +22,12 @@ class AuthServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function boot()
|
||||
{
|
||||
// Password Configuration
|
||||
Password::defaults(function () {
|
||||
return Password::min(8);
|
||||
});
|
||||
|
||||
// Custom guards
|
||||
Auth::extend('api-token', function ($app, $name, array $config) {
|
||||
return new ApiTokenGuard($app['request'], $app->make(LoginService::class));
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace BookStack\Providers;
|
||||
|
||||
use BookStack\Actions\ActivityService;
|
||||
use BookStack\Actions\ActivityLogger;
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Theming\ThemeService;
|
||||
use BookStack\Uploads\ImageService;
|
||||
|
@ -28,7 +28,7 @@ class CustomFacadeProvider extends ServiceProvider
|
|||
public function register()
|
||||
{
|
||||
$this->app->singleton('activity', function () {
|
||||
return $this->app->make(ActivityService::class);
|
||||
return $this->app->make(ActivityLogger::class);
|
||||
});
|
||||
|
||||
$this->app->singleton('images', function () {
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories\Actions;
|
||||
|
||||
use BookStack\Actions\Webhook;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class WebhookFactory extends Factory
|
||||
{
|
||||
protected $model = Webhook::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'name' => 'My webhook for ' . $this->faker->country(),
|
||||
'endpoint' => $this->faker->url,
|
||||
'active' => true,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories\Actions;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\Webhook;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class WebhookTrackedEventFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'webhook_id' => Webhook::factory(),
|
||||
'event' => ActivityType::all()[array_rand(ActivityType::all())],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Database\Factories\Auth;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
|
@ -12,7 +13,7 @@ class UserFactory extends Factory
|
|||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = \BookStack\Auth\User::class;
|
||||
protected $model = User::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
|
@ -26,7 +27,7 @@ class UserFactory extends Factory
|
|||
return [
|
||||
'name' => $name,
|
||||
'email' => $this->faker->email,
|
||||
'slug' => \Illuminate\Support\Str::slug($name . '-' . \Illuminate\Support\Str::random(5)),
|
||||
'slug' => Str::slug($name . '-' . Str::random(5)),
|
||||
'password' => Str::random(10),
|
||||
'remember_token' => Str::random(10),
|
||||
'email_confirmed' => 1,
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddIndexForUserIp extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
$table->index('ip', 'activities_ip_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
$table->dropIndex('activities_ip_index');
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateWebhooksTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('webhooks', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->string('name', 150);
|
||||
$table->boolean('active');
|
||||
$table->string('endpoint', 500);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('name');
|
||||
$table->index('active');
|
||||
});
|
||||
|
||||
Schema::create('webhook_tracked_events', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->integer('webhook_id');
|
||||
$table->string('event', 50);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('event');
|
||||
$table->index('webhook_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('webhooks');
|
||||
Schema::dropIfExists('webhook_tracked_events');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateJobsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateFailedJobsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10,15l5.88,0c0.27-0.31,0.67-0.5,1.12-0.5c0.83,0,1.5,0.67,1.5,1.5c0,0.83-0.67,1.5-1.5,1.5c-0.44,0-0.84-0.19-1.12-0.5 l-3.98,0c-0.46,2.28-2.48,4-4.9,4c-2.76,0-5-2.24-5-5c0-2.42,1.72-4.44,4-4.9l0,2.07C4.84,13.58,4,14.7,4,16c0,1.65,1.35,3,3,3 s3-1.35,3-3V15z M12.5,4c1.65,0,3,1.35,3,3h2c0-2.76-2.24-5-5-5l0,0c-2.76,0-5,2.24-5,5c0,1.43,0.6,2.71,1.55,3.62l-2.35,3.9 C6.02,14.66,5.5,15.27,5.5,16c0,0.83,0.67,1.5,1.5,1.5s1.5-0.67,1.5-1.5c0-0.16-0.02-0.31-0.07-0.45l3.38-5.63 C10.49,9.61,9.5,8.42,9.5,7C9.5,5.35,10.85,4,12.5,4z M17,13c-0.64,0-1.23,0.2-1.72,0.54l-3.05-5.07C11.53,8.35,11,7.74,11,7 c0-0.83,0.67-1.5,1.5-1.5S14,6.17,14,7c0,0.15-0.02,0.29-0.06,0.43l2.19,3.65C16.41,11.03,16.7,11,17,11l0,0c2.76,0,5,2.24,5,5 c0,2.76-2.24,5-5,5c-1.85,0-3.47-1.01-4.33-2.5l2.67,0C15.82,18.82,16.39,19,17,19c1.65,0,3-1.35,3-3S18.65,13,17,13z"/></svg>
|
After Width: | Height: | Size: 903 B |
|
@ -50,6 +50,7 @@ import templateManager from "./template-manager.js"
|
|||
import toggleSwitch from "./toggle-switch.js"
|
||||
import triLayout from "./tri-layout.js"
|
||||
import userSelect from "./user-select.js"
|
||||
import webhookEvents from "./webhook-events";
|
||||
import wysiwygEditor from "./wysiwyg-editor.js"
|
||||
|
||||
const componentMapping = {
|
||||
|
@ -105,6 +106,7 @@ const componentMapping = {
|
|||
"toggle-switch": toggleSwitch,
|
||||
"tri-layout": triLayout,
|
||||
"user-select": userSelect,
|
||||
"webhook-events": webhookEvents,
|
||||
"wysiwyg-editor": wysiwygEditor,
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
|
||||
/**
|
||||
* Webhook Events
|
||||
* Manages dynamic selection control in the webhook form interface.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class WebhookEvents {
|
||||
|
||||
setup() {
|
||||
this.checkboxes = this.$el.querySelectorAll('input[type="checkbox"]');
|
||||
this.allCheckbox = this.$el.querySelector('input[type="checkbox"][value="all"]');
|
||||
|
||||
this.$el.addEventListener('change', event => {
|
||||
if (event.target.checked && event.target === this.allCheckbox) {
|
||||
this.deselectIndividualEvents();
|
||||
} else if (event.target.checked) {
|
||||
this.allCheckbox.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deselectIndividualEvents() {
|
||||
for (const checkbox of this.checkboxes) {
|
||||
if (checkbox !== this.allCheckbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default WebhookEvents;
|
|
@ -7,41 +7,41 @@ return [
|
|||
|
||||
// Pages
|
||||
'page_create' => 'created page',
|
||||
'page_create_notification' => 'Page Successfully Created',
|
||||
'page_create_notification' => 'Page successfully created',
|
||||
'page_update' => 'updated page',
|
||||
'page_update_notification' => 'Page Successfully Updated',
|
||||
'page_update_notification' => 'Page successfully updated',
|
||||
'page_delete' => 'deleted page',
|
||||
'page_delete_notification' => 'Page Successfully Deleted',
|
||||
'page_delete_notification' => 'Page successfully deleted',
|
||||
'page_restore' => 'restored page',
|
||||
'page_restore_notification' => 'Page Successfully Restored',
|
||||
'page_restore_notification' => 'Page successfully restored',
|
||||
'page_move' => 'moved page',
|
||||
|
||||
// Chapters
|
||||
'chapter_create' => 'created chapter',
|
||||
'chapter_create_notification' => 'Chapter Successfully Created',
|
||||
'chapter_create_notification' => 'Chapter successfully created',
|
||||
'chapter_update' => 'updated chapter',
|
||||
'chapter_update_notification' => 'Chapter Successfully Updated',
|
||||
'chapter_update_notification' => 'Chapter successfully updated',
|
||||
'chapter_delete' => 'deleted chapter',
|
||||
'chapter_delete_notification' => 'Chapter Successfully Deleted',
|
||||
'chapter_delete_notification' => 'Chapter successfully deleted',
|
||||
'chapter_move' => 'moved chapter',
|
||||
|
||||
// Books
|
||||
'book_create' => 'created book',
|
||||
'book_create_notification' => 'Book Successfully Created',
|
||||
'book_create_notification' => 'Book successfully created',
|
||||
'book_update' => 'updated book',
|
||||
'book_update_notification' => 'Book Successfully Updated',
|
||||
'book_update_notification' => 'Book successfully updated',
|
||||
'book_delete' => 'deleted book',
|
||||
'book_delete_notification' => 'Book Successfully Deleted',
|
||||
'book_delete_notification' => 'Book successfully deleted',
|
||||
'book_sort' => 'sorted book',
|
||||
'book_sort_notification' => 'Book Successfully Re-sorted',
|
||||
'book_sort_notification' => 'Book successfully re-sorted',
|
||||
|
||||
// Bookshelves
|
||||
'bookshelf_create' => 'created Bookshelf',
|
||||
'bookshelf_create_notification' => 'Bookshelf Successfully Created',
|
||||
'bookshelf_create' => 'created bookshelf',
|
||||
'bookshelf_create_notification' => 'Bookshelf successfully created',
|
||||
'bookshelf_update' => 'updated bookshelf',
|
||||
'bookshelf_update_notification' => 'Bookshelf Successfully Updated',
|
||||
'bookshelf_update_notification' => 'Bookshelf successfully updated',
|
||||
'bookshelf_delete' => 'deleted bookshelf',
|
||||
'bookshelf_delete_notification' => 'Bookshelf Successfully Deleted',
|
||||
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
|
||||
|
||||
// Favourites
|
||||
'favourite_add_notification' => '":name" has been added to your favourites',
|
||||
|
@ -51,6 +51,14 @@ return [
|
|||
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
|
||||
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
|
||||
|
||||
// Webhooks
|
||||
'webhook_create' => 'created webhook',
|
||||
'webhook_create_notification' => 'Webhook successfully created',
|
||||
'webhook_update' => 'updated webhook',
|
||||
'webhook_update_notification' => 'Webhook successfully updated',
|
||||
'webhook_delete' => 'deleted webhook',
|
||||
'webhook_delete_notification' => 'Webhook successfully deleted',
|
||||
|
||||
// Other
|
||||
'commented_on' => 'commented on',
|
||||
'permissions_update' => 'updated permissions',
|
||||
|
|
|
@ -21,7 +21,7 @@ return [
|
|||
'email' => 'Email',
|
||||
'password' => 'Password',
|
||||
'password_confirm' => 'Confirm Password',
|
||||
'password_hint' => 'Must be over 7 characters',
|
||||
'password_hint' => 'Must be at least 8 characters',
|
||||
'forgot_password' => 'Forgot Password?',
|
||||
'remember_me' => 'Remember Me',
|
||||
'ldap_email_hint' => 'Please enter an email to use for this account.',
|
||||
|
|
|
@ -71,6 +71,9 @@ return [
|
|||
'list_view' => 'List View',
|
||||
'default' => 'Default',
|
||||
'breadcrumb' => 'Breadcrumb',
|
||||
'status' => 'Status',
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expand Header Menu',
|
||||
|
|
|
@ -143,6 +143,8 @@ return [
|
|||
'books_sort_chapters_last' => 'Chapters Last',
|
||||
'books_sort_show_other' => 'Show Other Books',
|
||||
'books_sort_save' => 'Save New Order',
|
||||
'books_copy' => 'Copy Book',
|
||||
'books_copy_success' => 'Book successfully copied',
|
||||
|
||||
// Chapters
|
||||
'chapter' => 'Chapter',
|
||||
|
@ -161,6 +163,8 @@ return [
|
|||
'chapters_move' => 'Move Chapter',
|
||||
'chapters_move_named' => 'Move Chapter :chapterName',
|
||||
'chapter_move_success' => 'Chapter moved to :bookName',
|
||||
'chapters_copy' => 'Copy Chapter',
|
||||
'chapters_copy_success' => 'Chapter successfully copied',
|
||||
'chapters_permissions' => 'Chapter Permissions',
|
||||
'chapters_empty' => 'No pages are currently in this chapter.',
|
||||
'chapters_permissions_active' => 'Chapter Permissions Active',
|
||||
|
@ -332,4 +336,12 @@ return [
|
|||
'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
|
||||
'revision_delete_success' => 'Revision deleted',
|
||||
'revision_cannot_delete_latest' => 'Cannot delete the latest revision.',
|
||||
|
||||
// Copy view
|
||||
'copy_consider' => 'Please consider the below when copying content.',
|
||||
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
|
||||
'copy_consider_owner' => 'You will become the owner of all copied content.',
|
||||
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
|
||||
'copy_consider_attachments' => 'Page attachments will not be copied.',
|
||||
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
|
||||
];
|
||||
|
|
|
@ -174,7 +174,7 @@ return [
|
|||
'users_role' => 'User Roles',
|
||||
'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
|
||||
'users_password' => 'User Password',
|
||||
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
|
||||
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
|
||||
'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
|
||||
'users_send_invite_option' => 'Send user invite email',
|
||||
'users_external_auth_id' => 'External Authentication ID',
|
||||
|
@ -233,6 +233,28 @@ return [
|
|||
'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
|
||||
'user_api_token_delete_success' => 'API token successfully deleted',
|
||||
|
||||
// Webhooks
|
||||
'webhooks' => 'Webhooks',
|
||||
'webhooks_create' => 'Create New Webhook',
|
||||
'webhooks_none_created' => 'No webhooks have yet been created.',
|
||||
'webhooks_edit' => 'Edit Webhook',
|
||||
'webhooks_save' => 'Save Webhook',
|
||||
'webhooks_details' => 'Webhook Details',
|
||||
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
|
||||
'webhooks_events' => 'Webhook Events',
|
||||
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
|
||||
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
|
||||
'webhooks_events_all' => 'All system events',
|
||||
'webhooks_name' => 'Webhook Name',
|
||||
'webhooks_endpoint' => 'Webhook Endpoint',
|
||||
'webhooks_active' => 'Webhook Active',
|
||||
'webhook_events_table_header' => 'Events',
|
||||
'webhooks_delete' => 'Delete Webhook',
|
||||
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
|
||||
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
|
||||
'webhooks_format_example' => 'Webhook Format Example',
|
||||
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
|
||||
|
||||
//! If editing translations files directly please ignore this in all
|
||||
//! languages apart from en. Content will be auto-copied from en.
|
||||
//!////////////////////////////////
|
||||
|
|
|
@ -1,5 +1,27 @@
|
|||
<h1 class="list-heading text-capitals mb-l">Getting Started</h1>
|
||||
|
||||
<p class="mb-none">
|
||||
This documentation covers use of the REST API. <br>
|
||||
Some alternative options for extension and customization can be found below:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ url('/settings/webhooks') }}" target="_blank" rel="noopener noreferrer">Webhooks</a> -
|
||||
HTTP POST calls upon events occurring in BookStack.
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/visual-theme-system.md" target="_blank" rel="noopener noreferrer">Visual Theme System</a> -
|
||||
Methods to override views, translations and icons within BookStack.
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/logical-theme-system.md" target="_blank" rel="noopener noreferrer">Logical Theme System</a> -
|
||||
Methods to extend back-end functionality within BookStack.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h5 id="authentication" class="text-mono mb-m">Authentication</h5>
|
||||
<p>
|
||||
To access the API a user has to have the <em>"Access System API"</em> permission enabled on one of their assigned roles.
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
@extends('layouts.simple')
|
||||
|
||||
@section('body')
|
||||
|
||||
<div class="container small">
|
||||
|
||||
<div class="my-s">
|
||||
@include('entities.breadcrumbs', ['crumbs' => [
|
||||
$book,
|
||||
$book->getUrl('/copy') => [
|
||||
'text' => trans('entities.books_copy'),
|
||||
'icon' => 'copy',
|
||||
]
|
||||
]])
|
||||
</div>
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
|
||||
<h1 class="list-heading">{{ trans('entities.books_copy') }}</h1>
|
||||
|
||||
<form action="{{ $book->getUrl('/copy') }}" method="POST">
|
||||
{!! csrf_field() !!}
|
||||
|
||||
<div class="form-group title-input">
|
||||
<label for="name">{{ trans('common.name') }}</label>
|
||||
@include('form.text', ['name' => 'name'])
|
||||
</div>
|
||||
|
||||
@include('entities.copy-considerations')
|
||||
|
||||
<div class="form-group text-right">
|
||||
<a href="{{ $book->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
<button type="submit" class="button">{{ trans('entities.books_copy') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@stop
|
|
@ -110,6 +110,12 @@
|
|||
<span>{{ trans('common.sort') }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@if(userCan('book-create-all'))
|
||||
<a href="{{ $book->getUrl('/copy') }}" class="icon-list-item">
|
||||
<span>@icon('copy')</span>
|
||||
<span>{{ trans('common.copy') }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@if(userCan('restrictions-manage', $book))
|
||||
<a href="{{ $book->getUrl('/permissions') }}" class="icon-list-item">
|
||||
<span>@icon('lock')</span>
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
@extends('layouts.simple')
|
||||
|
||||
@section('body')
|
||||
|
||||
<div class="container small">
|
||||
|
||||
<div class="my-s">
|
||||
@include('entities.breadcrumbs', ['crumbs' => [
|
||||
$chapter->book,
|
||||
$chapter,
|
||||
$chapter->getUrl('/copy') => [
|
||||
'text' => trans('entities.chapters_copy'),
|
||||
'icon' => 'copy',
|
||||
]
|
||||
]])
|
||||
</div>
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
|
||||
<h1 class="list-heading">{{ trans('entities.chapters_copy') }}</h1>
|
||||
|
||||
<form action="{{ $chapter->getUrl('/copy') }}" method="POST">
|
||||
{!! csrf_field() !!}
|
||||
|
||||
<div class="form-group title-input">
|
||||
<label for="name">{{ trans('common.name') }}</label>
|
||||
@include('form.text', ['name' => 'name'])
|
||||
</div>
|
||||
|
||||
<div class="form-group" collapsible>
|
||||
<button type="button" class="collapse-title text-primary" collapsible-trigger aria-expanded="false">
|
||||
<label for="entity_selection">{{ trans('entities.pages_copy_desination') }}</label>
|
||||
</button>
|
||||
<div class="collapse-content" collapsible-content>
|
||||
@include('entities.selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book', 'entityPermission' => 'chapter-create'])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('entities.copy-considerations')
|
||||
|
||||
<div class="form-group text-right">
|
||||
<a href="{{ $chapter->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
<button type="submit" class="button">{{ trans('entities.chapters_copy') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@stop
|
|
@ -108,6 +108,12 @@
|
|||
<span>{{ trans('common.edit') }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@if(userCanOnAny('chapter-create'))
|
||||
<a href="{{ $chapter->getUrl('/copy') }}" class="icon-list-item">
|
||||
<span>@icon('copy')</span>
|
||||
<span>{{ trans('common.copy') }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@if(userCan('chapter-update', $chapter) && userCan('chapter-delete', $chapter))
|
||||
<a href="{{ $chapter->getUrl('/move') }}" class="icon-list-item">
|
||||
<span>@icon('folder')</span>
|
||||
|
|
|
@ -24,8 +24,6 @@
|
|||
"{{ $activity->entity->name }}"
|
||||
@endif
|
||||
|
||||
@if($activity->extra) "{{ $activity->extra }}" @endif
|
||||
|
||||
<br>
|
||||
|
||||
<span class="text-muted"><small>@icon('time'){{ $activity->created_at->diffForHumans() }}</small></span>
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<p class="text-warn mb-none mt-l">
|
||||
@icon('warning') <strong>{{ trans('entities.copy_consider') }}</strong>
|
||||
</p>
|
||||
|
||||
<div class="grid half no-gap no-row-gap text-warn mb-m">
|
||||
<ul class="pr-s mb-none">
|
||||
<li>{{ trans('entities.copy_consider_permissions') }}</li>
|
||||
<li>{{ trans('entities.copy_consider_owner') }}</li>
|
||||
<li>{{ trans('entities.copy_consider_images') }}</li>
|
||||
</ul>
|
||||
<ul class="pr-s mb-none">
|
||||
<li>{{ trans('entities.copy_consider_attachments') }}</li>
|
||||
<li>{{ trans('entities.copy_consider_access') }}</li>
|
||||
</ul>
|
||||
</div>
|
|
@ -0,0 +1,3 @@
|
|||
@if($errors->has($name))
|
||||
<div class="text-neg text-small">{{ $errors->first($name) }}</div>
|
||||
@endif
|
|
@ -37,6 +37,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@include('entities.copy-considerations')
|
||||
|
||||
<div class="form-group text-right">
|
||||
<a href="{{ $page->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
<button type="submit" class="button">{{ trans('entities.pages_copy') }}</button>
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
</div>
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
<h2 class="list-heading">{{ trans('settings.audit') }}</h2>
|
||||
<h1 class="list-heading">{{ trans('settings.audit') }}</h1>
|
||||
<p class="text-muted">{{ trans('settings.audit_desc') }}</p>
|
||||
|
||||
<div class="flex-container-row">
|
||||
|
@ -41,12 +41,19 @@
|
|||
</div>
|
||||
@endforeach
|
||||
|
||||
<div class="form-group ml-auto"
|
||||
<div class="form-group ml-auto mr-m"
|
||||
component="submit-on-change"
|
||||
option:submit-on-change:filter='[name="user"]'>
|
||||
<label for="owner">{{ trans('settings.audit_table_user') }}</label>
|
||||
@include('form.user-select', ['user' => $listDetails['user'] ? \BookStack\Auth\User::query()->find($listDetails['user']) : null, 'name' => 'user', 'compact' => true])
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group ml-auto">
|
||||
<label for="ip">{{ trans('settings.audit_table_ip') }}</label>
|
||||
@include('form.text', ['name' => 'ip', 'model' => (object) $listDetails])
|
||||
<input type="submit" style="display: none">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -6,10 +6,12 @@ $version - Version of bookstack to display
|
|||
<div class="py-m flex fit-content">
|
||||
@include('settings.parts.navbar', ['selected' => $selected])
|
||||
</div>
|
||||
<div class="flex"></div>
|
||||
<div class="text-right p-m flex fit-content">
|
||||
</div>
|
||||
<div class="px-s">
|
||||
<hr class="darker m-none">
|
||||
</div>
|
||||
<div class="py-l px-m flex fit-content">
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases">
|
||||
BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
|
@ -13,4 +13,7 @@
|
|||
@if(userCan('user-roles-manage'))
|
||||
<a href="{{ url('/settings/roles') }}" @if($selected == 'roles') class="active" @endif>@icon('lock-open'){{ trans('settings.roles') }}</a>
|
||||
@endif
|
||||
@if(userCan('settings-manage'))
|
||||
<a href="{{ url('/settings/webhooks') }}" @if($selected == 'webhooks') class="active" @endif>@icon('webhooks'){{ trans('settings.webhooks') }}</a>
|
||||
@endif
|
||||
</nav>
|
|
@ -8,9 +8,21 @@
|
|||
@include('settings.parts.navbar', ['selected' => 'roles'])
|
||||
</div>
|
||||
|
||||
<div class="card content-wrap">
|
||||
<h1 class="list-heading">{{ trans('settings.role_create') }}</h1>
|
||||
|
||||
<form action="{{ url("/settings/roles/new") }}" method="POST">
|
||||
@include('settings.roles.parts.form', ['title' => trans('settings.role_create')])
|
||||
{{ csrf_field() }}
|
||||
|
||||
@include('settings.roles.parts.form', ['role' => $role ?? null])
|
||||
|
||||
<div class="form-group text-right">
|
||||
<a href="{{ url("/settings/roles") }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
<button type="submit" class="button">{{ trans('settings.role_save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@stop
|
||||
|
|
|
@ -7,10 +7,53 @@
|
|||
@include('settings.parts.navbar', ['selected' => 'roles'])
|
||||
</div>
|
||||
|
||||
<div class="card content-wrap">
|
||||
<h1 class="list-heading">{{ trans('settings.role_edit') }}</h1>
|
||||
|
||||
<form action="{{ url("/settings/roles/{$role->id}") }}" method="POST">
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
@include('settings.roles.parts.form', ['model' => $role, 'title' => trans('settings.role_edit'), 'icon' => 'edit'])
|
||||
{{ csrf_field() }}
|
||||
{{ method_field('PUT') }}
|
||||
|
||||
@include('settings.roles.parts.form', ['role' => $role])
|
||||
|
||||
<div class="form-group text-right">
|
||||
<a href="{{ url("/settings/roles") }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
<a href="{{ url("/settings/roles/new?copy_from={$role->id}") }}" class="button outline">{{ trans('common.copy') }}</a>
|
||||
<a href="{{ url("/settings/roles/delete/{$role->id}") }}" class="button outline">{{ trans('settings.role_delete') }}</a>
|
||||
<button type="submit" class="button">{{ trans('settings.role_save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
<h2 class="list-heading">{{ trans('settings.role_users') }}</h2>
|
||||
@if(count($role->users ?? []) > 0)
|
||||
<div class="grid third">
|
||||
@foreach($role->users as $user)
|
||||
<div class="user-list-item">
|
||||
<div>
|
||||
<img class="avatar small" src="{{ $user->getAvatar(40) }}" alt="{{ $user->name }}">
|
||||
</div>
|
||||
<div>
|
||||
@if(userCan('users-manage') || user()->id == $user->id)
|
||||
<a href="{{ url("/settings/users/{$user->id}") }}">
|
||||
@endif
|
||||
{{ $user->name }}
|
||||
@if(userCan('users-manage') || user()->id == $user->id)
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted">
|
||||
{{ trans('settings.role_users_none') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@stop
|
||||
|
|
|
@ -1,9 +1,4 @@
|
|||
{!! csrf_field() !!}
|
||||
|
||||
<div class="card content-wrap">
|
||||
<h1 class="list-heading">{{ $title }}</h1>
|
||||
|
||||
<div class="setting-list">
|
||||
<div class="setting-list">
|
||||
|
||||
<div class="grid half">
|
||||
<div>
|
||||
|
@ -12,20 +7,20 @@
|
|||
<div>
|
||||
<div class="form-group">
|
||||
<label for="display_name">{{ trans('settings.role_name') }}</label>
|
||||
@include('form.text', ['name' => 'display_name'])
|
||||
@include('form.text', ['name' => 'display_name', 'model' => $role])
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">{{ trans('settings.role_desc') }}</label>
|
||||
@include('form.text', ['name' => 'description'])
|
||||
@include('form.text', ['name' => 'description', 'model' => $role])
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced') ])
|
||||
@include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced'), 'model' => $role ])
|
||||
</div>
|
||||
|
||||
@if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc']))
|
||||
<div class="form-group">
|
||||
<label for="name">{{ trans('settings.role_external_auth_id') }}</label>
|
||||
@include('form.text', ['name' => 'external_auth_id'])
|
||||
@include('form.text', ['name' => 'external_auth_id', 'model' => $role])
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
@ -226,42 +221,4 @@
|
|||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group text-right">
|
||||
<a href="{{ url("/settings/roles") }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
@if (isset($role) && $role->id)
|
||||
<a href="{{ url("/settings/roles/delete/{$role->id}") }}" class="button outline">{{ trans('settings.role_delete') }}</a>
|
||||
@endif
|
||||
<button type="submit" class="button">{{ trans('settings.role_save') }}</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
<h2 class="list-heading">{{ trans('settings.role_users') }}</h2>
|
||||
@if(count($role->users ?? []) > 0)
|
||||
<div class="grid third">
|
||||
@foreach($role->users as $user)
|
||||
<div class="user-list-item">
|
||||
<div>
|
||||
<img class="avatar small" src="{{ $user->getAvatar(40) }}" alt="{{ $user->name }}">
|
||||
</div>
|
||||
<div>
|
||||
@if(userCan('users-manage') || user()->id == $user->id)
|
||||
<a href="{{ url("/settings/users/{$user->id}") }}">
|
||||
@endif
|
||||
{{ $user->name }}
|
||||
@if(userCan('users-manage') || user()->id == $user->id)
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted">
|
||||
{{ trans('settings.role_users_none') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
|
@ -0,0 +1,18 @@
|
|||
@extends('layouts.simple')
|
||||
|
||||
@section('body')
|
||||
|
||||
<div class="container small">
|
||||
|
||||
<div class="py-m">
|
||||
@include('settings.parts.navbar', ['selected' => 'webhooks'])
|
||||
</div>
|
||||
|
||||
<form action="{{ url("/settings/webhooks/create") }}" method="POST">
|
||||
@include('settings.webhooks.parts.form', ['title' => trans('settings.webhooks_create')])
|
||||
</form>
|
||||
|
||||
@include('settings.webhooks.parts.format-example')
|
||||
</div>
|
||||
|
||||
@stop
|
|
@ -0,0 +1,39 @@
|
|||
@extends('layouts.simple')
|
||||
|
||||
@section('body')
|
||||
<div class="container small">
|
||||
|
||||
<div class="py-m">
|
||||
@include('settings.parts.navbar', ['selected' => 'webhooks'])
|
||||
</div>
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
<h1 class="list-heading"> {{ trans('settings.webhooks_delete') }}</h1>
|
||||
|
||||
<p>{{ trans('settings.webhooks_delete_warning', ['webhookName' => $webhook->name]) }}</p>
|
||||
|
||||
|
||||
<form action="{{ $webhook->getUrl() }}" method="POST">
|
||||
{!! csrf_field() !!}
|
||||
{!! method_field('DELETE') !!}
|
||||
|
||||
<div class="grid half v-center">
|
||||
<div>
|
||||
<p class="text-neg">
|
||||
<strong>{{ trans('settings.webhooks_delete_confirm') }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-group text-right">
|
||||
<a href="{{ $webhook->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
<button type="submit" class="button">{{ trans('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@stop
|
|
@ -0,0 +1,18 @@
|
|||
@extends('layouts.simple')
|
||||
|
||||
@section('body')
|
||||
|
||||
<div class="container small">
|
||||
<div class="py-m">
|
||||
@include('settings.parts.navbar', ['selected' => 'webhooks'])
|
||||
</div>
|
||||
|
||||
<form action="{{ $webhook->getUrl() }}" method="POST">
|
||||
{!! method_field('PUT') !!}
|
||||
@include('settings.webhooks.parts.form', ['model' => $webhook, 'title' => trans('settings.webhooks_edit')])
|
||||
</form>
|
||||
|
||||
@include('settings.webhooks.parts.format-example')
|
||||
</div>
|
||||
|
||||
@stop
|
|
@ -0,0 +1,59 @@
|
|||
@extends('layouts.simple')
|
||||
|
||||
@section('body')
|
||||
|
||||
<div class="container small">
|
||||
|
||||
<div class="py-m">
|
||||
@include('settings.parts.navbar', ['selected' => 'webhooks'])
|
||||
</div>
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
|
||||
<div class="grid half v-center">
|
||||
<h1 class="list-heading">{{ trans('settings.webhooks') }}</h1>
|
||||
|
||||
<div class="text-right">
|
||||
<a href="{{ url("/settings/webhooks/create") }}"
|
||||
class="button outline">{{ trans('settings.webhooks_create') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(count($webhooks) > 0)
|
||||
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>{{ trans('common.name') }}</th>
|
||||
<th>{{ trans('settings.webhook_events_table_header') }}</th>
|
||||
<th>{{ trans('common.status') }}</th>
|
||||
</tr>
|
||||
@foreach($webhooks as $webhook)
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ $webhook->getUrl() }}">{{ $webhook->name }}</a> <br>
|
||||
<span class="small text-muted italic">{{ $webhook->endpoint }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if($webhook->tracksEvent('all'))
|
||||
{{ trans('settings.webhooks_events_all') }}
|
||||
@else
|
||||
{{ $webhook->trackedEvents->count() }}
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
{{ trans('common.status_' . ($webhook->active ? 'active' : 'inactive')) }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
@else
|
||||
<p class="text-muted empty-text px-none">
|
||||
{{ trans('settings.webhooks_none_created') }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@stop
|
|
@ -0,0 +1,75 @@
|
|||
{!! csrf_field() !!}
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
<h1 class="list-heading">{{ $title }}</h1>
|
||||
|
||||
<div class="setting-list">
|
||||
|
||||
<div class="grid half">
|
||||
<div>
|
||||
<label class="setting-list-label">{{ trans('settings.webhooks_details') }}</label>
|
||||
<p class="small">{{ trans('settings.webhooks_details_desc') }}</p>
|
||||
<div>
|
||||
@include('form.toggle-switch', [
|
||||
'name' => 'active',
|
||||
'value' => old('active') ?? $model->active ?? true,
|
||||
'label' => trans('settings.webhooks_active'),
|
||||
])
|
||||
@include('form.errors', ['name' => 'active'])
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label for="name">{{ trans('settings.webhooks_name') }}</label>
|
||||
@include('form.text', ['name' => 'name'])
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="endpoint">{{ trans('settings.webhooks_endpoint') }}</label>
|
||||
@include('form.text', ['name' => 'endpoint'])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div component="webhook-events">
|
||||
<label class="setting-list-label">{{ trans('settings.webhooks_events') }}</label>
|
||||
@include('form.errors', ['name' => 'events'])
|
||||
|
||||
<p class="small">{{ trans('settings.webhooks_events_desc') }}</p>
|
||||
<p class="text-warn small">{{ trans('settings.webhooks_events_warning') }}</p>
|
||||
|
||||
<div class="toggle-switch-list">
|
||||
@include('form.custom-checkbox', [
|
||||
'name' => 'events[]',
|
||||
'value' => 'all',
|
||||
'label' => trans('settings.webhooks_events_all'),
|
||||
'checked' => old('events') ? in_array('all', old('events')) : (isset($webhook) ? $webhook->tracksEvent('all') : false),
|
||||
])
|
||||
</div>
|
||||
|
||||
<hr class="my-s">
|
||||
|
||||
<div class="dual-column-content toggle-switch-list">
|
||||
@foreach(\BookStack\Actions\ActivityType::all() as $activityType)
|
||||
<div>
|
||||
@include('form.custom-checkbox', [
|
||||
'name' => 'events[]',
|
||||
'value' => $activityType,
|
||||
'label' => $activityType,
|
||||
'checked' => old('events') ? in_array($activityType, old('events')) : (isset($webhook) ? $webhook->tracksEvent($activityType) : false),
|
||||
])
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="form-group text-right">
|
||||
<a href="{{ url("/settings/webhooks") }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
@if ($webhook->id ?? false)
|
||||
<a href="{{ $webhook->getUrl('/delete') }}" class="button outline">{{ trans('settings.webhooks_delete') }}</a>
|
||||
@endif
|
||||
<button type="submit" class="button">{{ trans('settings.webhooks_save') }}</button>
|
||||
</div>
|
||||
|
||||
</div>
|
|
@ -0,0 +1,34 @@
|
|||
<div component="code-highlighter" class="card content-wrap auto-height">
|
||||
<h2 class="list-heading">{{ trans('settings.webhooks_format_example') }}</h2>
|
||||
<p>{{ trans('settings.webhooks_format_example_desc') }}</p>
|
||||
<pre><code class="language-json">{
|
||||
"event": "page_update",
|
||||
"text": "Benny updated page \"My wonderful updated page\"",
|
||||
"triggered_at": "2021-12-11T22:25:10.000000Z",
|
||||
"triggered_by": {
|
||||
"id": 1,
|
||||
"name": "Benny",
|
||||
"slug": "benny"
|
||||
},
|
||||
"triggered_by_profile_url": "https://bookstack.local/user/benny",
|
||||
"webhook_id": 2,
|
||||
"webhook_name": "My page update webhook",
|
||||
"url": "https://bookstack.local/books/my-awesome-book/page/my-wonderful-updated-page",
|
||||
"related_item": {
|
||||
"id": 2432,
|
||||
"book_id": 13,
|
||||
"chapter_id": 554,
|
||||
"name": "My wonderful updated page",
|
||||
"slug": "my-wonderful-updated-page",
|
||||
"priority": 2,
|
||||
"created_at": "2021-12-11T21:53:24.000000Z",
|
||||
"updated_at": "2021-12-11T22:25:10.000000Z",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"draft": false,
|
||||
"revision_count": 9,
|
||||
"template": false,
|
||||
"owned_by": 1
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
|
@ -29,7 +29,11 @@ use BookStack\Http\Controllers\UserApiTokenController;
|
|||
use BookStack\Http\Controllers\UserController;
|
||||
use BookStack\Http\Controllers\UserProfileController;
|
||||
use BookStack\Http\Controllers\UserSearchController;
|
||||
use BookStack\Http\Controllers\WebhookController;
|
||||
use BookStack\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
Route::get('/status', [StatusController::class, 'show']);
|
||||
Route::get('/robots.txt', [HomeController::class, 'robots']);
|
||||
|
@ -76,6 +80,8 @@ Route::middleware('auth')->group(function () {
|
|||
Route::get('/books/{bookSlug}/permissions', [BookController::class, 'showPermissions']);
|
||||
Route::put('/books/{bookSlug}/permissions', [BookController::class, 'permissions']);
|
||||
Route::get('/books/{slug}/delete', [BookController::class, 'showDelete']);
|
||||
Route::get('/books/{bookSlug}/copy', [BookController::class, 'showCopy']);
|
||||
Route::post('/books/{bookSlug}/copy', [BookController::class, 'copy']);
|
||||
Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']);
|
||||
Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']);
|
||||
Route::get('/books/{bookSlug}/export/html', [BookExportController::class, 'html']);
|
||||
|
@ -123,6 +129,8 @@ Route::middleware('auth')->group(function () {
|
|||
Route::put('/books/{bookSlug}/chapter/{chapterSlug}', [ChapterController::class, 'update']);
|
||||
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'showMove']);
|
||||
Route::put('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'move']);
|
||||
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/copy', [ChapterController::class, 'showCopy']);
|
||||
Route::post('/books/{bookSlug}/chapter/{chapterSlug}/copy', [ChapterController::class, 'copy']);
|
||||
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/edit', [ChapterController::class, 'edit']);
|
||||
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [ChapterController::class, 'showPermissions']);
|
||||
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [ChapterExportController::class, 'pdf']);
|
||||
|
@ -244,13 +252,22 @@ Route::middleware('auth')->group(function () {
|
|||
Route::delete('/settings/users/{userId}/api-tokens/{tokenId}', [UserApiTokenController::class, 'destroy']);
|
||||
|
||||
// Roles
|
||||
Route::get('/settings/roles', [RoleController::class, 'list']);
|
||||
Route::get('/settings/roles', [RoleController::class, 'index']);
|
||||
Route::get('/settings/roles/new', [RoleController::class, 'create']);
|
||||
Route::post('/settings/roles/new', [RoleController::class, 'store']);
|
||||
Route::get('/settings/roles/delete/{id}', [RoleController::class, 'showDelete']);
|
||||
Route::delete('/settings/roles/delete/{id}', [RoleController::class, 'delete']);
|
||||
Route::get('/settings/roles/{id}', [RoleController::class, 'edit']);
|
||||
Route::put('/settings/roles/{id}', [RoleController::class, 'update']);
|
||||
|
||||
// Webhooks
|
||||
Route::get('/settings/webhooks', [WebhookController::class, 'index']);
|
||||
Route::get('/settings/webhooks/create', [WebhookController::class, 'create']);
|
||||
Route::post('/settings/webhooks/create', [WebhookController::class, 'store']);
|
||||
Route::get('/settings/webhooks/{id}', [WebhookController::class, 'edit']);
|
||||
Route::put('/settings/webhooks/{id}', [WebhookController::class, 'update']);
|
||||
Route::get('/settings/webhooks/{id}/delete', [WebhookController::class, 'delete']);
|
||||
Route::delete('/settings/webhooks/{id}', [WebhookController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// MFA routes
|
||||
|
@ -291,9 +308,9 @@ Route::post('/saml2/logout', [Auth\Saml2Controller::class, 'logout']);
|
|||
Route::get('/saml2/metadata', [Auth\Saml2Controller::class, 'metadata']);
|
||||
Route::get('/saml2/sls', [Auth\Saml2Controller::class, 'sls']);
|
||||
Route::post('/saml2/acs', [Auth\Saml2Controller::class, 'startAcs'])->withoutMiddleware([
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\BookStack\Http\Middleware\VerifyCsrfToken::class,
|
||||
StartSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
]);
|
||||
Route::get('/saml2/acs', [Auth\Saml2Controller::class, 'processAcs']);
|
||||
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace Tests;
|
||||
namespace Tests\Actions;
|
||||
|
||||
use function app;
|
||||
use BookStack\Actions\Activity;
|
||||
use BookStack\Actions\ActivityService;
|
||||
use BookStack\Actions\ActivityLogger;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
|
@ -11,16 +12,18 @@ use BookStack\Entities\Models\Page;
|
|||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use Carbon\Carbon;
|
||||
use function config;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuditLogTest extends TestCase
|
||||
{
|
||||
/** @var ActivityService */
|
||||
/** @var ActivityLogger */
|
||||
protected $activityService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->activityService = app(ActivityService::class);
|
||||
$this->activityService = app(ActivityLogger::class);
|
||||
}
|
||||
|
||||
public function test_only_accessible_with_right_permissions()
|
||||
|
@ -46,7 +49,7 @@ class AuditLogTest extends TestCase
|
|||
$admin = $this->getAdmin();
|
||||
$this->actingAs($admin);
|
||||
$page = Page::query()->first();
|
||||
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
|
||||
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
|
||||
$activity = Activity::query()->orderBy('id', 'desc')->first();
|
||||
|
||||
$resp = $this->get('settings/audit');
|
||||
|
@ -61,7 +64,7 @@ class AuditLogTest extends TestCase
|
|||
$this->actingAs($this->getAdmin());
|
||||
$page = Page::query()->first();
|
||||
$pageName = $page->name;
|
||||
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
|
||||
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
|
||||
|
||||
app(PageRepo::class)->destroy($page);
|
||||
app(TrashCan::class)->empty();
|
||||
|
@ -76,7 +79,7 @@ class AuditLogTest extends TestCase
|
|||
$viewer = $this->getViewer();
|
||||
$this->actingAs($viewer);
|
||||
$page = Page::query()->first();
|
||||
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
|
||||
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
|
||||
|
||||
$this->actingAs($this->getAdmin());
|
||||
app(UserRepo::class)->destroy($viewer);
|
||||
|
@ -89,7 +92,7 @@ class AuditLogTest extends TestCase
|
|||
{
|
||||
$this->actingAs($this->getAdmin());
|
||||
$page = Page::query()->first();
|
||||
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
|
||||
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
|
||||
|
||||
$resp = $this->get('settings/audit');
|
||||
$resp->assertSeeText($page->name);
|
||||
|
@ -102,7 +105,7 @@ class AuditLogTest extends TestCase
|
|||
{
|
||||
$this->actingAs($this->getAdmin());
|
||||
$page = Page::query()->first();
|
||||
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
|
||||
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
|
||||
|
||||
$yesterday = (Carbon::now()->subDay()->format('Y-m-d'));
|
||||
$tomorrow = (Carbon::now()->addDay()->format('Y-m-d'));
|
||||
|
@ -126,11 +129,11 @@ class AuditLogTest extends TestCase
|
|||
$editor = $this->getEditor();
|
||||
$this->actingAs($admin);
|
||||
$page = Page::query()->first();
|
||||
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
|
||||
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
|
||||
|
||||
$this->actingAs($editor);
|
||||
$chapter = Chapter::query()->first();
|
||||
$this->activityService->addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
|
||||
$this->activityService->add(ActivityType::CHAPTER_UPDATE, $chapter);
|
||||
|
||||
$resp = $this->actingAs($admin)->get('settings/audit?user=' . $admin->id);
|
||||
$resp->assertSeeText($page->name);
|
||||
|
@ -166,6 +169,32 @@ class AuditLogTest extends TestCase
|
|||
$resp->assertSee('192.123.45.1');
|
||||
}
|
||||
|
||||
public function test_ip_address_is_searchable()
|
||||
{
|
||||
config()->set('app.proxies', '*');
|
||||
$editor = $this->getEditor();
|
||||
/** @var Page $page */
|
||||
$page = Page::query()->first();
|
||||
|
||||
$this->actingAs($editor)->put($page->getUrl(), [
|
||||
'name' => 'Updated page',
|
||||
'html' => '<p>Updated content</p>',
|
||||
], [
|
||||
'X-Forwarded-For' => '192.123.45.1',
|
||||
])->assertRedirect($page->refresh()->getUrl());
|
||||
|
||||
$this->actingAs($editor)->put($page->getUrl(), [
|
||||
'name' => 'Updated page',
|
||||
'html' => '<p>Updated content</p>',
|
||||
], [
|
||||
'X-Forwarded-For' => '192.122.45.1',
|
||||
])->assertRedirect($page->refresh()->getUrl());
|
||||
|
||||
$resp = $this->asAdmin()->get('/settings/audit?&ip=192.123');
|
||||
$resp->assertSee('192.123.45.1');
|
||||
$resp->assertDontSee('192.122.45.1');
|
||||
}
|
||||
|
||||
public function test_ip_address_not_logged_in_demo_mode()
|
||||
{
|
||||
config()->set('app.proxies', '*');
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Actions;
|
||||
|
||||
use BookStack\Actions\ActivityLogger;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\DispatchWebhookJob;
|
||||
use BookStack\Actions\Webhook;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebhookCallTest extends TestCase
|
||||
{
|
||||
public function test_webhook_listening_to_all_called_on_event()
|
||||
{
|
||||
$this->newWebhook([], ['all']);
|
||||
Bus::fake();
|
||||
$this->runEvent(ActivityType::ROLE_CREATE);
|
||||
Bus::assertDispatched(DispatchWebhookJob::class);
|
||||
}
|
||||
|
||||
public function test_webhook_listening_to_specific_event_called_on_event()
|
||||
{
|
||||
$this->newWebhook([], [ActivityType::ROLE_UPDATE]);
|
||||
Bus::fake();
|
||||
$this->runEvent(ActivityType::ROLE_UPDATE);
|
||||
Bus::assertDispatched(DispatchWebhookJob::class);
|
||||
}
|
||||
|
||||
public function test_webhook_listening_to_specific_event_not_called_on_other_event()
|
||||
{
|
||||
$this->newWebhook([], [ActivityType::ROLE_UPDATE]);
|
||||
Bus::fake();
|
||||
$this->runEvent(ActivityType::ROLE_CREATE);
|
||||
Bus::assertNotDispatched(DispatchWebhookJob::class);
|
||||
}
|
||||
|
||||
public function test_inactive_webhook_not_called_on_event()
|
||||
{
|
||||
$this->newWebhook(['active' => false], ['all']);
|
||||
Bus::fake();
|
||||
$this->runEvent(ActivityType::ROLE_CREATE);
|
||||
Bus::assertNotDispatched(DispatchWebhookJob::class);
|
||||
}
|
||||
|
||||
public function test_failed_webhook_call_logs_error()
|
||||
{
|
||||
$logger = $this->withTestLogger();
|
||||
Http::fake([
|
||||
'*' => Http::response('', 500),
|
||||
]);
|
||||
$this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
|
||||
|
||||
$this->runEvent(ActivityType::ROLE_CREATE);
|
||||
|
||||
$this->assertTrue($logger->hasError('Webhook call to endpoint https://wh.example.com failed with status 500'));
|
||||
}
|
||||
|
||||
public function test_webhook_call_data_format()
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response('', 200),
|
||||
]);
|
||||
$webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
|
||||
/** @var Page $page */
|
||||
$page = Page::query()->first();
|
||||
$editor = $this->getEditor();
|
||||
|
||||
$this->runEvent(ActivityType::PAGE_UPDATE, $page, $editor);
|
||||
|
||||
Http::assertSent(function (Request $request) use ($editor, $page, $webhook) {
|
||||
$reqData = $request->data();
|
||||
|
||||
return $request->isJson()
|
||||
&& $reqData['event'] === 'page_update'
|
||||
&& $reqData['text'] === ($editor->name . ' updated page "' . $page->name . '"')
|
||||
&& is_string($reqData['triggered_at'])
|
||||
&& $reqData['triggered_by']['name'] === $editor->name
|
||||
&& $reqData['triggered_by_profile_url'] === $editor->getProfileUrl()
|
||||
&& $reqData['webhook_id'] === $webhook->id
|
||||
&& $reqData['webhook_name'] === $webhook->name
|
||||
&& $reqData['url'] === $page->getUrl()
|
||||
&& $reqData['related_item']['name'] === $page->name;
|
||||
});
|
||||
}
|
||||
|
||||
protected function runEvent(string $event, $detail = '', ?User $user = null)
|
||||
{
|
||||
if (is_null($user)) {
|
||||
$user = $this->getEditor();
|
||||
}
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$activityLogger = $this->app->make(ActivityLogger::class);
|
||||
$activityLogger->add($event, $detail);
|
||||
}
|
||||
|
||||
protected function newWebhook(array $attrs = [], array $events = ['all']): Webhook
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::factory()->create($attrs);
|
||||
|
||||
foreach ($events as $event) {
|
||||
$webhook->trackedEvents()->create(['event' => $event]);
|
||||
}
|
||||
|
||||
return $webhook;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Actions;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\Webhook;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebhookManagementTest extends TestCase
|
||||
{
|
||||
public function test_index_view()
|
||||
{
|
||||
$webhook = $this->newWebhook([
|
||||
'name' => 'My awesome webhook',
|
||||
'endpoint' => 'https://example.com/donkey/webhook',
|
||||
], ['all']);
|
||||
|
||||
$resp = $this->asAdmin()->get('/settings/webhooks');
|
||||
$resp->assertOk();
|
||||
$resp->assertElementContains('a[href$="/settings/webhooks/create"]', 'Create New Webhook');
|
||||
$resp->assertElementExists('a[href="' . $webhook->getUrl() . '"]', $webhook->name);
|
||||
$resp->assertSee($webhook->endpoint);
|
||||
$resp->assertSee('All system events');
|
||||
$resp->assertSee('Active');
|
||||
}
|
||||
|
||||
public function test_create_view()
|
||||
{
|
||||
$resp = $this->asAdmin()->get('/settings/webhooks/create');
|
||||
$resp->assertOk();
|
||||
$resp->assertSee('Create New Webhook');
|
||||
$resp->assertElementContains('form[action$="/settings/webhooks/create"] button', 'Save Webhook');
|
||||
}
|
||||
|
||||
public function test_store()
|
||||
{
|
||||
$resp = $this->asAdmin()->post('/settings/webhooks/create', [
|
||||
'name' => 'My first webhook',
|
||||
'endpoint' => 'https://example.com/webhook',
|
||||
'events' => ['all'],
|
||||
'active' => 'true',
|
||||
]);
|
||||
|
||||
$resp->assertRedirect('/settings/webhooks');
|
||||
$this->assertActivityExists(ActivityType::WEBHOOK_CREATE);
|
||||
|
||||
$resp = $this->followRedirects($resp);
|
||||
$resp->assertSee('Webhook successfully created');
|
||||
|
||||
$this->assertDatabaseHas('webhooks', [
|
||||
'name' => 'My first webhook',
|
||||
'endpoint' => 'https://example.com/webhook',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->where('name', '=', 'My first webhook')->first();
|
||||
$this->assertDatabaseHas('webhook_tracked_events', [
|
||||
'webhook_id' => $webhook->id,
|
||||
'event' => 'all',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_edit_view()
|
||||
{
|
||||
$webhook = $this->newWebhook();
|
||||
|
||||
$resp = $this->asAdmin()->get('/settings/webhooks/' . $webhook->id);
|
||||
$resp->assertOk();
|
||||
$resp->assertSee('Edit Webhook');
|
||||
$resp->assertElementContains('form[action="' . $webhook->getUrl() . '"] button', 'Save Webhook');
|
||||
$resp->assertElementContains('a[href="' . $webhook->getUrl('/delete') . '"]', 'Delete Webhook');
|
||||
$resp->assertElementExists('input[type="checkbox"][value="all"][name="events[]"]');
|
||||
}
|
||||
|
||||
public function test_update()
|
||||
{
|
||||
$webhook = $this->newWebhook();
|
||||
|
||||
$resp = $this->asAdmin()->put('/settings/webhooks/' . $webhook->id, [
|
||||
'name' => 'My updated webhook',
|
||||
'endpoint' => 'https://example.com/updated-webhook',
|
||||
'events' => [ActivityType::PAGE_CREATE, ActivityType::PAGE_UPDATE],
|
||||
'active' => 'true',
|
||||
]);
|
||||
$resp->assertRedirect('/settings/webhooks');
|
||||
|
||||
$resp = $this->followRedirects($resp);
|
||||
$resp->assertSee('Webhook successfully updated');
|
||||
|
||||
$this->assertDatabaseHas('webhooks', [
|
||||
'id' => $webhook->id,
|
||||
'name' => 'My updated webhook',
|
||||
'endpoint' => 'https://example.com/updated-webhook',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$trackedEvents = $webhook->trackedEvents()->get();
|
||||
$this->assertCount(2, $trackedEvents);
|
||||
$this->assertEquals(['page_create', 'page_update'], $trackedEvents->pluck('event')->values()->all());
|
||||
|
||||
$this->assertActivityExists(ActivityType::WEBHOOK_UPDATE);
|
||||
}
|
||||
|
||||
public function test_delete_view()
|
||||
{
|
||||
$webhook = $this->newWebhook(['name' => 'Webhook to delete']);
|
||||
|
||||
$resp = $this->asAdmin()->get('/settings/webhooks/' . $webhook->id . '/delete');
|
||||
$resp->assertOk();
|
||||
$resp->assertSee('Delete Webhook');
|
||||
$resp->assertSee('This will fully delete this webhook, with the name \'Webhook to delete\', from the system.');
|
||||
$resp->assertElementContains('form[action$="/settings/webhooks/' . $webhook->id . '"]', 'Delete');
|
||||
}
|
||||
|
||||
public function test_destroy()
|
||||
{
|
||||
$webhook = $this->newWebhook();
|
||||
|
||||
$resp = $this->asAdmin()->delete('/settings/webhooks/' . $webhook->id);
|
||||
$resp->assertRedirect('/settings/webhooks');
|
||||
|
||||
$resp = $this->followRedirects($resp);
|
||||
$resp->assertSee('Webhook successfully deleted');
|
||||
|
||||
$this->assertDatabaseMissing('webhooks', ['id' => $webhook->id]);
|
||||
$this->assertDatabaseMissing('webhook_tracked_events', ['webhook_id' => $webhook->id]);
|
||||
|
||||
$this->assertActivityExists(ActivityType::WEBHOOK_DELETE);
|
||||
}
|
||||
|
||||
public function test_settings_manage_permission_required_for_webhook_routes()
|
||||
{
|
||||
$editor = $this->getEditor();
|
||||
$this->actingAs($editor);
|
||||
|
||||
$routes = [
|
||||
['GET', '/settings/webhooks'],
|
||||
['GET', '/settings/webhooks/create'],
|
||||
['POST', '/settings/webhooks/create'],
|
||||
['GET', '/settings/webhooks/1'],
|
||||
['PUT', '/settings/webhooks/1'],
|
||||
['DELETE', '/settings/webhooks/1'],
|
||||
['GET', '/settings/webhooks/1/delete'],
|
||||
];
|
||||
|
||||
foreach ($routes as [$method, $endpoint]) {
|
||||
$resp = $this->call($method, $endpoint);
|
||||
$this->assertPermissionError($resp);
|
||||
}
|
||||
|
||||
$this->giveUserPermissions($editor, ['settings-manage']);
|
||||
|
||||
foreach ($routes as [$method, $endpoint]) {
|
||||
$resp = $this->call($method, $endpoint);
|
||||
$this->assertNotPermissionError($resp);
|
||||
}
|
||||
}
|
||||
|
||||
protected function newWebhook(array $attrs = [], array $events = ['all']): Webhook
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::factory()->create($attrs);
|
||||
|
||||
foreach ($events as $event) {
|
||||
$webhook->trackedEvents()->create(['event' => $event]);
|
||||
}
|
||||
|
||||
return $webhook;
|
||||
}
|
||||
}
|
|
@ -4,6 +4,8 @@ namespace Tests\Commands;
|
|||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Facades\Activity;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
@ -12,8 +14,9 @@ class ClearActivityCommandTest extends TestCase
|
|||
public function test_clear_activity_command()
|
||||
{
|
||||
$this->asEditor();
|
||||
$page = Page::first();
|
||||
\Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
|
||||
/** @var Page $page */
|
||||
$page = Page::query()->first();
|
||||
Activity::add(ActivityType::PAGE_UPDATE, $page);
|
||||
|
||||
$this->assertDatabaseHas('activities', [
|
||||
'type' => 'page_update',
|
||||
|
@ -22,7 +25,7 @@ class ClearActivityCommandTest extends TestCase
|
|||
]);
|
||||
|
||||
DB::rollBack();
|
||||
$exitCode = \Artisan::call('bookstack:clear-activity');
|
||||
$exitCode = Artisan::call('bookstack:clear-activity');
|
||||
DB::beginTransaction();
|
||||
$this->assertTrue($exitCode === 0, 'Command executed successfully');
|
||||
|
||||
|
|
|
@ -3,10 +3,15 @@
|
|||
namespace Tests\Entity;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use Tests\TestCase;
|
||||
use Tests\Uploads\UsesImages;
|
||||
|
||||
class BookTest extends TestCase
|
||||
{
|
||||
use UsesImages;
|
||||
|
||||
public function test_create()
|
||||
{
|
||||
$book = Book::factory()->make([
|
||||
|
@ -204,4 +209,88 @@ class BookTest extends TestCase
|
|||
|
||||
$this->assertEquals('parta-partb-partc', $book->slug);
|
||||
}
|
||||
|
||||
public function test_show_view_has_copy_button()
|
||||
{
|
||||
/** @var Book $book */
|
||||
$book = Book::query()->first();
|
||||
$resp = $this->asEditor()->get($book->getUrl());
|
||||
|
||||
$resp->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy');
|
||||
}
|
||||
|
||||
public function test_copy_view()
|
||||
{
|
||||
/** @var Book $book */
|
||||
$book = Book::query()->first();
|
||||
$resp = $this->asEditor()->get($book->getUrl('/copy'));
|
||||
|
||||
$resp->assertOk();
|
||||
$resp->assertSee('Copy Book');
|
||||
$resp->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]");
|
||||
}
|
||||
|
||||
public function test_copy()
|
||||
{
|
||||
/** @var Book $book */
|
||||
$book = Book::query()->whereHas('chapters')->whereHas('pages')->first();
|
||||
$resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
|
||||
|
||||
/** @var Book $copy */
|
||||
$copy = Book::query()->where('name', '=', 'My copy book')->first();
|
||||
|
||||
$resp->assertRedirect($copy->getUrl());
|
||||
$this->assertEquals($book->getDirectChildren()->count(), $copy->getDirectChildren()->count());
|
||||
}
|
||||
|
||||
public function test_copy_does_not_copy_non_visible_content()
|
||||
{
|
||||
/** @var Book $book */
|
||||
$book = Book::query()->whereHas('chapters')->whereHas('pages')->first();
|
||||
|
||||
// Hide child content
|
||||
/** @var BookChild $page */
|
||||
foreach ($book->getDirectChildren() as $child) {
|
||||
$child->restricted = true;
|
||||
$child->save();
|
||||
$this->regenEntityPermissions($child);
|
||||
}
|
||||
|
||||
$this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
|
||||
/** @var Book $copy */
|
||||
$copy = Book::query()->where('name', '=', 'My copy book')->first();
|
||||
|
||||
$this->assertEquals(0, $copy->getDirectChildren()->count());
|
||||
}
|
||||
|
||||
public function test_copy_does_not_copy_pages_or_chapters_if_user_cant_create()
|
||||
{
|
||||
/** @var Book $book */
|
||||
$book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first();
|
||||
$viewer = $this->getViewer();
|
||||
$this->giveUserPermissions($viewer, ['book-create-all']);
|
||||
|
||||
$this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']);
|
||||
/** @var Book $copy */
|
||||
$copy = Book::query()->where('name', '=', 'My copy book')->first();
|
||||
|
||||
$this->assertEquals(0, $copy->pages()->count());
|
||||
$this->assertEquals(0, $copy->chapters()->count());
|
||||
}
|
||||
|
||||
public function test_copy_clones_cover_image_if_existing()
|
||||
{
|
||||
/** @var Book $book */
|
||||
$book = Book::query()->first();
|
||||
$bookRepo = $this->app->make(BookRepo::class);
|
||||
$coverImageFile = $this->getTestImage('cover.png');
|
||||
$bookRepo->updateCoverImage($book, $coverImageFile);
|
||||
|
||||
$this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
|
||||
|
||||
/** @var Book $copy */
|
||||
$copy = Book::query()->where('name', '=', 'My copy book')->first();
|
||||
$this->assertNotNull($copy->cover);
|
||||
$this->assertNotEquals($book->cover->id, $copy->cover->id);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ namespace Tests\Entity;
|
|||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ChapterTest extends TestCase
|
||||
|
@ -54,4 +55,95 @@ class ChapterTest extends TestCase
|
|||
$redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
|
||||
$redirectReq->assertNotificationContains('Chapter Successfully Deleted');
|
||||
}
|
||||
|
||||
public function test_show_view_has_copy_button()
|
||||
{
|
||||
/** @var Chapter $chapter */
|
||||
$chapter = Chapter::query()->first();
|
||||
|
||||
$resp = $this->asEditor()->get($chapter->getUrl());
|
||||
$resp->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy');
|
||||
}
|
||||
|
||||
public function test_copy_view()
|
||||
{
|
||||
/** @var Chapter $chapter */
|
||||
$chapter = Chapter::query()->first();
|
||||
|
||||
$resp = $this->asEditor()->get($chapter->getUrl('/copy'));
|
||||
$resp->assertOk();
|
||||
$resp->assertSee('Copy Chapter');
|
||||
$resp->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]");
|
||||
$resp->assertElementExists('input[name="entity_selection"]');
|
||||
}
|
||||
|
||||
public function test_copy()
|
||||
{
|
||||
/** @var Chapter $chapter */
|
||||
$chapter = Chapter::query()->whereHas('pages')->first();
|
||||
/** @var Book $otherBook */
|
||||
$otherBook = Book::query()->where('id', '!=', $chapter->book_id)->first();
|
||||
|
||||
$resp = $this->asEditor()->post($chapter->getUrl('/copy'), [
|
||||
'name' => 'My copied chapter',
|
||||
'entity_selection' => 'book:' . $otherBook->id,
|
||||
]);
|
||||
|
||||
/** @var Chapter $newChapter */
|
||||
$newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first();
|
||||
|
||||
$resp->assertRedirect($newChapter->getUrl());
|
||||
$this->assertEquals($otherBook->id, $newChapter->book_id);
|
||||
$this->assertEquals($chapter->pages->count(), $newChapter->pages->count());
|
||||
}
|
||||
|
||||
public function test_copy_does_not_copy_non_visible_pages()
|
||||
{
|
||||
/** @var Chapter $chapter */
|
||||
$chapter = Chapter::query()->whereHas('pages')->first();
|
||||
|
||||
// Hide pages to all non-admin roles
|
||||
/** @var Page $page */
|
||||
foreach ($chapter->pages as $page) {
|
||||
$page->restricted = true;
|
||||
$page->save();
|
||||
$this->regenEntityPermissions($page);
|
||||
}
|
||||
|
||||
$this->asEditor()->post($chapter->getUrl('/copy'), [
|
||||
'name' => 'My copied chapter',
|
||||
]);
|
||||
|
||||
/** @var Chapter $newChapter */
|
||||
$newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first();
|
||||
$this->assertEquals(0, $newChapter->pages()->count());
|
||||
}
|
||||
|
||||
public function test_copy_does_not_copy_pages_if_user_cant_page_create()
|
||||
{
|
||||
/** @var Chapter $chapter */
|
||||
$chapter = Chapter::query()->whereHas('pages')->first();
|
||||
$viewer = $this->getViewer();
|
||||
$this->giveUserPermissions($viewer, ['chapter-create-all']);
|
||||
|
||||
// Lacking permission results in no copied pages
|
||||
$this->actingAs($viewer)->post($chapter->getUrl('/copy'), [
|
||||
'name' => 'My copied chapter',
|
||||
]);
|
||||
|
||||
/** @var Chapter $newChapter */
|
||||
$newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first();
|
||||
$this->assertEquals(0, $newChapter->pages()->count());
|
||||
|
||||
$this->giveUserPermissions($viewer, ['page-create-all']);
|
||||
|
||||
// Having permission rules in copied pages
|
||||
$this->actingAs($viewer)->post($chapter->getUrl('/copy'), [
|
||||
'name' => 'My copied again chapter',
|
||||
]);
|
||||
|
||||
/** @var Chapter $newChapter2 */
|
||||
$newChapter2 = Chapter::query()->where('name', '=', 'My copied again chapter')->first();
|
||||
$this->assertEquals($chapter->pages()->count(), $newChapter2->pages()->count());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -403,6 +403,17 @@ class EntitySearchTest extends TestCase
|
|||
$search->assertElementContains('.tag-value.highlight', 'MeowieCat');
|
||||
}
|
||||
|
||||
public function test_match_highlighting_works_with_multibyte_content()
|
||||
{
|
||||
$this->newPage([
|
||||
'name' => 'Test Page',
|
||||
'html' => '<p>На мен ми трябва нещо добро test</p>',
|
||||
]);
|
||||
|
||||
$search = $this->asEditor()->get('/search?term=' . urlencode('На мен ми трябва нещо добро'));
|
||||
$search->assertSee('<strong>На</strong> <strong>мен</strong> <strong>ми</strong> <strong>трябва</strong> <strong>нещо</strong> <strong>добро</strong> test', false);
|
||||
}
|
||||
|
||||
public function test_html_entities_in_item_details_remains_escaped_in_search_results()
|
||||
{
|
||||
$this->newPage(['name' => 'My <cool> TestPageContent', 'html' => '<p>My supercool <great> TestPageContent page</p>']);
|
||||
|
|
|
@ -163,6 +163,23 @@ class RolesTest extends TestCase
|
|||
$this->assertEquals($this->user->id, $roleA->users()->first()->id);
|
||||
}
|
||||
|
||||
public function test_copy_role_button_shown()
|
||||
{
|
||||
/** @var Role $role */
|
||||
$role = Role::query()->first();
|
||||
$resp = $this->asAdmin()->get("/settings/roles/{$role->id}");
|
||||
$resp->assertElementContains('a[href$="/roles/new?copy_from=' . $role->id . '"]', 'Copy');
|
||||
}
|
||||
|
||||
public function test_copy_from_param_on_create_prefills_with_other_role_data()
|
||||
{
|
||||
/** @var Role $role */
|
||||
$role = Role::query()->first();
|
||||
$resp = $this->asAdmin()->get("/settings/roles/new?copy_from={$role->id}");
|
||||
$resp->assertOk();
|
||||
$resp->assertElementExists('input[name="display_name"][value="' . ($role->display_name . ' (Copy)') . '"]');
|
||||
}
|
||||
|
||||
public function test_manage_user_permission()
|
||||
{
|
||||
$this->actingAs($this->user)->get('/settings/users')->assertRedirect('/');
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Cache\ArrayStore;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Tests\TestCase;
|
||||
use Mockery;
|
||||
|
||||
class StatusTest extends TestCase
|
||||
{
|
||||
|
|
|
@ -34,7 +34,7 @@ class TestEmailTest extends TestCase
|
|||
$this->app[Dispatcher::class] = $mockDispatcher;
|
||||
|
||||
$exception = new \Exception('A random error occurred when testing an email');
|
||||
$mockDispatcher->shouldReceive('send')->andThrow($exception);
|
||||
$mockDispatcher->shouldReceive('sendNow')->andThrow($exception);
|
||||
|
||||
$admin = $this->getAdmin();
|
||||
$sendReq = $this->actingAs($admin)->post('/settings/maintenance/send-test-email');
|
||||
|
|
|
@ -11,16 +11,16 @@ class AvatarTest extends TestCase
|
|||
{
|
||||
use UsesImages;
|
||||
|
||||
protected function createUserRequest($user)
|
||||
protected function createUserRequest($user): User
|
||||
{
|
||||
$this->asAdmin()->post('/settings/users/create', [
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'password' => 'testing',
|
||||
'password-confirm' => 'testing',
|
||||
'password' => 'testing101',
|
||||
'password-confirm' => 'testing101',
|
||||
]);
|
||||
|
||||
return User::where('email', '=', $user->email)->first();
|
||||
return User::query()->where('email', '=', $user->email)->first();
|
||||
}
|
||||
|
||||
protected function assertImageFetchFrom(string $url)
|
||||
|
|
|
@ -64,8 +64,8 @@ class UserProfileTest extends TestCase
|
|||
$newUser = User::factory()->create();
|
||||
$this->actingAs($newUser);
|
||||
$entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
|
||||
Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
|
||||
Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
|
||||
Activity::add(ActivityType::BOOK_UPDATE, $entities['book']);
|
||||
Activity::add(ActivityType::PAGE_CREATE, $entities['page']);
|
||||
|
||||
$this->asAdmin()->get('/user/' . $newUser->slug)
|
||||
->assertElementContains('#recent-user-activity', 'updated book')
|
||||
|
@ -78,8 +78,8 @@ class UserProfileTest extends TestCase
|
|||
$newUser = User::factory()->create();
|
||||
$this->actingAs($newUser);
|
||||
$entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
|
||||
Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
|
||||
Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
|
||||
Activity::add(ActivityType::BOOK_UPDATE, $entities['book']);
|
||||
Activity::add(ActivityType::PAGE_CREATE, $entities['page']);
|
||||
|
||||
$linkSelector = '#recent-activity a[href$="/user/' . $newUser->slug . '"]';
|
||||
$this->asAdmin()->get('/')
|
||||
|
|
Loading…
Reference in New Issue