Compare commits

..

33 Commits

Author SHA1 Message Date
Dan Brown
d34f837e19 Started work on details/summary blocks 2022-01-21 17:07:27 +00:00
Dan Brown
264966de02 Crawled forward slightly on table resizing 2022-01-21 12:16:05 +00:00
Dan Brown
8b4f112462 Improved iframe embed interaction within editor 2022-01-20 13:55:44 +00:00
Dan Brown
20f37292a1 Added support for iframe node blocks 2022-01-20 13:38:16 +00:00
Dan Brown
b1f5495a7f Shared link mark update logic with color controls 2022-01-19 23:54:59 +00:00
Dan Brown
bb12541179 Improved anchor updating/remove action
Now will update the link mark if you have a no-range selection on the
link.
2022-01-19 23:22:48 +00:00
Dan Brown
e3ead1c115 Added radio options for anchor target option 2022-01-19 22:14:09 +00:00
Dan Brown
9b4ea368dc Started on table editing/resizing 2022-01-19 16:46:45 +00:00
Dan Brown
4b08eef12c Added table creation and insertion 2022-01-19 15:22:10 +00:00
Dan Brown
b2283106fc Added source code view/set button 2022-01-19 11:31:02 +00:00
Dan Brown
7125530e55 Added image resizing via drag handles 2022-01-17 17:43:16 +00:00
Dan Brown
7622106665 Added jsdoc types for prosemirror
Also added link markdown handling when target is set.
2022-01-16 15:21:57 +00:00
Dan Brown
89194a3f85 Got link insert/editor working 2022-01-16 14:37:58 +00:00
Dan Brown
7703face52 Started menu dialog support 2022-01-14 20:56:05 +00:00
Dan Brown
c013d7e549 Added inline code and clear formatting 2022-01-14 18:27:37 +00:00
Dan Brown
07c8876e22 Imported marks from example schema for customization 2022-01-14 14:55:07 +00:00
Dan Brown
0dc64d22ef Added horizonal rule insert 2022-01-14 14:33:37 +00:00
Dan Brown
013943dcc5 Added list buttons 2022-01-14 13:14:25 +00:00
Dan Brown
dc1c9807ef Reorganised & aligned editor icons 2022-01-12 16:10:16 +00:00
Dan Brown
56d7864bdf Added bg-color mark, added color grid selectors 2022-01-12 15:33:59 +00:00
Dan Brown
1018b5627e Added text color mark 2022-01-12 11:02:28 +00:00
Dan Brown
717557df89 Rolled out text alignment to other block types
Completed off alignment types and markdown handling in the process.
2022-01-12 10:18:06 +00:00
Dan Brown
6744ab2ff9 Got alignment buttons barely working for paragraphs 2022-01-11 18:58:24 +00:00
Dan Brown
4e5153d372 Copied in default node types for control and future editing 2022-01-11 17:13:40 +00:00
Dan Brown
34db138a64 Split marks and nodes into their own files 2022-01-11 16:26:12 +00:00
Dan Brown
c3595b1807 Added strike, sup and sub marks 2022-01-11 16:00:57 +00:00
Dan Brown
a8f48185b5 Got underline working in editor
Major step, since this is the first inline HTML element which needed
advanced parsing out on the markdown side, since not commonmark
supported.
2022-01-10 13:38:32 +00:00
Dan Brown
9d7174557e Added in a custom menubar
This is a copy of the ProseMirror/prosemirror-menu repo files
which suggest working from a fork of this.

These changes include the ability to select callouts
from the menubar.
2022-01-09 16:37:16 +00:00
Dan Brown
47c3d4fc0f Fixed issue with new nodes being callouts 2022-01-07 21:56:04 +00:00
Dan Brown
81dfe9c345 Got callouts about working, simplified markdown setup 2022-01-07 21:22:07 +00:00
Dan Brown
0fb8ba00a5 Attempted adding tricky custom block
Attempted adding callouts, which have the challenge of being shown via
HTML within markdown content. Got stuck on parsing back to the state
from markdown.
2022-01-07 16:37:36 +00:00
Dan Brown
aa9fe9ca82 Added notes file 2022-01-07 13:36:53 +00:00
Dan Brown
27f9e8e4bd Started playing with prosemirror
- Got base setup together with WYSIWYG/Markdown switching, where HTML is
  the base content format.
- Added some testing routes/views for initial development.
- Added some dev npm tasks to support editor-specific actions.
2022-01-07 13:36:52 +00:00
588 changed files with 15821 additions and 20100 deletions

View File

@@ -42,7 +42,7 @@ APP_TIMEZONE=UTC
# overrides can be made. Defaults to disabled.
APP_THEME=false
# Trusted proxies
# Trusted Proxies
# Used to indicate trust of systems that proxy to the application so
# certain header values (Such as "X-Forwarded-For") can be used from the
# incoming proxy request to provide origin detail.
@@ -58,13 +58,6 @@ DB_DATABASE=database_database
DB_USERNAME=database_username
DB_PASSWORD=database_user_password
# MySQL specific connection options
# Path to Certificate Authority (CA) certificate file for your MySQL instance.
# When this option is used host name identity verification will be performed
# which checks the hostname, used by the client, against names within the
# certificate itself (Common Name or Subject Alternative Name).
MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
# Mail system to use
# Can be 'smtp' or 'sendmail'
MAIL_DRIVER=smtp
@@ -223,7 +216,6 @@ LDAP_DUMP_USER_DETAILS=false
LDAP_USER_TO_GROUPS=false
LDAP_GROUP_ATTRIBUTE="memberOf"
LDAP_REMOVE_FROM_GROUPS=false
LDAP_DUMP_USER_GROUPS=false
# SAML authentication configuration
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
@@ -305,11 +297,6 @@ RECYCLE_BIN_LIFETIME=30
# Maximum file size, in megabytes, that can be uploaded to the system.
FILE_UPLOAD_SIZE_LIMIT=50
# Export Page Size
# Primarily used to determine page size of PDF exports.
# Can be 'a4' or 'letter'.
EXPORT_PAGE_SIZE=a4
# Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false
@@ -332,13 +319,6 @@ ALLOW_UNTRUSTED_SERVER_FETCHING=false
# Setting this option will also auto-adjust cookies to be SameSite=None.
ALLOWED_IFRAME_HOSTS=null
# A list of sources/hostnames that can be loaded within iframes within BookStack.
# Space separated if multiple. BookStack host domain is auto-inferred.
# Can be set to a lone "*" to allow all sources for iframe content (Not advised).
# Defaults to a set of common services.
# Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"
# The default and maximum item-counts for listing API requests.
API_DEFAULT_ITEM_COUNT=100
API_MAX_ITEM_COUNT=500

View File

@@ -1,5 +1,6 @@
name: New API Endpoint or API Ability
description: Request a new endpoint or API feature be added
title: "[API Request]: "
labels: [":nut_and_bolt: API Request"]
body:
- type: textarea

View File

@@ -1,5 +1,6 @@
name: Bug Report
description: Create a report to help us improve or fix things
title: "[Bug Report]: "
labels: [":bug: Bug"]
body:
- type: textarea
@@ -35,15 +36,6 @@ body:
description: Provide any additional context and screenshots here to help us solve this issue
validations:
required: false
- type: input
id: browserdetails
attributes:
label: Browser Details
description: |
If this is an issue that occurs when using the BookStack interface, please provide details of the browser used which presents the reported issue.
placeholder: (eg. Firefox 97 (64-bit) on Windows 11)
validations:
required: false
- type: input
id: bsversion
attributes:

View File

@@ -1,5 +1,6 @@
name: Feature Request
description: Request a new feature or idea to be added to BookStack
description: Request a new language to be added to CrowdIn for you to translate
title: "[Feature Request]: "
labels: [":hammer: Feature Request"]
body:
- type: textarea
@@ -12,41 +13,8 @@ body:
- type: textarea
id: benefits
attributes:
label: Describe the benefits this would bring to existing BookStack users
description: |
Explain the measurable benefits this feature would achieve for existing BookStack users.
These benefits should details outcomes in terms of what this request solves/achieves, and should not be specific to implementation.
This helps us understand the core desired goal so that a variety of potential implementations could be explored.
This field is important. Lack if input here may lead to early issue closure.
validations:
required: true
- type: textarea
id: already_achieved
attributes:
label: Can the goal of this request already be achieved via other means?
description: |
Yes/No. If yes, please describe how the requested approach fits in with the existing method.
validations:
required: true
- type: checkboxes
id: confirm-search
attributes:
label: Have you searched for an existing open/closed issue?
description: |
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundemental benefit/goal of your request.
options:
- label: I have searched for existing issues and none cover my fundemental request
required: true
- type: dropdown
id: existing_usage
attributes:
label: How long have you been using BookStack?
options:
- Not using yet, just scoping
- 0 to 6 months
- 6 months to 1 year
- 1 to 5 years
- Over 5 years
label: Describe the benefits this feature would bring to BookStack users
description: Explain the measurable benefits this feature would achieve for existing BookStack users
validations:
required: true
- type: textarea

View File

@@ -1,5 +1,6 @@
name: Language Request
description: Request a new language to be added to CrowdIn for you to translate
title: "[Language Request]: "
labels: [":earth_africa: Translations"]
assignees:
- ssddanbrown

View File

@@ -1,5 +1,6 @@
name: Support Request
description: Request support for a specific problem you have not been able to solve yourself
title: "[Support Request]: "
labels: [":dog2: Support"]
body:
- type: checkboxes

View File

@@ -158,7 +158,7 @@ HenrijsS :: Latvian
Pascal R-B (pborgner) :: German
Boris (Ginfred) :: Russian
Jonas Anker Rasmussen (jonasanker) :: Danish
Gerwin de Keijzer (gdekeijzer) :: Dutch; German Informal; German
Gerwin de Keijzer (gdekeijzer) :: Dutch; German; German Informal
kometchtech :: Japanese
Auri (Atalonica) :: Catalan
Francesco Franchina (ffranchina) :: Italian
@@ -210,31 +210,3 @@ Tomáš Batelka (Vofy) :: Czech
Mundo Racional (ismael.mesquita) :: Portuguese, Brazilian
Zarik (3apuk) :: Russian
Ali Shaatani (a.shaatani) :: Arabic
ChacMaster :: Portuguese, Brazilian
Saeed (saeed205) :: Persian
Julesdevops :: French
peter cerny (posli.to.semka) :: Slovak
Pavel Karlin (pavelkarlin) :: Russian
SmokingCrop :: Dutch
Maciej Lebiest (Szwendacz) :: Polish
DiscordDigital :: German; German Informal
Gábor Marton (dodver) :: Hungarian
Jasell :: Swedish
Ghost_chu (ghostchu) :: Chinese Simplified
Ravid Shachar (ravidshachar) :: Hebrew
Helga Guchshenskaya (guchshenskaya) :: Russian
daniel chou (chou0214) :: Chinese Traditional
Manolis PATRIARCHE (m.patriarche) :: French
Mohammed Haboubi (haboubi92) :: Arabic
roncallyt :: Portuguese, Brazilian
goegol :: Dutch
msevgen :: Turkish
Khroners :: French
MASOUD HOSSEINY (masoudme) :: Persian
Thomerson Roncally (roncallyt) :: Portuguese, Brazilian
metaarch :: Bulgarian
Xabi (xabikip) :: Basque
pedromcsousa :: Portuguese
Nir Louk (looknear) :: Hebrew
Alex (qianmengnet) :: Chinese Simplified
stothew :: German

View File

@@ -1,14 +1,19 @@
name: phpstan
on: [push, pull_request]
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.4']
php: ['7.3']
steps:
- uses: actions/checkout@v1

View File

@@ -1,14 +1,19 @@
name: phpunit
on: [push, pull_request]
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.4', '8.0', '8.1']
php: ['7.3', '7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1

View File

@@ -1,14 +1,19 @@
name: test-migrations
on: [push, pull_request]
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.4', '8.0', '8.1']
php: ['7.3', '7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1

6
.gitignore vendored
View File

@@ -5,10 +5,10 @@ Homestead.yaml
.idea
npm-debug.log
yarn-error.log
/public/dist/*.map
/public/dist
/public/plugins
/public/css/*.map
/public/js/*.map
/public/css
/public/js
/public/bower
/public/build/
/storage/images

49
TODO Normal file
View File

@@ -0,0 +1,49 @@
### Next
- Table cell height resize & cell width resize via width style
- Column resize source: https://github.com/ProseMirror/prosemirror-tables/blob/master/src/columnresizing.js
- Have updated column resizing to set cell widths
- Now need to handle table overall size on change, then heights.
- Details/Summary
- Need view to control summary editability, make readonly but editable via popover.
- Need some default styles to visualise details boundary.
- Markdown parser needs to be updated to handle separate open/close tags for blocks.
### In-Progress
- Tables
- Details/Summary
### Features
- Images
- Drawings
- LTR/RTL control
- Fullscreen
- Paste Image Uploading
- Drag + Drop Image Uploading
- Checkbox/TODO list items
- Code blocks
- Indents
- Attachment integration (Drag & drop)
- Template system integration.
### Improvements
- List type changing.
- Color picker options should have "clear" option.
- Color picker buttons should be split, with button to re-apply last selected color.
- Color picker options should change color if different instead of remove.
- Clear formatting, If no selection range, clear the formatting of parent block.
- If no marks, clear the block type if text type?
- Remove links button? (Action already in place if link href is empty).
- Links - Validate URL.
- Links - Integrate entity picker.
- iFrame - Parse iframe HTML & auto-convert youtube/vimeo urls to embeds.
### Notes
- Use NodeViews for embedded content (Code, Drawings) where control is needed.
- Probably still easiest to have seperate (codemirror) MD editor. Can alter display output via NodeViews to make MD like
but its tricky since editing the markdown content would change the block definition/type while editing.

View File

@@ -3,14 +3,17 @@
namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Theme;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use BookStack\Theming\ThemeEvents;
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;
@@ -21,16 +24,31 @@ class DispatchWebhookJob implements ShouldQueue
use Queueable;
use SerializesModels;
protected Webhook $webhook;
protected string $event;
protected User $initiator;
protected int $initiatedTime;
/**
* @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.
*
@@ -52,8 +70,8 @@ class DispatchWebhookJob implements ShouldQueue
*/
public function handle()
{
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime);
$webhookData = $themeResponse ?? WebhookFormatter::getDefault($this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime)->format();
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail);
$webhookData = $themeResponse ?? $this->buildWebhookData();
$lastError = null;
try {
@@ -79,4 +97,36 @@ class DispatchWebhookJob implements ShouldQueue
$this->webhook->save();
}
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;
}
}

View File

@@ -1,124 +0,0 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use Illuminate\Support\Carbon;
class WebhookFormatter
{
protected Webhook $webhook;
protected string $event;
protected User $initiator;
protected int $initiatedTime;
/**
* @var string|Loggable
*/
protected $detail;
/**
* @var array{condition: callable(string, Model):bool, format: callable(Model):void}[]
*/
protected $modelFormatters = [];
public function __construct(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime)
{
$this->webhook = $webhook;
$this->event = $event;
$this->initiator = $initiator;
$this->initiatedTime = $initiatedTime;
$this->detail = is_object($detail) ? clone $detail : $detail;
}
public function format(): array
{
$data = [
'event' => $this->event,
'text' => $this->formatText(),
'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->formatModel();
}
return $data;
}
/**
* @param callable(string, Model):bool $condition
* @param callable(Model):void $format
*/
public function addModelFormatter(callable $condition, callable $format): void
{
$this->modelFormatters[] = [
'condition' => $condition,
'format' => $format,
];
}
public function addDefaultModelFormatters(): void
{
// Load entity owner, creator, updater details
$this->addModelFormatter(
fn ($event, $model) => ($model instanceof Entity),
fn ($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy'])
);
// Load revision detail for page update and create events
$this->addModelFormatter(
fn ($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)),
fn ($model) => $model->load('currentRevision')
);
}
protected function formatModel(): array
{
/** @var Model $model */
$model = $this->detail;
$model->unsetRelations();
foreach ($this->modelFormatters as $formatter) {
if ($formatter['condition']($this->event, $model)) {
$formatter['format']($model);
}
}
return $model->toArray();
}
protected function formatText(): string
{
$textParts = [
$this->initiator->name,
trans('activities.' . $this->event),
];
if ($this->detail instanceof Entity) {
$textParts[] = '"' . $this->detail->name . '"';
}
return implode(' ', $textParts);
}
public static function getDefault(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime): self
{
$instance = new self($event, $webhook, $detail, $initiator, $initiatedTime);
$instance->addDefaultModelFormatters();
return $instance;
}
}

View File

@@ -3,13 +3,11 @@
namespace BookStack\Api;
use BookStack\Http\Controllers\Api\ApiController;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
@@ -102,37 +100,11 @@ class ApiDocsGenerator
$this->controllerClasses[$className] = $class;
}
$rules = collect($class->getValidationRules()[$methodName] ?? [])->map(function ($validations) {
return array_map(function ($validation) {
return $this->getValidationAsString($validation);
}, $validations);
})->toArray();
$rules = $class->getValdationRules()[$methodName] ?? [];
return empty($rules) ? null : $rules;
}
/**
* Convert the given validation message to a readable string.
*/
protected function getValidationAsString($validation): string
{
if (is_string($validation)) {
return $validation;
}
if (is_object($validation) && method_exists($validation, '__toString')) {
return strval($validation);
}
if ($validation instanceof Password) {
return 'min:8';
}
$class = get_class($validation);
throw new Exception("Cannot provide string representation of rule for class: {$class}");
}
/**
* Parse out the description text from a class method comment.
*/

View File

@@ -2,10 +2,8 @@
namespace BookStack\Api;
use BookStack\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ListingResponseBuilder
@@ -14,11 +12,6 @@ class ListingResponseBuilder
protected $request;
protected $fields;
/**
* @var array<callable>
*/
protected $resultModifiers = [];
protected $filterOperators = [
'eq' => '=',
'ne' => '!=',
@@ -31,7 +24,6 @@ class ListingResponseBuilder
/**
* ListingResponseBuilder constructor.
* The given fields will be forced visible within the model results.
*/
public function __construct(Builder $query, Request $request, array $fields)
{
@@ -43,16 +35,12 @@ class ListingResponseBuilder
/**
* Get the response from this builder.
*/
public function toResponse(): JsonResponse
public function toResponse()
{
$filteredQuery = $this->filterQuery($this->query);
$total = $filteredQuery->count();
$data = $this->fetchData($filteredQuery)->each(function ($model) {
foreach ($this->resultModifiers as $modifier) {
$modifier($model);
}
});
$data = $this->fetchData($filteredQuery);
return response()->json([
'data' => $data,
@@ -61,17 +49,7 @@ class ListingResponseBuilder
}
/**
* Add a callback to modify each element of the results.
*
* @param (callable(Model)) $modifier
*/
public function modifyResults($modifier): void
{
$this->resultModifiers[] = $modifier;
}
/**
* Fetch the data to return within the response.
* Fetch the data to return in the response.
*/
protected function fetchData(Builder $query): Collection
{

View File

@@ -5,7 +5,6 @@ namespace BookStack\Auth\Access\Guards;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
@@ -16,7 +15,7 @@ use Illuminate\Support\Str;
class LdapSessionGuard extends ExternalBaseSessionGuard
{
protected LdapService $ldapService;
protected $ldapService;
/**
* LdapSessionGuard constructor.
@@ -60,9 +59,8 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
* @param array $credentials
* @param bool $remember
*
* @throws LdapException*@throws \BookStack\Exceptions\JsonDebugException
* @throws LoginAttemptException
* @throws JsonDebugException
* @throws LdapException
*
* @return bool
*/
@@ -86,7 +84,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
try {
$user = $this->createNewFromLdapAndCreds($userDetails, $credentials);
} catch (UserRegistrationException $exception) {
throw new LoginAttemptException($exception->getMessage());
throw new LoginAttemptException($exception->message);
}
}

View File

@@ -15,17 +15,12 @@ use Illuminate\Support\Facades\Log;
*/
class LdapService
{
protected Ldap $ldap;
protected GroupSyncService $groupSyncService;
protected UserAvatars $userAvatars;
/**
* @var resource
*/
protected $ldap;
protected $groupSyncService;
protected $ldapConnection;
protected array $config;
protected bool $enabled;
protected $userAvatars;
protected $config;
protected $enabled;
/**
* LdapService constructor.
@@ -279,7 +274,6 @@ class LdapService
* Get the groups a user is a part of on ldap.
*
* @throws LdapException
* @throws JsonDebugException
*/
public function getUserGroups(string $userName): array
{
@@ -291,17 +285,8 @@ class LdapService
}
$userGroups = $this->groupFilter($user);
$allGroups = $this->getGroupsRecursive($userGroups, []);
if ($this->config['dump_user_groups']) {
throw new JsonDebugException([
'details_from_ldap' => $user,
'parsed_direct_user_groups' => $userGroups,
'parsed_recursive_user_groups' => $allGroups,
]);
}
return $allGroups;
return $this->getGroupsRecursive($userGroups, []);
}
/**
@@ -384,7 +369,6 @@ class LdapService
* Sync the LDAP groups to the user roles for the current user.
*
* @throws LdapException
* @throws JsonDebugException
*/
public function syncGroups(User $user, string $username)
{

View File

@@ -1,9 +0,0 @@
<?php
namespace BookStack\Auth\Access\Oidc;
use Exception;
class OidcException extends Exception
{
}

View File

@@ -2,8 +2,6 @@
namespace BookStack\Auth\Access\Oidc;
use Exception;
class OidcIssuerDiscoveryException extends Exception
class OidcIssuerDiscoveryException extends \Exception
{
}

View File

@@ -60,11 +60,8 @@ class OidcJwtSigningKey
*/
protected function loadFromJwkArray(array $jwk)
{
// 'alg' is optional for a JWK, but we will still attempt to validate if
// it exists otherwise presume it will be compatible.
$alg = $jwk['alg'] ?? null;
if ($jwk['kty'] !== 'RSA' || !(is_null($alg) || $alg === 'RS256')) {
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
if ($jwk['alg'] !== 'RS256') {
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}");
}
if (empty($jwk['use'])) {

View File

@@ -164,9 +164,7 @@ class OidcProviderSettings
protected function filterKeys(array $keys): array
{
return array_filter($keys, function (array $key) {
$alg = $key['alg'] ?? null;
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256';
});
}

View File

@@ -7,12 +7,14 @@ use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\OpenIdConnectException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use function config;
use Exception;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface as HttpClient;
use function trans;
use function url;
@@ -23,9 +25,9 @@ use function url;
*/
class OidcService
{
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected HttpClient $httpClient;
protected $registrationService;
protected $loginService;
protected $httpClient;
/**
* OpenIdService constructor.
@@ -40,8 +42,6 @@ class OidcService
/**
* Initiate an authorization flow.
*
* @throws OidcException
*
* @return array{url: string, state: string}
*/
public function login(): array
@@ -57,15 +57,14 @@ class OidcService
/**
* Process the Authorization response from the authorization server and
* return the matching, or new if registration active, user matched to the
* authorization server. Throws if the user cannot be auth if not authenticated.
* return the matching, or new if registration active, user matched to
* the authorization server.
* Returns null if not authenticated.
*
* @throws JsonDebugException
* @throws OidcException
* @throws StoppedAuthenticationException
* @throws IdentityProviderException
* @throws Exception
* @throws ClientExceptionInterface
*/
public function processAuthorizeResponse(?string $authorizationCode): User
public function processAuthorizeResponse(?string $authorizationCode): ?User
{
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
@@ -79,7 +78,8 @@ class OidcService
}
/**
* @throws OidcException
* @throws OidcIssuerDiscoveryException
* @throws ClientExceptionInterface
*/
protected function getProviderSettings(): OidcProviderSettings
{
@@ -100,11 +100,7 @@ class OidcService
// Run discovery
if ($config['discover'] ?? false) {
try {
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
} catch (OidcIssuerDiscoveryException $exception) {
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
}
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
}
$settings->validate();
@@ -165,8 +161,9 @@ class OidcService
* Processes a received access token for a user. Login the user when
* they exist, optionally registering them automatically.
*
* @throws OidcException
* @throws OpenIdConnectException
* @throws JsonDebugException
* @throws UserRegistrationException
* @throws StoppedAuthenticationException
*/
protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
@@ -185,28 +182,28 @@ class OidcService
try {
$idToken->validate($settings->clientId);
} catch (OidcInvalidTokenException $exception) {
throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}");
}
$userDetails = $this->getUserDetails($idToken);
$isLoggedIn = auth()->check();
if (empty($userDetails['email'])) {
throw new OidcException(trans('errors.oidc_no_email_address'));
throw new OpenIdConnectException(trans('errors.oidc_no_email_address'));
}
if ($isLoggedIn) {
throw new OidcException(trans('errors.oidc_already_logged_in'));
throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login');
}
try {
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
);
} catch (UserRegistrationException $exception) {
throw new OidcException($exception->getMessage());
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
);
if ($user === null) {
throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
}
$this->loginService->login($user, 'oidc');

View File

@@ -96,8 +96,7 @@ class RegistrationService
}
// Create the user
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
$newUser->attachDefaultRole();
$newUser = $this->userRepo->registerNew($userData, $emailConfirmed);
// Assign social account if given
if ($socialAccount) {

View File

@@ -1,39 +0,0 @@
<?php
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Get all the users with their permissions in a paginated format.
* Note: Due to the use of email search this should only be used when
* user is assumed to be trusted. (Admin users).
* Email search can be abused to extract email addresses.
*/
class AllUsersPaginatedAndSorted
{
/**
* @param array{sort: string, order: string, search: string} $sortData
*/
public function run(int $count, array $sortData): LengthAwarePaginator
{
$sort = $sortData['sort'];
$query = User::query()->select(['*'])
->scopes(['withLastActivityAt'])
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $sortData['order']);
if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%';
$query->where(function ($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('email', 'like', $term);
});
}
return $query->paginate($count);
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
/**
* Get asset created counts for the given user.
*/
class UserContentCounts
{
/**
* @return array{pages: int, chapters: int, books: int, shelves: int}
*/
public function run(User $user): array
{
$createdBy = ['created_by' => $user->id];
return [
'pages' => Page::visible()->where($createdBy)->count(),
'chapters' => Chapter::visible()->where($createdBy)->count(),
'books' => Book::visible()->where($createdBy)->count(),
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
];
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
/**
* Get the recently created content for the provided user.
*/
class UserRecentlyCreatedContent
{
/**
* @return array{pages: Collection, chapters: Collection, books: Collection, shelves: Collection}
*/
public function run(User $user, int $count): array
{
$query = function (Builder $query) use ($user, $count) {
return $query->orderBy('created_at', 'desc')
->where('created_by', '=', $user->id)
->take($count)
->get();
};
return [
'pages' => $query(Page::visible()->where('draft', '=', false)),
'chapters' => $query(Chapter::visible()),
'books' => $query(Book::visible()),
'shelves' => $query(Bookshelf::visible()),
];
}
}

View File

@@ -28,8 +28,6 @@ class Role extends Model implements Loggable
protected $fillable = ['display_name', 'description', 'external_auth_id'];
protected $hidden = ['pivot'];
/**
* The roles that belong to the role.
*/

View File

@@ -72,20 +72,22 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected $hidden = [
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
'created_at', 'updated_at', 'image_id', 'roles', 'avatar', 'user_id',
'created_at', 'updated_at', 'image_id',
];
/**
* This holds the user's permissions when loaded.
*
* @var ?Collection
*/
protected ?Collection $permissions;
protected $permissions;
/**
* This holds the default user when loaded.
*
* @var null|User
*/
protected static ?User $defaultUser = null;
protected static $defaultUser = null;
/**
* Returns the default public user.
@@ -144,7 +146,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function attachDefaultRole(): void
{
$roleId = intval(setting('registration-role'));
$roleId = setting('registration-role');
if ($roleId && $this->roles()->where('id', '=', $roleId)->count() === 0) {
$this->roles()->attach($roleId);
}

View File

@@ -2,29 +2,30 @@
namespace BookStack\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Entities\EntityProvider;
use BookStack\Exceptions\NotifyException;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Facades\Activity;
use BookStack\Uploads\UserAvatars;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class UserRepo
{
protected UserAvatars $userAvatar;
protected UserInviteService $inviteService;
protected $userAvatar;
/**
* UserRepo constructor.
*/
public function __construct(UserAvatars $userAvatar, UserInviteService $inviteService)
public function __construct(UserAvatars $userAvatar)
{
$this->userAvatar = $userAvatar;
$this->inviteService = $inviteService;
}
/**
@@ -52,164 +53,70 @@ class UserRepo
}
/**
* Create a new basic instance of user with the given pre-validated data.
*
* @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data
* Get all the users with their permissions.
*/
public function createWithoutActivity(array $data, bool $emailConfirmed = false): User
public function getAllUsers(): Collection
{
$user = new User();
$user->name = $data['name'];
$user->email = $data['email'];
$user->password = bcrypt(empty($data['password']) ? Str::random(32) : $data['password']);
$user->email_confirmed = $emailConfirmed;
$user->external_auth_id = $data['external_auth_id'] ?? '';
return User::query()->with('roles', 'avatar')->orderBy('name', 'asc')->get();
}
$user->refreshSlug();
$user->save();
/**
* Get all the users with their permissions in a paginated format.
* Note: Due to the use of email search this should only be used when
* user is assumed to be trusted. (Admin users).
* Email search can be abused to extract email addresses.
*/
public function getAllUsersPaginatedAndSorted(int $count, array $sortData): LengthAwarePaginator
{
$sort = $sortData['sort'];
if (!empty($data['language'])) {
setting()->putUser($user, 'language', $data['language']);
$query = User::query()->select(['*'])
->scopes(['withLastActivityAt'])
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $sortData['order']);
if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%';
$query->where(function ($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('email', 'like', $term);
});
}
if (isset($data['roles'])) {
$this->setUserRoles($user, $data['roles']);
}
return $query->paginate($count);
}
/**
* Creates a new user and attaches a role to them.
*/
public function registerNew(array $data, bool $emailConfirmed = false): User
{
$user = $this->create($data, $emailConfirmed);
$user->attachDefaultRole();
$this->downloadAndAssignUserAvatar($user);
return $user;
}
/**
* As per "createWithoutActivity" but records a "create" activity.
* Assign a user to a system-level role.
*
* @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data
* @throws NotFoundException
*/
public function create(array $data, bool $sendInvite = false): User
public function attachSystemRole(User $user, string $systemRoleName)
{
$user = $this->createWithoutActivity($data, true);
if ($sendInvite) {
$this->inviteService->sendInvitation($user);
}
Activity::add(ActivityType::USER_CREATE, $user);
return $user;
}
/**
* Update the given user with the given data.
*
* @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string} $data
*
* @throws UserUpdateException
*/
public function update(User $user, array $data, bool $manageUsersAllowed): User
{
if (!empty($data['name'])) {
$user->name = $data['name'];
$user->refreshSlug();
}
if (!empty($data['email']) && $manageUsersAllowed) {
$user->email = $data['email'];
}
if (!empty($data['external_auth_id']) && $manageUsersAllowed) {
$user->external_auth_id = $data['external_auth_id'];
}
if (isset($data['roles']) && $manageUsersAllowed) {
$this->setUserRoles($user, $data['roles']);
}
if (!empty($data['password'])) {
$user->password = bcrypt($data['password']);
}
if (!empty($data['language'])) {
setting()->putUser($user, 'language', $data['language']);
}
$user->save();
Activity::add(ActivityType::USER_UPDATE, $user);
return $user;
}
/**
* Remove the given user from storage, Delete all related content.
*
* @throws Exception
*/
public function destroy(User $user, ?int $newOwnerId = null)
{
$this->ensureDeletable($user);
$user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->delete();
// Delete user profile images
$this->userAvatar->destroyAllForUser($user);
if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
$this->migrateOwnership($user, $newOwner);
}
}
Activity::add(ActivityType::USER_DELETE, $user);
}
/**
* @throws NotifyException
*/
protected function ensureDeletable(User $user): void
{
if ($this->isOnlyAdmin($user)) {
throw new NotifyException(trans('errors.users_cannot_delete_only_admin'), $user->getEditUrl());
}
if ($user->system_name === 'public') {
throw new NotifyException(trans('errors.users_cannot_delete_guest'), $user->getEditUrl());
}
}
/**
* Migrate ownership of items in the system from one user to another.
*/
protected function migrateOwnership(User $fromUser, User $toUser)
{
$entities = (new EntityProvider())->all();
foreach ($entities as $instance) {
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $toUser->id]);
}
}
/**
* Get an avatar image for a user and set it as their avatar.
* Returns early if avatars disabled or not set in config.
*/
protected function downloadAndAssignUserAvatar(User $user): void
{
try {
$this->userAvatar->fetchAndAssignToUser($user);
} catch (Exception $e) {
Log::error('Failed to save user avatar image');
$role = Role::getSystemRole($systemRoleName);
if (is_null($role)) {
throw new NotFoundException("Role '{$systemRoleName}' not found");
}
$user->attachRole($role);
}
/**
* Checks if the give user is the only admin.
*/
protected function isOnlyAdmin(User $user): bool
public function isOnlyAdmin(User $user): bool
{
if (!$user->hasSystemRole('admin')) {
return false;
@@ -228,7 +135,7 @@ class UserRepo
*
* @throws UserUpdateException
*/
protected function setUserRoles(User $user, array $roles)
public function setUserRoles(User $user, array $roles)
{
if ($this->demotingLastAdmin($user, $roles)) {
throw new UserUpdateException(trans('errors.role_cannot_remove_only_admin'), $user->getEditUrl());
@@ -252,4 +159,117 @@ class UserRepo
return false;
}
/**
* Create a new basic instance of user.
*/
public function create(array $data, bool $emailConfirmed = false): User
{
$details = [
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
'email_confirmed' => $emailConfirmed,
'external_auth_id' => $data['external_auth_id'] ?? '',
];
$user = new User();
$user->forceFill($details);
$user->refreshSlug();
$user->save();
return $user;
}
/**
* Remove the given user from storage, Delete all related content.
*
* @throws Exception
*/
public function destroy(User $user, ?int $newOwnerId = null)
{
$user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->delete();
// Delete user profile images
$this->userAvatar->destroyAllForUser($user);
if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
$this->migrateOwnership($user, $newOwner);
}
}
}
/**
* Migrate ownership of items in the system from one user to another.
*/
protected function migrateOwnership(User $fromUser, User $toUser)
{
$entities = (new EntityProvider())->all();
foreach ($entities as $instance) {
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $toUser->id]);
}
}
/**
* Get the recently created content for this given user.
*/
public function getRecentlyCreated(User $user, int $count = 20): array
{
$query = function (Builder $query) use ($user, $count) {
return $query->orderBy('created_at', 'desc')
->where('created_by', '=', $user->id)
->take($count)
->get();
};
return [
'pages' => $query(Page::visible()->where('draft', '=', false)),
'chapters' => $query(Chapter::visible()),
'books' => $query(Book::visible()),
'shelves' => $query(Bookshelf::visible()),
];
}
/**
* Get asset created counts for the give user.
*/
public function getAssetCounts(User $user): array
{
$createdBy = ['created_by' => $user->id];
return [
'pages' => Page::visible()->where($createdBy)->count(),
'chapters' => Chapter::visible()->where($createdBy)->count(),
'books' => Book::visible()->where($createdBy)->count(),
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
];
}
/**
* Get the roles in the system that are assignable to a user.
*/
public function getAllRoles(): Collection
{
return Role::query()->orderBy('display_name', 'asc')->get();
}
/**
* Get an avatar image for a user and set it as their avatar.
* Returns early if avatars disabled or not set in config.
*/
public function downloadAndAssignUserAvatar(User $user): void
{
try {
$this->userAvatar->fetchAndAssignToUser($user);
} catch (Exception $e) {
Log::error('Failed to save user avatar image');
}
}
}

View File

@@ -57,13 +57,6 @@ return [
// Space separated if multiple. BookStack host domain is auto-inferred.
'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null),
// A list of sources/hostnames that can be loaded within iframes within BookStack.
// Space separated if multiple. BookStack host domain is auto-inferred.
// Can be set to a lone "*" to allow all sources for iframe content (Not advised).
// Defaults to a set of common services.
// Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),
// Application timezone for back-end date functions.
'timezone' => env('APP_TIMEZONE', 'UTC'),
@@ -71,7 +64,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'],
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'],
// Application Fallback Locale
'fallback_locale' => 'en',

View File

@@ -7,10 +7,6 @@
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$dompdfPaperSizeMap = [
'a4' => 'a4',
'letter' => 'letter',
];
return [
@@ -154,7 +150,7 @@ return [
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
'default_paper_size' => 'a4',
/**
* The default font family.

View File

@@ -119,7 +119,6 @@ return [
'ldap' => [
'server' => env('LDAP_SERVER', false),
'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', false),
'dump_user_groups' => env('LDAP_DUMP_USER_GROUPS', false),
'dn' => env('LDAP_DN', false),
'pass' => env('LDAP_PASS', false),
'base_dn' => env('LDAP_BASE_DN', false),

View File

@@ -7,10 +7,6 @@
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$snappyPaperSizeMap = [
'a4' => 'A4',
'letter' => 'Letter',
];
return [
'pdf' => [
@@ -18,8 +14,7 @@ return [
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
'timeout' => false,
'options' => [
'outline' => true,
'page-size' => $snappyPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'A4',
'outline' => true,
],
'env' => [],
],

View File

@@ -2,12 +2,9 @@
namespace BookStack\Console\Commands;
use BookStack\Auth\Role;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
@@ -22,8 +19,7 @@ class CreateAdmin extends Command
protected $signature = 'bookstack:create-admin
{--email= : The email address for the new admin user}
{--name= : The name of the new admin user}
{--password= : The password to assign to the new admin user}
{--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}';
{--password= : The password to assign to the new admin user}';
/**
* The console command description.
@@ -46,35 +42,28 @@ class CreateAdmin extends Command
/**
* Execute the console command.
*
* @throws NotFoundException
* @throws \BookStack\Exceptions\NotFoundException
*
* @return mixed
*/
public function handle()
{
$details = $this->snakeCaseOptions();
$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'])) {
if (empty($details['external_auth_id'])) {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
} else {
$details['password'] = Str::random(32);
}
$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_without:external_auth_id', Password::default()],
'external_auth_id' => ['required_without:password'],
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
'name' => ['required', 'min:2'],
'password' => ['required', Password::default()],
]);
if ($validator->fails()) {
@@ -85,8 +74,9 @@ class CreateAdmin extends Command
return SymfonyCommand::FAILURE;
}
$user = $this->userRepo->createWithoutActivity($validator->validated());
$user->attachRole(Role::getSystemRole('admin'));
$user = $this->userRepo->create($validator->validated());
$this->userRepo->attachSystemRole($user, 'admin');
$this->userRepo->downloadAndAssignUserAvatar($user);
$user->email_confirmed = true;
$user->save();
@@ -94,14 +84,4 @@ class CreateAdmin extends Command
return SymfonyCommand::SUCCESS;
}
protected function snakeCaseOptions(): array
{
$returnOpts = [];
foreach ($this->options() as $key => $value) {
$returnOpts[str_replace('-', '_', $key)] = $value;
}
return $returnOpts;
}
}

View File

@@ -15,6 +15,8 @@ class DeleteUsers extends Command
*/
protected $signature = 'bookstack:delete-users';
protected $user;
protected $userRepo;
/**
@@ -24,8 +26,9 @@ class DeleteUsers extends Command
*/
protected $description = 'Delete users that are not "admin" or system users';
public function __construct(UserRepo $userRepo)
public function __construct(User $user, UserRepo $userRepo)
{
$this->user = $user;
$this->userRepo = $userRepo;
parent::__construct();
}
@@ -35,8 +38,8 @@ class DeleteUsers extends Command
$confirm = $this->ask('This will delete all users from the system that are not "admin" or system users. Are you sure you want to continue? (Type "yes" to continue)');
$numDeleted = 0;
if (strtolower(trim($confirm)) === 'yes') {
$totalUsers = User::query()->count();
$users = User::query()->whereNull('system_name')->with('roles')->get();
$totalUsers = $this->user->count();
$users = $this->user->where('system_name', '=', null)->with('roles')->get();
foreach ($users as $user) {
if ($user->hasSystemRole('admin')) {
// don't delete users with "admin" role

View File

@@ -59,7 +59,7 @@ class Deletion extends Model implements Loggable
/**
* Get a URL for this specific deletion.
*/
public function getUrl(string $path = 'restore'): string
public function getUrl($path): string
{
return url("/settings/recycle-bin/{$this->id}/" . ltrim($path, '/'));
}

View File

@@ -36,7 +36,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string $slug
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Carbon $deleted_at
* @property int $created_by
* @property int $updated_by
* @property bool $restricted

View File

@@ -10,22 +10,19 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* Class Page.
*
* @property int $chapter_id
* @property string $html
* @property string $markdown
* @property string $text
* @property bool $template
* @property bool $draft
* @property int $revision_count
* @property Chapter $chapter
* @property Collection $attachments
* @property Collection $revisions
* @property PageRevision $currentRevision
* @property int $chapter_id
* @property string $html
* @property string $markdown
* @property string $text
* @property bool $template
* @property bool $draft
* @property int $revision_count
* @property Chapter $chapter
* @property Collection $attachments
*/
class Page extends BookChild
{
@@ -85,19 +82,6 @@ class Page extends BookChild
->orderBy('id', 'desc');
}
/**
* Get the current revision for the page if existing.
*
* @return PageRevision|null
*/
public function currentRevision(): HasOne
{
return $this->hasOne(PageRevision::class)
->where('type', '=', 'version')
->orderBy('created_at', 'desc')
->orderBy('id', 'desc');
}
/**
* Get all revision instances assigned to this page.
* Includes all types of revisions.
@@ -133,6 +117,16 @@ class Page extends BookChild
return url('/' . implode('/', $parts));
}
/**
* Get the current revision for the page if existing.
*
* @return PageRevision|null
*/
public function getCurrentRevision()
{
return $this->revisions()->first();
}
/**
* Get this page for JSON display.
*/

View File

@@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Class PageRevision.
*
* @property mixed $id
* @property int $page_id
* @property string $slug
* @property string $book_slug
@@ -28,7 +27,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PageRevision extends Model
{
protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
protected $hidden = ['html', 'markdown', 'restricted', 'text'];
/**
* Get the user that created the page revision.
@@ -48,10 +46,19 @@ class PageRevision extends Model
/**
* Get the url for this revision.
*
* @param null|string $path
*
* @return string
*/
public function getUrl(string $path = ''): string
public function getUrl($path = null)
{
return $this->page->getUrl('/revisions/' . $this->id . '/' . ltrim($path, '/'));
$url = $this->page->getUrl() . '/revisions/' . $this->id;
if ($path) {
return $url . '/' . trim($path, '/');
}
return $url;
}
/**

View File

@@ -7,7 +7,6 @@ use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Uploads\ImageService;
use BookStack\Util\CspService;
use DOMDocument;
use DOMElement;
use DOMXPath;
@@ -16,18 +15,16 @@ use Throwable;
class ExportFormatter
{
protected ImageService $imageService;
protected PdfGenerator $pdfGenerator;
protected CspService $cspService;
protected $imageService;
protected $pdfGenerator;
/**
* ExportService constructor.
*/
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator)
{
$this->imageService = $imageService;
$this->pdfGenerator = $pdfGenerator;
$this->cspService = $cspService;
}
/**
@@ -40,9 +37,8 @@ class ExportFormatter
{
$page->html = (new PageContent($page))->render();
$pageHtml = view('pages.export', [
'page' => $page,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
'page' => $page,
'format' => 'html',
])->render();
return $this->containHtml($pageHtml);
@@ -60,10 +56,9 @@ class ExportFormatter
$page->html = (new PageContent($page))->render();
});
$html = view('chapters.export', [
'chapter' => $chapter,
'pages' => $pages,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
'chapter' => $chapter,
'pages' => $pages,
'format' => 'html',
])->render();
return $this->containHtml($html);
@@ -81,7 +76,6 @@ class ExportFormatter
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
])->render();
return $this->containHtml($html);
@@ -98,7 +92,6 @@ class ExportFormatter
$html = view('pages.export', [
'page' => $page,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
])->render();
return $this->htmlToPdf($html);
@@ -120,7 +113,6 @@ class ExportFormatter
'chapter' => $chapter,
'pages' => $pages,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
])->render();
return $this->htmlToPdf($html);
@@ -138,7 +130,6 @@ class ExportFormatter
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
])->render();
return $this->htmlToPdf($html);
@@ -153,31 +144,10 @@ class ExportFormatter
{
$html = $this->containHtml($html);
$html = $this->replaceIframesWithLinks($html);
$html = $this->openDetailElements($html);
return $this->pdfGenerator->fromHtml($html);
}
/**
* Within the given HTML content, Open any detail blocks.
*/
protected function openDetailElements(string $html): string
{
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$details = $xPath->query('//details');
/** @var DOMElement $detail */
foreach ($details as $detail) {
$detail->setAttribute('open', 'open');
}
return $doc->saveHTML();
}
/**
* Within the given HTML content, replace any iframe elements
* with anchor links within paragraph blocks.
@@ -326,7 +296,7 @@ class ExportFormatter
$text .= $this->pageToMarkdown($page) . "\n\n";
}
return trim($text);
return $text;
}
/**
@@ -338,12 +308,12 @@ class ExportFormatter
$text = '# ' . $book->name . "\n\n";
foreach ($bookTree as $bookChild) {
if ($bookChild instanceof Chapter) {
$text .= $this->chapterToMarkdown($bookChild) . "\n\n";
$text .= $this->chapterToMarkdown($bookChild);
} else {
$text .= $this->pageToMarkdown($bookChild) . "\n\n";
$text .= $this->pageToMarkdown($bookChild);
}
}
return trim($text);
return $text;
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\ConverterInterface;
use League\HTMLToMarkdown\ElementInterface;
class CheckboxConverter implements ConverterInterface
{
public function convert(ElementInterface $element): string
{
if (strtolower($element->getAttribute('type')) === 'checkbox') {
$isChecked = $element->getAttribute('checked') === 'checked';
return $isChecked ? ' [x] ' : ' [ ] ';
}
return $element->getValue();
}
/**
* @return string[]
*/
public function getSupportedTags(): array
{
return ['input'];
}
}

View File

@@ -87,7 +87,6 @@ class HtmlToMarkdown
$environment->addConverter(new CustomParagraphConverter());
$environment->addConverter(new PreformattedConverter());
$environment->addConverter(new TextConverter());
$environment->addConverter(new CheckboxConverter());
return $environment;
}

View File

@@ -109,35 +109,15 @@ class PageContent
/**
* Convert all inline base64 content to uploaded image files.
* Regex is used to locate the start of data-uri definitions then
* manual looping over content is done to parse the whole data uri.
* Attempting to capture the whole data uri using regex can cause PHP
* PCRE limits to be hit with larger, multi-MB, files.
*/
protected function extractBase64ImagesFromMarkdown(string $markdown)
{
$matches = [];
$contentLength = strlen($markdown);
$replacements = [];
preg_match_all('/!\[.*?]\(.*?(data:image\/.{1,6};base64,)/', $markdown, $matches, PREG_OFFSET_CAPTURE);
preg_match_all('/!\[.*?]\(.*?(data:image\/.*?)[)"\s]/', $markdown, $matches);
foreach ($matches[1] as $base64MatchPair) {
[$dataUri, $index] = $base64MatchPair;
for ($i = strlen($dataUri) + $index; $i < $contentLength; $i++) {
$char = $markdown[$i];
if ($char === ')' || $char === ' ' || $char === "\n" || $char === '"') {
break;
}
$dataUri .= $char;
}
$newUrl = $this->base64ImageUriToUploadedImageUrl($dataUri);
$replacements[] = [$dataUri, $newUrl];
}
foreach ($replacements as [$dataUri, $newUrl]) {
$markdown = str_replace($dataUri, $newUrl, $markdown);
foreach ($matches[1] as $base64Match) {
$newUrl = $this->base64ImageUriToUploadedImageUrl($base64Match);
$markdown = str_replace($base64Match, $newUrl, $markdown);
}
return $markdown;
@@ -239,9 +219,6 @@ class PageContent
$html .= $doc->saveHTML($childNode);
}
// Perform required string-level tweaks
$html = str_replace(' ', '&nbsp;', $html);
return $html;
}

View File

@@ -7,15 +7,14 @@ use Barryvdh\Snappy\Facades\SnappyPdf;
class PdfGenerator
{
const ENGINE_DOMPDF = 'dompdf';
const ENGINE_WKHTML = 'wkhtml';
/**
* Generate PDF content from the given HTML content.
*/
public function fromHtml(string $html): string
{
if ($this->getActiveEngine() === self::ENGINE_WKHTML) {
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
if ($useWKHTML) {
$pdf = SnappyPDF::loadHTML($html);
$pdf->setOption('print-media-type', true);
} else {
@@ -24,15 +23,4 @@ class PdfGenerator
return $pdf->output();
}
/**
* Get the currently active PDF engine.
* Returns the value of an `ENGINE_` const on this class.
*/
public function getActiveEngine(): string
{
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
return $useWKHTML ? self::ENGINE_WKHTML : self::ENGINE_DOMPDF;
}
}

View File

@@ -22,12 +22,9 @@ class TrashCan
{
/**
* Send a shelf to the recycle bin.
*
* @throws NotifyException
*/
public function softDestroyShelf(Bookshelf $shelf)
{
$this->ensureDeletable($shelf);
Deletion::createForEntity($shelf);
$shelf->delete();
}
@@ -39,7 +36,6 @@ class TrashCan
*/
public function softDestroyBook(Book $book)
{
$this->ensureDeletable($book);
Deletion::createForEntity($book);
foreach ($book->pages as $page) {
@@ -61,7 +57,6 @@ class TrashCan
public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
{
if ($recordDelete) {
$this->ensureDeletable($chapter);
Deletion::createForEntity($chapter);
}
@@ -82,47 +77,19 @@ class TrashCan
public function softDestroyPage(Page $page, bool $recordDelete = true)
{
if ($recordDelete) {
$this->ensureDeletable($page);
Deletion::createForEntity($page);
}
$page->delete();
}
/**
* Ensure the given entity is deletable.
* Is not for permissions, but logical conditions within the application.
* Will throw if not deletable.
*
* @throws NotifyException
*/
protected function ensureDeletable(Entity $entity): void
{
$customHomeId = intval(explode(':', setting('app-homepage', '0:'))[0]);
$customHomeActive = setting('app-homepage-type') === 'page';
$removeCustomHome = false;
// Check custom homepage usage for pages
if ($entity instanceof Page && $entity->id === $customHomeId) {
if ($customHomeActive) {
throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
// Check if set as custom homepage & remove setting if not used or throw error if active
$customHome = setting('app-homepage', '0:');
if (intval($page->id) === intval(explode(':', $customHome)[0])) {
if (setting('app-homepage-type') === 'page') {
throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
}
$removeCustomHome = true;
}
// Check custom homepage usage within chapters or books
if ($entity instanceof Chapter || $entity instanceof Book) {
if ($entity->pages()->where('id', '=', $customHomeId)->exists()) {
if ($customHomeActive) {
throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
}
$removeCustomHome = true;
}
}
if ($removeCustomHome) {
setting()->remove('app-homepage');
}
$page->delete();
}
/**

View File

@@ -101,10 +101,6 @@ class Handler extends ExceptionHandler
$code = $e->status;
}
if (method_exists($e, 'getStatus')) {
$code = $e->getStatus();
}
$responseData['error']['code'] = $code;
return new JsonResponse($responseData, $code, $headers);

View File

@@ -3,25 +3,23 @@
namespace BookStack\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
class JsonDebugException extends Exception
{
protected array $data;
protected $data;
/**
* JsonDebugException constructor.
*/
public function __construct(array $data)
public function __construct($data)
{
$this->data = $data;
parent::__construct();
}
/**
* Covert this exception into a response.
*/
public function render(): JsonResponse
public function render()
{
return response()->json($this->data);
}

View File

@@ -9,24 +9,17 @@ class NotifyException extends Exception implements Responsable
{
public $message;
public $redirectLocation;
protected $status;
public function __construct(string $message, string $redirectLocation = '/', int $status = 500)
/**
* NotifyException constructor.
*/
public function __construct(string $message, string $redirectLocation = '/')
{
$this->message = $message;
$this->redirectLocation = $redirectLocation;
$this->status = $status;
parent::__construct();
}
/**
* Get the desired status code for this exception.
*/
public function getStatus(): int
{
return $this->status;
}
/**
* Send the response for this type of exception.
*
@@ -36,11 +29,6 @@ class NotifyException extends Exception implements Responsable
{
$message = $this->getMessage();
// Front-end JSON handling. API-side handling managed via handler.
if ($request->wantsJson()) {
return response()->json(['error' => $message], 403);
}
if (!empty($message)) {
session()->flash('error', $message);
}

View File

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

View File

@@ -15,14 +15,10 @@ abstract class ApiController extends Controller
* Provide a paginated listing JSON response in a standard format
* taking into account any pagination parameters passed by the user.
*/
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): JsonResponse
protected function apiListingResponse(Builder $query, array $fields): JsonResponse
{
$listing = new ListingResponseBuilder($query, request(), $fields);
foreach ($modifiers as $modifier) {
$listing->modifyResults($modifier);
}
return $listing->toResponse();
}
@@ -30,7 +26,7 @@ abstract class ApiController extends Controller
* Get the validation rules for this controller.
* Defaults to a $rules property but can be a rules() method.
*/
public function getValidationRules(): array
public function getValdationRules(): array
{
if (method_exists($this, 'rules')) {
return $this->rules();

View File

@@ -1,168 +0,0 @@
<?php
namespace BookStack\Http\Controllers\Api;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserUpdateException;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
class UserApiController extends ApiController
{
protected $userRepo;
protected $fieldsToExpose = [
'email', 'created_at', 'updated_at', 'last_activity_at', 'external_auth_id',
];
public function __construct(UserRepo $userRepo)
{
$this->userRepo = $userRepo;
// Checks for all endpoints in this controller
$this->middleware(function ($request, $next) {
$this->checkPermission('users-manage');
$this->preventAccessInDemoMode();
return $next($request);
});
}
protected function rules(int $userId = null): array
{
return [
'create' => [
'name' => ['required', 'min:2'],
'email' => [
'required', 'min:2', 'email', new Unique('users', 'email'),
],
'external_auth_id' => ['string'],
'language' => ['string'],
'password' => [Password::default()],
'roles' => ['array'],
'roles.*' => ['integer'],
'send_invite' => ['boolean'],
],
'update' => [
'name' => ['min:2'],
'email' => [
'min:2',
'email',
(new Unique('users', 'email'))->ignore($userId ?? null),
],
'external_auth_id' => ['string'],
'language' => ['string'],
'password' => [Password::default()],
'roles' => ['array'],
'roles.*' => ['integer'],
],
'delete' => [
'migrate_ownership_id' => ['integer', 'exists:users,id'],
],
];
}
/**
* Get a listing of users in the system.
* Requires permission to manage users.
*/
public function list()
{
$users = User::query()->select(['*'])
->scopes('withLastActivityAt')
->with(['avatar']);
return $this->apiListingResponse($users, [
'id', 'name', 'slug', 'email', 'external_auth_id',
'created_at', 'updated_at', 'last_activity_at',
], [Closure::fromCallable([$this, 'listFormatter'])]);
}
/**
* Create a new user in the system.
* Requires permission to manage users.
*/
public function create(Request $request)
{
$data = $this->validate($request, $this->rules()['create']);
$sendInvite = ($data['send_invite'] ?? false) === true;
$user = null;
DB::transaction(function () use ($data, $sendInvite, &$user) {
$user = $this->userRepo->create($data, $sendInvite);
});
$this->singleFormatter($user);
return response()->json($user);
}
/**
* View the details of a single user.
* Requires permission to manage users.
*/
public function read(string $id)
{
$user = $this->userRepo->getById($id);
$this->singleFormatter($user);
return response()->json($user);
}
/**
* Update an existing user in the system.
* Requires permission to manage users.
*
* @throws UserUpdateException
*/
public function update(Request $request, string $id)
{
$data = $this->validate($request, $this->rules($id)['update']);
$user = $this->userRepo->getById($id);
$this->userRepo->update($user, $data, userCan('users-manage'));
$this->singleFormatter($user);
return response()->json($user);
}
/**
* Delete a user from the system.
* Can optionally accept a user id via `migrate_ownership_id` to indicate
* who should be the new owner of their related content.
* Requires permission to manage users.
*/
public function delete(Request $request, string $id)
{
$user = $this->userRepo->getById($id);
$newOwnerId = $request->get('migrate_ownership_id', null);
$this->userRepo->destroy($user, $newOwnerId);
return response('', 204);
}
/**
* Format the given user model for single-result display.
*/
protected function singleFormatter(User $user)
{
$this->listFormatter($user);
$user->load('roles:id,display_name');
$user->makeVisible(['roles']);
}
/**
* Format the given user model for a listing multi-result display.
*/
protected function listFormatter(User $user)
{
$user->makeVisible($this->fieldsToExpose);
$user->setAttribute('profile_url', $user->getProfileUrl());
$user->setAttribute('edit_url', $user->getEditUrl());
$user->setAttribute('avatar_url', $user->getAvatar());
}
}

View File

@@ -2,14 +2,13 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\Oidc\OidcException;
use BookStack\Auth\Access\Oidc\OidcService;
use BookStack\Http\Controllers\Controller;
use Illuminate\Http\Request;
class OidcController extends Controller
{
protected OidcService $oidcService;
protected $oidcService;
/**
* OpenIdController constructor.
@@ -25,14 +24,7 @@ class OidcController extends Controller
*/
public function login()
{
try {
$loginDetails = $this->oidcService->login();
} catch (OidcException $exception) {
$this->showErrorNotification($exception->getMessage());
return redirect('/login');
}
$loginDetails = $this->oidcService->login();
session()->flash('oidc_state', $loginDetails['state']);
return redirect($loginDetails['url']);
@@ -53,13 +45,7 @@ class OidcController extends Controller
return redirect('/login');
}
try {
$this->oidcService->processAuthorizeResponse($request->query('code'));
} catch (OidcException $oidcException) {
$this->showErrorNotification($oidcException->getMessage());
return redirect('/login');
}
$this->oidcService->processAuthorizeResponse($request->query('code'));
return redirect()->intended();
}

View File

@@ -2,13 +2,13 @@
namespace BookStack\Http\Controllers;
use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use BookStack\Util\WebSafeMimeSniffer;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller as BaseController;
@@ -53,9 +53,14 @@ abstract class Controller extends BaseController
*/
protected function showPermissionError()
{
$message = request()->wantsJson() ? trans('errors.permissionJson') : trans('errors.permission');
if (request()->wantsJson()) {
$response = response()->json(['error' => trans('errors.permissionJson')], 403);
} else {
$response = redirect('/');
$this->showErrorNotification(trans('errors.permission'));
}
throw new NotifyException($message, '/', 403);
throw new HttpResponseException($response);
}
/**

View File

@@ -107,6 +107,14 @@ class HomeController extends Controller
return view('home.default', $commonData);
}
/**
* Get custom head HTML, Used in ajax calls to show in editor.
*/
public function customHeadContent()
{
return view('common.custom-head');
}
/**
* Show the view for /robots.txt.
*/

View File

@@ -14,7 +14,6 @@ use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
@@ -365,22 +364,15 @@ class PageController extends Controller
*/
public function showRecentlyUpdated()
{
$visibleBelongsScope = function (BelongsTo $query) {
$query->scopes('visible');
};
$pages = Page::visible()->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
->orderBy('updated_at', 'desc')
$pages = Page::visible()->orderBy('updated_at', 'desc')
->paginate(20)
->setPath(url('/pages/recently-updated'));
$this->setPageTitle(trans('entities.recently_updated_pages'));
return view('common.detailed-listing-paginated', [
'title' => trans('entities.recently_updated_pages'),
'entities' => $pages,
'showUpdatedBy' => true,
'showPath' => true,
'title' => trans('entities.recently_updated_pages'),
'entities' => $pages,
]);
}

View File

@@ -124,8 +124,11 @@ class PageRevisionController extends Controller
throw new NotFoundException("Revision #{$revId} not found");
}
// Check if it's the latest revision, cannot delete the latest revision.
if (intval($page->currentRevision->id ?? null) === intval($revId)) {
// Get the current revision for the page
$currentRevision = $page->getCurrentRevision();
// Check if its the latest revision, cannot delete latest revision.
if (intval($currentRevision->id) === intval($revId)) {
$this->showErrorNotification(trans('entities.revision_cannot_delete_latest'));
return redirect($page->getUrl('/revisions'));

View File

@@ -9,10 +9,11 @@ use Illuminate\Http\Request;
class SettingController extends Controller
{
protected ImageRepo $imageRepo;
protected array $settingCategories = ['features', 'customization', 'registration'];
protected $imageRepo;
/**
* SettingController constructor.
*/
public function __construct(ImageRepo $imageRepo)
{
$this->imageRepo = $imageRepo;
@@ -21,17 +22,15 @@ class SettingController extends Controller
/**
* Display a listing of the settings.
*/
public function index(string $category)
public function index()
{
$this->ensureCategoryExists($category);
$this->checkPermission('settings-manage');
$this->setPageTitle(trans('settings.settings'));
// Get application version
$version = trim(file_get_contents(base_path('version')));
return view('settings.' . $category, [
'category' => $category,
return view('settings.index', [
'version' => $version,
'guestUser' => User::getDefault(),
]);
@@ -40,9 +39,8 @@ class SettingController extends Controller
/**
* Update the specified settings in storage.
*/
public function update(Request $request, string $category)
public function update(Request $request)
{
$this->ensureCategoryExists($category);
$this->preventAccessInDemoMode();
$this->checkPermission('settings-manage');
$this->validate($request, [
@@ -59,7 +57,7 @@ class SettingController extends Controller
}
// Update logo image if set
if ($category === 'customization' && $request->hasFile('app_logo')) {
if ($request->hasFile('app_logo')) {
$logoFile = $request->file('app_logo');
$this->imageRepo->destroyByType('system');
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
@@ -67,21 +65,16 @@ class SettingController extends Controller
}
// Clear logo image if requested
if ($category === 'customization' && $request->get('app_logo_reset', null)) {
if ($request->get('app_logo_reset', null)) {
$this->imageRepo->destroyByType('system');
setting()->remove('app-logo');
}
$this->logActivity(ActivityType::SETTINGS_UPDATE, $category);
$section = $request->get('section', '');
$this->logActivity(ActivityType::SETTINGS_UPDATE, $section);
$this->showSuccessNotification(trans('settings.settings_save_success'));
$redirectLocation = '/settings#' . $section;
return redirect("/settings/${category}");
}
protected function ensureCategoryExists(string $category): void
{
if (!in_array($category, $this->settingCategories)) {
abort(404);
}
return redirect(rtrim($redirectLocation, '#'));
}
}

View File

@@ -2,9 +2,9 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\Queries\AllUsersPaginatedAndSorted;
use BookStack\Auth\Role;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ImageUploadException;
@@ -12,21 +12,25 @@ use BookStack\Exceptions\UserUpdateException;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
class UserController extends Controller
{
protected $user;
protected $userRepo;
protected $inviteService;
protected $imageRepo;
/**
* UserController constructor.
*/
public function __construct(UserRepo $userRepo, ImageRepo $imageRepo)
public function __construct(User $user, UserRepo $userRepo, UserInviteService $inviteService, ImageRepo $imageRepo)
{
$this->user = $user;
$this->userRepo = $userRepo;
$this->inviteService = $inviteService;
$this->imageRepo = $imageRepo;
}
@@ -41,16 +45,12 @@ class UserController extends Controller
'search' => $request->get('search', ''),
'sort' => $request->get('sort', 'name'),
];
$users = (new AllUsersPaginatedAndSorted())->run(20, $listDetails);
$users = $this->userRepo->getAllUsersPaginatedAndSorted(20, $listDetails);
$this->setPageTitle(trans('settings.users'));
$users->appends($listDetails);
return view('users.index', [
'users' => $users,
'listDetails' => $listDetails,
]);
return view('users.index', ['users' => $users, 'listDetails' => $listDetails]);
}
/**
@@ -60,42 +60,59 @@ class UserController extends Controller
{
$this->checkPermission('users-manage');
$authMethod = config('auth.method');
$roles = Role::query()->orderBy('display_name', 'asc')->get();
$this->setPageTitle(trans('settings.users_add_new'));
$roles = $this->userRepo->getAllRoles();
return view('users.create', ['authMethod' => $authMethod, 'roles' => $roles]);
}
/**
* Store a new user in storage.
* Store a newly created user in storage.
*
* @throws UserUpdateException
* @throws ValidationException
*/
public function store(Request $request)
{
$this->checkPermission('users-manage');
$validationRules = [
'name' => ['required'],
'email' => ['required', 'email', 'unique:users,email'],
];
$authMethod = config('auth.method');
$sendInvite = ($request->get('send_invite', 'false') === 'true');
$externalAuth = $authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'oidc';
$passwordRequired = ($authMethod === 'standard' && !$sendInvite);
$validationRules = [
'name' => ['required'],
'email' => ['required', 'email', 'unique:users,email'],
'language' => ['string'],
'roles' => ['array'],
'roles.*' => ['integer'],
'password' => $passwordRequired ? ['required', Password::default()] : null,
'password-confirm' => $passwordRequired ? ['required', 'same:password'] : null,
'external_auth_id' => $externalAuth ? ['required'] : null,
];
if ($authMethod === 'standard' && !$sendInvite) {
$validationRules['password'] = ['required', Password::default()];
$validationRules['password-confirm'] = ['required', 'same:password'];
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
$validationRules['external_auth_id'] = ['required'];
}
$this->validate($request, $validationRules);
$validated = $this->validate($request, array_filter($validationRules));
$user = $this->user->fill($request->all());
DB::transaction(function () use ($validated, $sendInvite) {
$this->userRepo->create($validated, $sendInvite);
});
if ($authMethod === 'standard') {
$user->password = bcrypt($request->get('password', Str::random(32)));
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
$user->external_auth_id = $request->get('external_auth_id');
}
$user->refreshSlug();
$user->save();
if ($sendInvite) {
$this->inviteService->sendInvitation($user);
}
if ($request->filled('roles')) {
$roles = $request->get('roles');
$this->userRepo->setUserRoles($user, $roles);
}
$this->userRepo->downloadAndAssignUserAvatar($user);
$this->logActivity(ActivityType::USER_CREATE, $user);
return redirect('/settings/users');
}
@@ -108,14 +125,14 @@ class UserController extends Controller
$this->checkPermissionOrCurrentUser('users-manage', $id);
/** @var User $user */
$user = User::query()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
$user = $this->user->newQuery()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
$mfaMethods = $user->mfaValues->groupBy('method');
$this->setPageTitle(trans('settings.user_profile'));
$roles = Role::query()->orderBy('display_name', 'asc')->get();
$roles = $this->userRepo->getAllRoles();
return view('users.edit', [
'user' => $user,
@@ -138,20 +155,51 @@ class UserController extends Controller
$this->preventAccessInDemoMode();
$this->checkPermissionOrCurrentUser('users-manage', $id);
$validated = $this->validate($request, [
$this->validate($request, [
'name' => ['min:2'],
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
'password' => ['required_with:password_confirm', Password::default()],
'password-confirm' => ['same:password', 'required_with:password'],
'language' => ['string'],
'roles' => ['array'],
'roles.*' => ['integer'],
'external_auth_id' => ['string'],
'setting' => ['array'],
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
]);
$user = $this->userRepo->getById($id);
$this->userRepo->update($user, $validated, userCan('users-manage'));
$user->fill($request->except(['email']));
// Email updates
if (userCan('users-manage') && $request->filled('email')) {
$user->email = $request->get('email');
}
// Refresh the slug if the user's name has changed
if ($user->isDirty('name')) {
$user->refreshSlug();
}
// Role updates
if (userCan('users-manage') && $request->filled('roles')) {
$roles = $request->get('roles');
$this->userRepo->setUserRoles($user, $roles);
}
// Password updates
if ($request->filled('password')) {
$password = $request->get('password');
$user->password = bcrypt($password);
}
// External auth id updates
if (user()->can('users-manage') && $request->filled('external_auth_id')) {
$user->external_auth_id = $request->get('external_auth_id');
}
// Save an user-specific settings
if ($request->filled('setting')) {
foreach ($request->get('setting') as $key => $value) {
setting()->putUser($user, $key, $value);
}
}
// Save profile image if in request
if ($request->hasFile('profile_image')) {
@@ -159,7 +207,6 @@ class UserController extends Controller
$this->imageRepo->destroyImage($user->avatar);
$image = $this->imageRepo->saveNew($imageUpload, 'user', $user->id);
$user->image_id = $image->id;
$user->save();
}
// Delete the profile image if reset option is in request
@@ -167,7 +214,11 @@ class UserController extends Controller
$this->imageRepo->destroyImage($user->avatar);
}
$redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}";
$user->save();
$this->showSuccessNotification(trans('settings.users_edit_success'));
$this->logActivity(ActivityType::USER_UPDATE, $user);
$redirectUrl = userCan('users-manage') ? '/settings/users' : ('/settings/users/' . $user->id);
return redirect($redirectUrl);
}
@@ -198,7 +249,21 @@ class UserController extends Controller
$user = $this->userRepo->getById($id);
$newOwnerId = $request->get('new_owner_id', null);
if ($this->userRepo->isOnlyAdmin($user)) {
$this->showErrorNotification(trans('errors.users_cannot_delete_only_admin'));
return redirect($user->getEditUrl());
}
if ($user->system_name === 'public') {
$this->showErrorNotification(trans('errors.users_cannot_delete_guest'));
return redirect($user->getEditUrl());
}
$this->userRepo->destroy($user, $newOwnerId);
$this->showSuccessNotification(trans('settings.users_delete_success'));
$this->logActivity(ActivityType::USER_DELETE, $user);
return redirect('/settings/users');
}
@@ -283,7 +348,7 @@ class UserController extends Controller
$newState = $request->get('expand', 'false');
$user = $this->userRepo->getById($id);
$user = $this->user->findOrFail($id);
setting()->putUser($user, 'section_expansion#' . $key, $newState);
return response('', 204);
@@ -306,7 +371,7 @@ class UserController extends Controller
$order = 'asc';
}
$user = $this->userRepo->getById($userId);
$user = $this->user->findOrFail($userId);
$sortKey = $listName . '_sort';
$orderKey = $listName . '_sort_order';
setting()->putUser($user, $sortKey, $sort);

View File

@@ -3,8 +3,6 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityQueries;
use BookStack\Auth\Queries\UserContentCounts;
use BookStack\Auth\Queries\UserRecentlyCreatedContent;
use BookStack\Auth\UserRepo;
class UserProfileController extends Controller
@@ -17,8 +15,8 @@ class UserProfileController extends Controller
$user = $repo->getBySlug($slug);
$userActivity = $activities->userActivity($user);
$recentlyCreated = (new UserRecentlyCreatedContent())->run($user, 5);
$assetCounts = (new UserContentCounts())->run($user);
$recentlyCreated = $repo->getRecentlyCreated($user, 5);
$assetCounts = $repo->getAssetCounts($user);
$this->setPageTitle($user->name);

View File

@@ -8,7 +8,10 @@ use Illuminate\Http\Request;
class ApplyCspRules
{
protected CspService $cspService;
/**
* @var CspService
*/
protected $cspService;
public function __construct(CspService $cspService)
{
@@ -32,8 +35,10 @@ class ApplyCspRules
$response = $next($request);
$cspHeader = $this->cspService->getCspHeader();
$response->headers->set('Content-Security-Policy', $cspHeader, false);
$this->cspService->setFrameAncestors($response);
$this->cspService->setScriptSrc($response);
$this->cspService->setObjectSrc($response);
$this->cspService->setBaseUri($response);
return $response;
}

View File

@@ -29,7 +29,6 @@ class Localization
'es' => 'es_ES',
'es_AR' => 'es_AR',
'et' => 'et_EE',
'eu' => 'eu_ES',
'fr' => 'fr_FR',
'he' => 'he_IL',
'hr' => 'hr_HR',

View File

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

View File

@@ -23,7 +23,6 @@ class AuthServiceProvider extends ServiceProvider
public function boot()
{
// Password Configuration
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
Password::defaults(function () {
return Password::min(8);
});

View File

@@ -93,8 +93,6 @@ class ThemeEvents
* @param string $event
* @param \BookStack\Actions\Webhook $webhook
* @param string|\BookStack\Interfaces\Loggable $detail
* @param \BookStack\Auth\User $initiator
* @param int $initiatedTime
*/
const WEBHOOK_CALL_BEFORE = 'webhook_call_before';
}

View File

@@ -5,7 +5,6 @@ namespace BookStack\Uploads;
use BookStack\Exceptions\ImageUploadException;
use ErrorException;
use Exception;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
@@ -15,7 +14,6 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Intervention\Image\Exception\NotSupportedException;
use Intervention\Image\Image as InterventionImage;
use Intervention\Image\ImageManager;
use League\Flysystem\Util;
use Psr\SimpleCache\InvalidArgumentException;
@@ -310,8 +308,6 @@ class ImageService
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
$this->orientImageToOriginalExif($thumb, $imageData);
if ($keepRatio) {
$thumb->resize($width, $height, function ($constraint) {
$constraint->aspectRatio();
@@ -332,49 +328,6 @@ class ImageService
return $thumbData;
}
/**
* Orientate the given intervention image based upon the given original image data.
* Intervention does have an `orientate` method but the exif data it needs is lost before it
* can be used (At least when created using binary string data) so we need to do some
* implementation on our side to use the original image data.
* Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
* Copyright (c) Oliver Vogel, MIT License.
*/
protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
{
if (!extension_loaded('exif')) {
return;
}
$stream = Utils::streamFor($originalData)->detach();
$exif = @exif_read_data($stream);
$orientation = $exif ? ($exif['Orientation'] ?? null) : null;
switch ($orientation) {
case 2:
$image->flip();
break;
case 3:
$image->rotate(180);
break;
case 4:
$image->rotate(180)->flip();
break;
case 5:
$image->rotate(270)->flip();
break;
case 6:
$image->rotate(270);
break;
case 7:
$image->rotate(90)->flip();
break;
case 8:
$image->rotate(90);
break;
}
}
/**
* Get the raw data content from an image.
*

View File

@@ -2,6 +2,7 @@
namespace BookStack\Uploads;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\User;
use BookStack\Exceptions\HttpFetchException;
use Exception;
@@ -16,6 +17,7 @@ class UserAvatars
{
$this->imageService = $imageService;
$this->http = $http;
$ldapService = app()->make(LdapService::class);
}
/**

View File

@@ -3,10 +3,12 @@
namespace BookStack\Util;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class CspService
{
protected string $nonce;
/** @var string */
protected $nonce;
public function __construct(string $nonce = '')
{
@@ -22,34 +24,37 @@ class CspService
}
/**
* Get the CSP headers for the application.
* Sets CSP 'script-src' headers to restrict the forms of script that can
* run on the page.
*/
public function getCspHeader(): string
public function setScriptSrc(Response $response)
{
$headers = [
$this->getFrameAncestors(),
$this->getFrameSrc(),
$this->getScriptSrc(),
$this->getObjectSrc(),
$this->getBaseUri(),
if (config('app.allow_content_scripts')) {
return;
}
$parts = [
'http:',
'https:',
'\'nonce-' . $this->nonce . '\'',
'\'strict-dynamic\'',
];
return implode('; ', array_filter($headers));
$value = 'script-src ' . implode(' ', $parts);
$response->headers->set('Content-Security-Policy', $value, false);
}
/**
* Get the CSP rules for the application for a HTML meta tag.
* Sets CSP "frame-ancestors" headers to restrict the hosts that BookStack can be
* iframed within. Also adjusts the cookie samesite options so that cookies will
* operate in the third-party context.
*/
public function getCspMetaTagValue(): string
public function setFrameAncestors(Response $response)
{
$headers = [
$this->getFrameSrc(),
$this->getScriptSrc(),
$this->getObjectSrc(),
$this->getBaseUri(),
];
return implode('; ', array_filter($headers));
$iframeHosts = $this->getAllowedIframeHosts();
array_unshift($iframeHosts, "'self'");
$cspValue = 'frame-ancestors ' . implode(' ', $iframeHosts);
$response->headers->set('Content-Security-Policy', $cspValue, false);
}
/**
@@ -61,67 +66,25 @@ class CspService
}
/**
* Create CSP 'script-src' rule to restrict the forms of script that can run on the page.
*/
protected function getScriptSrc(): string
{
if (config('app.allow_content_scripts')) {
return '';
}
$parts = [
'http:',
'https:',
'\'nonce-' . $this->nonce . '\'',
'\'strict-dynamic\'',
];
return 'script-src ' . implode(' ', $parts);
}
/**
* Create CSP "frame-ancestors" rule to restrict the hosts that BookStack can be iframed within.
*/
protected function getFrameAncestors(): string
{
$iframeHosts = $this->getAllowedIframeHosts();
array_unshift($iframeHosts, "'self'");
return 'frame-ancestors ' . implode(' ', $iframeHosts);
}
/**
* Creates CSP "frame-src" rule to restrict what hosts/sources can be loaded
* within iframes to provide an allow-list-style approach to iframe content.
*/
protected function getFrameSrc(): string
{
$iframeHosts = $this->getAllowedIframeSources();
array_unshift($iframeHosts, "'self'");
return 'frame-src ' . implode(' ', $iframeHosts);
}
/**
* Creates CSP 'object-src' rule to restrict the types of dynamic content
* Sets CSP 'object-src' headers to restrict the types of dynamic content
* that can be embedded on the page.
*/
protected function getObjectSrc(): string
public function setObjectSrc(Response $response)
{
if (config('app.allow_content_scripts')) {
return '';
return;
}
return "object-src 'self'";
$response->headers->set('Content-Security-Policy', 'object-src \'self\'', false);
}
/**
* Creates CSP 'base-uri' rule to restrict what base tags can be set on
* Sets CSP 'base-uri' headers to restrict what base tags can be set on
* the page to prevent manipulation of relative links.
*/
protected function getBaseUri(): string
public function setBaseUri(Response $response)
{
return "base-uri 'self'";
$response->headers->set('Content-Security-Policy', 'base-uri \'self\'', false);
}
protected function getAllowedIframeHosts(): array
@@ -130,21 +93,4 @@ class CspService
return array_filter(explode(' ', $hosts));
}
protected function getAllowedIframeSources(): array
{
$sources = config('app.iframe_sources', '');
$hosts = array_filter(explode(' ', $sources));
// Extract drawing service url to allow embedding if active
$drawioConfigValue = config('services.drawio');
if ($drawioConfigValue) {
$drawioSource = is_string($drawioConfigValue) ? $drawioConfigValue : 'https://embed.diagrams.net/';
$drawioSourceParsed = parse_url($drawioSource);
$drawioHost = $drawioSourceParsed['scheme'] . '://' . $drawioSourceParsed['host'];
$hosts[] = $drawioHost;
}
return $hosts;
}
}

View File

@@ -8,7 +8,7 @@
"license": "MIT",
"type": "project",
"require": {
"php": "^7.4|^8.0",
"php": "^7.3|^8.0",
"ext-curl": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
@@ -17,8 +17,8 @@
"ext-mbstring": "*",
"ext-xml": "*",
"bacon/bacon-qr-code": "^2.0",
"barryvdh/laravel-dompdf": "^1.0",
"barryvdh/laravel-snappy": "^1.0",
"barryvdh/laravel-dompdf": "^0.9.0",
"barryvdh/laravel-snappy": "^0.4.8",
"doctrine/dbal": "^3.1",
"filp/whoops": "^2.14",
"guzzlehttp/guzzle": "^7.4",
@@ -41,7 +41,7 @@
"socialiteproviders/okta": "^4.1",
"socialiteproviders/slack": "^4.1",
"socialiteproviders/twitch": "^5.3",
"ssddanbrown/htmldiff": "^1.0.2"
"ssddanbrown/htmldiff": "^1.0.1"
},
"require-dev": {
"fakerphp/faker": "^1.16",
@@ -95,7 +95,7 @@
"preferred-install": "dist",
"sort-packages": true,
"platform": {
"php": "7.4.0"
"php": "7.3.0"
}
},
"extra": {

1461
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
{
"name": "Dan Brown",
"email": "dannyb@example.com",
"roles": [1],
"language": "fr",
"send_invite": true
}

View File

@@ -1,3 +0,0 @@
{
"migrate_ownership_id": 5
}

View File

@@ -1,7 +0,0 @@
{
"name": "Dan Spaggleforth",
"email": "dspaggles@example.com",
"roles": [2],
"language": "de",
"password": "hunter2000"
}

View File

@@ -1,19 +0,0 @@
{
"id": 1,
"name": "Dan Brown",
"email": "dannyb@example.com",
"created_at": "2022-02-03T16:27:55.000000Z",
"updated_at": "2022-02-03T16:27:55.000000Z",
"external_auth_id": "abc123456",
"slug": "dan-brown",
"last_activity_at": "2022-02-03T16:27:55.000000Z",
"profile_url": "https://docs.example.com/user/dan-brown",
"edit_url": "https://docs.example.com/settings/users/1",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-10/thumbs-50-50/profile-2021.jpg",
"roles": [
{
"id": 1,
"display_name": "Admin"
}
]
}

View File

@@ -1,33 +0,0 @@
{
"data": [
{
"id": 1,
"name": "Dan Brown",
"email": "dannyb@example.com",
"created_at": "2022-02-03T16:27:55.000000Z",
"updated_at": "2022-02-03T16:27:55.000000Z",
"external_auth_id": "abc123456",
"slug": "dan-brown",
"user_id": 1,
"last_activity_at": "2022-02-03T16:27:55.000000Z",
"profile_url": "https://docs.example.com/user/dan-brown",
"edit_url": "https://docs.example.com/settings/users/1",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-10/thumbs-50-50/profile-2021.jpg"
},
{
"id": 2,
"name": "Benny",
"email": "benny@example.com",
"created_at": "2022-01-31T20:39:24.000000Z",
"updated_at": "2021-11-18T17:10:58.000000Z",
"external_auth_id": "",
"slug": "benny",
"user_id": 2,
"last_activity_at": "2022-01-31T20:39:24.000000Z",
"profile_url": "https://docs.example.com/user/benny",
"edit_url": "https://docs.example.com/settings/users/2",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-11/thumbs-50-50/guest.jpg"
}
],
"total": 28
}

View File

@@ -1,19 +0,0 @@
{
"id": 1,
"name": "Dan Brown",
"email": "dannyb@example.com",
"created_at": "2022-02-03T16:27:55.000000Z",
"updated_at": "2022-02-03T16:27:55.000000Z",
"external_auth_id": "abc123456",
"slug": "dan-brown",
"last_activity_at": "2022-02-03T16:27:55.000000Z",
"profile_url": "https://docs.example.com/user/dan-brown",
"edit_url": "https://docs.example.com/settings/users/1",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-10/thumbs-50-50/profile-2021.jpg",
"roles": [
{
"id": 1,
"display_name": "Admin"
}
]
}

View File

@@ -1,19 +0,0 @@
{
"id": 1,
"name": "Dan Spaggleforth",
"email": "dspaggles@example.com",
"created_at": "2022-02-03T16:27:55.000000Z",
"updated_at": "2022-02-03T16:27:55.000000Z",
"external_auth_id": "abc123456",
"slug": "dan-spaggleforth",
"last_activity_at": "2022-02-03T16:27:55.000000Z",
"profile_url": "https://docs.example.com/user/dan-spaggleforth",
"edit_url": "https://docs.example.com/settings/users/1",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-10/thumbs-50-50/profile-2021.jpg",
"roles": [
{
"id": 2,
"display_name": "Editors"
}
]
}

View File

@@ -1,4 +1,4 @@
FROM php:8.1-apache
FROM php:7.4-apache
ENV APACHE_DOCUMENT_ROOT /app/public
WORKDIR /app
@@ -8,8 +8,6 @@ RUN apt-get update -y \
&& apt-get install -y git zip unzip libpng-dev libldap2-dev libzip-dev wait-for-it \
&& docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu \
&& docker-php-ext-install pdo_mysql gd ldap zip \
&& pecl install xdebug \
&& docker-php-ext-enable xdebug \
&& a2enmod rewrite \
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
&& sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf

View File

@@ -1,7 +0,0 @@
zend_extension=xdebug
[xdebug]
xdebug.mode=debug
xdebug.client_host=host.docker.internal
xdebug.start_with_request=yes
xdebug.client_port=9090

View File

@@ -38,10 +38,7 @@ services:
- ${DEV_PORT:-8080}:80
volumes:
- ./:/app
- ./dev/docker/php/conf.d/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
entrypoint: /app/dev/docker/entrypoint.app.sh
extra_hosts:
- "host.docker.internal:host-gateway"
node:
image: node:alpine
working_dir: /app

988
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,11 @@
"build:css:dev": "sass ./resources/sass:./public/dist",
"build:css:watch": "sass ./resources/sass:./public/dist --watch",
"build:css:production": "sass ./resources/sass:./public/dist -s compressed",
"build:js:dev": "esbuild --bundle ./resources/js/*.{js,mjs} --outdir=public/dist/ --sourcemap --target=es2020 --main-fields=module,main --format=esm",
"build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main",
"build:js:watch": "chokidar --initial \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
"build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/*.{js,mjs} --outdir=public/dist/ --sourcemap --target=es2020 --main-fields=module,main --minify --format=esm",
"build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main --minify",
"build:js_editor:dev": "esbuild --bundle ./resources/js/editor.js --outfile=public/dist/editor.js --sourcemap --target=es2019 --main-fields=module,main",
"build:js_editor:watch": "chokidar --initial \"./resources/js/editor.js\" \"./resources/js/editor/**/*.js\" -c \"npm run build:js_editor:dev\"",
"build": "npm-run-all --parallel build:*:dev",
"production": "npm-run-all --parallel build:*:production",
"dev": "npm-run-all --parallel watch livereload",
@@ -16,18 +18,27 @@
},
"devDependencies": {
"chokidar-cli": "^3.0",
"esbuild": "0.14.27",
"esbuild": "0.13.12",
"livereload": "^0.9.3",
"npm-run-all": "^4.1.5",
"punycode": "^2.1.1",
"sass": "^1.49.9"
"sass": "^1.43.4"
},
"dependencies": {
"clipboard": "^2.0.10",
"codemirror": "^5.65.2",
"clipboard": "^2.0.8",
"codemirror": "^5.63.3",
"crelt": "^1.0.5",
"dropzone": "^5.9.3",
"markdown-it": "^12.3.2",
"markdown-it": "^12.2.0",
"markdown-it-task-lists": "^2.1.1",
"sortablejs": "^1.15.0"
"prosemirror-commands": "^1.1.12",
"prosemirror-example-setup": "^1.1.2",
"prosemirror-markdown": "^1.6.0",
"prosemirror-model": "^1.15.0",
"prosemirror-schema-list": "^1.1.6",
"prosemirror-state": "^1.3.4",
"prosemirror-tables": "^1.1.1",
"prosemirror-view": "^1.23.2",
"sortablejs": "^1.14.0"
}
}

View File

@@ -9,7 +9,7 @@ parameters:
# The level 8 is the highest level
level: 1
phpVersion: 70400
phpVersion: 70300
bootstrapFiles:
- bootstrap/phpstan.php

View File

@@ -34,8 +34,6 @@
<server name="AVATAR_URL" value=""/>
<server name="LDAP_START_TLS" value="false"/>
<server name="LDAP_VERSION" value="3"/>
<server name="LDAP_DUMP_USER_DETAILS" value="false"/>
<server name="LDAP_DUMP_USER_GROUPS" value="false"/>
<server name="SESSION_SECURE_COOKIE" value="null"/>
<server name="STORAGE_TYPE" value="local"/>
<server name="STORAGE_ATTACHMENT_TYPE" value="local"/>

62
public/dist/app.js vendored

File diff suppressed because one or more lines are too long

33
public/dist/code.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
:root{--color-primary: #206ea7;--color-primary-light: rgba(32,110,167,0.15);--color-page: #206ea7;--color-page-draft: #7e50b1;--color-chapter: #af4d0d;--color-book: #077b70;--color-bookshelf: #a94747}header{display:none}html,body{font-size:12px;background-color:#fff}.page-content{margin:0 auto}.print-hidden{display:none !important}.tri-layout-container{grid-template-columns:1fr;grid-template-areas:"b";margin-inline-start:0;margin-inline-end:0;display:block}.card{box-shadow:none}.content-wrap.card{padding-inline-start:0;padding-inline-end:0}/*# sourceMappingURL=print-styles.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1 @@
/**
* Copyright (c) Tiny Technologies, Inc. All rights reserved.
* Licensed under the LGPL or a commercial license.
* For LGPL see License.txt in the project root for license information.
* For commercial licenses see https://www.tiny.cloud/
*
* Version: 5.10.2 (2021-11-17)
*/
!function(){"use strict";function f(t,e,n){t.execCommand("UL"===e?"InsertUnorderedList":"InsertOrderedList",!1,!1===n?null:{"list-style-type":n})}function i(t){return function(){return t}}function t(t){return t}function e(){return s}var n=tinymce.util.Tools.resolve("tinymce.PluginManager"),g=tinymce.util.Tools.resolve("tinymce.util.Tools"),u=i(!1),l=i(!0),s={fold:function(t,e){return t()},isSome:u,isNone:l,getOr:t,getOrThunk:r,getOrDie:function(t){throw new Error(t||"error: getOrDie called on none.")},getOrNull:i(null),getOrUndefined:i(void 0),or:t,orThunk:r,map:e,each:function(){},bind:e,exists:u,forall:l,filter:function(){return s},toArray:function(){return[]},toString:i("none()")};function r(t){return t()}function d(t){return t&&/^(TH|TD)$/.test(t.nodeName)}function m(c,a){return function(s){function t(t){var e,n,r,o,i,u,l;s.setActive((e=c,r=a,i=-1!==(o=function(t,e){for(var n=0;n<t.length;n++)if(e(t[n]))return n;return-1}((n=t).parents,d))?n.parents.slice(0,o):n.parents,0<(u=g.grep(i,(l=e,function(t){return t&&/^(OL|UL|DL)$/.test(t.nodeName)&&(e=t,l.$.contains(l.getBody(),e));var e}))).length&&u[0].nodeName===r))}return c.on("NodeChange",t),function(){return c.off("NodeChange",t)}}}function c(t,e,n,r,o,i){var u,l,s,c,a,d;1<i.length?(c=r,a=o,d=i,(s=t).ui.registry.addSplitButton(e,{tooltip:n,icon:"OL"===a?"ordered-list":"unordered-list",presets:"listpreview",columns:3,fetch:function(t){t(g.map(d,function(t){return{type:"choiceitem",value:"default"===t?"":t,icon:"list-"+("OL"===a?"num":"bull")+"-"+("disc"===t||"decimal"===t?"default":t),text:t.replace(/\-/g," ").replace(/\b\w/g,function(t){return t.toUpperCase()})}}))},onAction:function(){return s.execCommand(c)},onItemAction:function(t,e){f(s,a,e)},select:function(e){var t,n=(t=s).dom.getParent(t.selection.getNode(),"ol,ul"),r=t.dom.getStyle(n,"listStyleType");return p(r).map(function(t){return e===t}).getOr(!1)},onSetup:m(s,a)})):(l=r,(u=t).ui.registry.addToggleButton(e,{active:!1,tooltip:n,icon:"OL"===o?"ordered-list":"unordered-list",onSetup:m(u,o),onAction:function(){return u.execCommand(l)}}))}var a=function(n){function t(){return o}function e(t){return t(n)}var r=i(n),o={fold:function(t,e){return e(n)},isSome:l,isNone:u,getOr:r,getOrThunk:r,getOrDie:r,getOrNull:r,getOrUndefined:r,or:t,orThunk:t,map:function(t){return a(t(n))},each:function(t){t(n)},bind:e,exists:e,forall:e,filter:function(t){return t(n)?o:s},toArray:function(){return[n]},toString:function(){return"some("+n+")"}};return o},p=function(t){return null==t?s:a(t)};n.add("advlist",function(t){var n,e,r,o;t.hasPlugin("lists")?(c(e=t,"numlist","Numbered list","InsertOrderedList","OL",(r=e.getParam("advlist_number_styles","default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman"))?r.split(/[ ,]/):[]),c(e,"bullist","Bullet list","InsertUnorderedList","UL",(o=e.getParam("advlist_bullet_styles","default,circle,square"))?o.split(/[ ,]/):[]),(n=t).addCommand("ApplyUnorderedListStyle",function(t,e){f(n,"UL",e["list-style-type"])}),n.addCommand("ApplyOrderedListStyle",function(t,e){f(n,"OL",e["list-style-type"])})):console.error("Please use the Lists plugin together with the Advanced List plugin.")})}();
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager"),a=tinymce.util.Tools.resolve("tinymce.util.Tools"),s=function(t,e,n){var r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===n?null:{"list-style-type":n})},o=function(n){n.addCommand("ApplyUnorderedListStyle",function(t,e){s(n,"UL",e["list-style-type"])}),n.addCommand("ApplyOrderedListStyle",function(t,e){s(n,"OL",e["list-style-type"])})},e=function(t){var e=t.getParam("advlist_number_styles","default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman");return e?e.split(/[ ,]/):[]},n=function(t){var e=t.getParam("advlist_bullet_styles","default,circle,disc,square");return e?e.split(/[ ,]/):[]},u=function(t){return t&&/^(TH|TD)$/.test(t.nodeName)},c=function(r){return function(t){return t&&/^(OL|UL|DL)$/.test(t.nodeName)&&(n=t,(e=r).$.contains(e.getBody(),n));var e,n}},d=function(t){var e=t.dom.getParent(t.selection.getNode(),"ol,ul");return t.dom.getStyle(e,"listStyleType")||""},p=function(t){return a.map(t,function(t){return{text:t.replace(/\-/g," ").replace(/\b\w/g,function(t){return t.toUpperCase()}),data:"default"===t?"":t}})},f=function(i,l){return function(t){var o=t.control;i.on("NodeChange",function(t){var e=function(t,e){for(var n=0;n<t.length;n++)if(e(t[n]))return n;return-1}(t.parents,u),n=-1!==e?t.parents.slice(0,e):t.parents,r=a.grep(n,c(i));o.active(0<r.length&&r[0].nodeName===l)})}},m=function(e,t,n,r,o,i){var l;e.addButton(t,{active:!1,type:"splitbutton",tooltip:n,menu:p(i),onPostRender:f(e,o),onshow:(l=e,function(t){var e=d(l);t.control.items().each(function(t){t.active(t.settings.data===e)})}),onselect:function(t){s(e,o,t.control.settings.data)},onclick:function(){e.execCommand(r)}})},r=function(t,e,n,r,o,i){var l,a,s,u,c;0<i.length?m(t,e,n,r,o,i):(a=e,s=n,u=r,c=o,(l=t).addButton(a,{active:!1,type:"button",tooltip:s,onPostRender:f(l,c),onclick:function(){l.execCommand(u)}}))},i=function(t){r(t,"numlist","Numbered list","InsertOrderedList","OL",e(t)),r(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))};t.add("advlist",function(t){var e,n,r;n="lists",r=(e=t).settings.plugins?e.settings.plugins:"",-1!==a.inArray(r.split(/[ ,]/),n)&&(i(t),o(t))})}();

View File

@@ -1,9 +1 @@
/**
* Copyright (c) Tiny Technologies, Inc. All rights reserved.
* Licensed under the LGPL or a commercial license.
* For LGPL see License.txt in the project root for license information.
* For commercial licenses see https://www.tiny.cloud/
*
* Version: 5.10.2 (2021-11-17)
*/
!function(){"use strict";function a(e){return e.getAttribute("id")||e.getAttribute("name")||""}function c(e){return e&&"a"===e.nodeName.toLowerCase()&&!e.getAttribute("href")&&""!==a(e)}function d(e){return e.dom.getParent(e.selection.getStart(),l)}function r(e,t){var o,a,n,r,i,l=d(e);l?(n=e,r=t,(i=l).removeAttribute("name"),i.id=r,n.addVisual(),n.undoManager.add()):(a=t,(o=e).undoManager.transact(function(){var e,n;o.getParam("allow_html_in_named_anchor",!1,"boolean")||o.selection.collapse(!0),o.selection.isCollapsed()?o.insertContent(o.dom.createHTML("a",{id:a})):(n=(e=o).dom,u(n).walk(e.selection.getRng(),function(e){s.each(e,function(e){var t;c(t=e)&&!t.firstChild&&n.remove(e,!1)})}),o.formatter.remove("namedAnchor",null,null,!0),o.formatter.apply("namedAnchor",{value:a}),o.addVisual())})),e.focus()}function i(r){return function(e){for(var t,n=0;n<e.length;n++){var o=e[n],a=void 0;!(a=t=o)||a.attr("href")||!a.attr("id")&&!a.attr("name")||t.firstChild||o.attr("contenteditable",r)}}}var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),u=tinymce.util.Tools.resolve("tinymce.dom.RangeUtils"),s=tinymce.util.Tools.resolve("tinymce.util.Tools"),l="a:not([href])";e.add("anchor",function(e){var t,n,o;(t=e).on("PreInit",function(){t.parser.addNodeFilter("a",i("false")),t.serializer.addNodeFilter("a",i(null))}),(n=e).addCommand("mceAnchor",function(){var o,e,t;t=(e=d(o=n))?a(e):"",o.windowManager.open({title:"Anchor",size:"normal",body:{type:"panel",items:[{name:"id",type:"input",label:"ID",placeholder:"example"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{id:t},onSubmit:function(e){var t=o,n=e.getData().id;(/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(n)?(r(t,n),0):(t.windowManager.alert("Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores."),1))||e.close()}})}),(o=e).ui.registry.addToggleButton("anchor",{icon:"bookmark",tooltip:"Anchor",onAction:function(){return o.execCommand("mceAnchor")},onSetup:function(e){return o.selection.selectorChangedWithUnbind("a:not([href])",e.setActive).unbind}}),o.ui.registry.addMenuItem("anchor",{icon:"bookmark",text:"Anchor...",onAction:function(){return o.execCommand("mceAnchor")}}),e.on("PreInit",function(){e.formatter.register("namedAnchor",{inline:"a",selector:l,remove:"all",split:!0,deep:!0,attributes:{id:"%value"},onmatch:c})})})}();
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager"),a=function(t){return/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(t)},e=function(t){var e=t.selection.getNode();return"A"===e.tagName&&""===t.dom.getAttrib(e,"href")?e.id||e.name:""},i=function(t,e){var n=t.selection.getNode();"A"===n.tagName&&""===t.dom.getAttrib(n,"href")?(n.removeAttribute("name"),n.id=e,t.undoManager.add()):(t.focus(),t.selection.collapse(!0),t.execCommand("mceInsertContent",!1,t.dom.createHTML("a",{id:e})))},n=function(r){var t=e(r);r.windowManager.open({title:"Anchor",body:{type:"textbox",name:"id",size:40,label:"Id",value:t},onsubmit:function(t){var e,n,o=t.data.id;e=r,(a(n=o)?(i(e,n),0):(e.windowManager.alert("Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores."),1))&&t.preventDefault()}})},o=function(t){t.addCommand("mceAnchor",function(){n(t)})},r=function(o){return function(t){for(var e=0;e<t.length;e++)(n=t[e]).attr("href")||!n.attr("id")&&!n.attr("name")||n.firstChild||t[e].attr("contenteditable",o);var n}},c=function(t){t.on("PreInit",function(){t.parser.addNodeFilter("a",r("false")),t.serializer.addNodeFilter("a",r(null))})},d=function(t){t.addButton("anchor",{icon:"anchor",tooltip:"Anchor",cmd:"mceAnchor",stateSelector:"a:not([href])"}),t.addMenuItem("anchor",{icon:"anchor",text:"Anchor",context:"insert",cmd:"mceAnchor"})};t.add("anchor",function(t){c(t),o(t),d(t)})}();

View File

@@ -1,9 +1 @@
/**
* Copyright (c) Tiny Technologies, Inc. All rights reserved.
* Licensed under the LGPL or a commercial license.
* For LGPL see License.txt in the project root for license information.
* For commercial licenses see https://www.tiny.cloud/
*
* Version: 5.10.2 (2021-11-17)
*/
!function(){"use strict";function k(e){return/^[(\[{ \u00a0]$/.test(e)}function w(e){return 3===e.nodeType}function i(e){return 1===e.nodeType}function o(e,t){var n;return t<0&&(t=0),!w(e)||(n=e.data.length)<t&&(t=n),t}function y(e,t,n){!i(t)||t.hasChildNodes()?e.setStart(t,o(t,n)):e.setStartBefore(t)}function v(e,t,n){!i(t)||t.hasChildNodes()?e.setEnd(t,o(t,n)):e.setEndAfter(t)}function r(e,t){var n,i,o,r,a,f=e.getParam("autolink_pattern",A),s=e.getParam("default_link_target",!1);if(null===e.dom.getParent(e.selection.getNode(),"a[href]")){var d=e.selection.getRng().cloneRange();if(d.startOffset<5){if(!(r=d.endContainer.previousSibling)){if(!d.endContainer.firstChild||!d.endContainer.firstChild.nextSibling)return;r=d.endContainer.firstChild.nextSibling}if(y(d,r,a=r.length),v(d,r,a),d.endOffset<5)return;n=d.endOffset,i=r}else{if(!w(i=d.endContainer)&&i.firstChild){for(;!w(i)&&i.firstChild;)i=i.firstChild;w(i)&&(y(d,i,0),v(d,i,i.nodeValue.length))}n=1===d.endOffset?2:d.endOffset-1-t}for(var l=n;y(d,i,2<=n?n-2:0),v(d,i,1<=n?n-1:0),--n,!k(d.toString())&&0<=n-2;);k(d.toString())?(y(d,i,n),v(d,i,l),n+=1):(0===d.startOffset?y(d,i,0):y(d,i,n),v(d,i,l)),u=d.toString(),/[?!,.;:]/.test(u.charAt(u.length-1))&&v(d,i,l-1);var u,c,g,h,C=(u=d.toString().trim()).match(f),m=e.getParam("link_default_protocol","http","string");C&&((g=c=C[0]).length>=(h="www.").length&&g.substr(0,0+h.length)===h?c=m+"://"+c:-1===c.indexOf("@")||/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(c)||(c="mailto:"+c),o=e.selection.getBookmark(),e.selection.setRng(d),e.execCommand("createlink",!1,c),!1!==s&&e.dom.setAttrib(e.selection.getNode(),"target",s),e.selection.moveToBookmark(o),e.nodeChanged())}}var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),a=tinymce.util.Tools.resolve("tinymce.Env"),A=new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-+~=.,%()\/\w]*[-+~=%()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i");e.add("autolink",function(e){var t,n;(t=e).on("keydown",function(e){if(13===e.keyCode)return r(t,-1)}),a.browser.isIE()?t.on("focus",function(){if(!n){n=!0;try{t.execCommand("AutoUrlDetect",!1,!0)}catch(e){}}}):(t.on("keypress",function(e){if(41===e.keyCode||93===e.keyCode||125===e.keyCode)return r(t,-1)}),t.on("keyup",function(e){if(32===e.keyCode)return r(t,0)}))})}();
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),i=tinymce.util.Tools.resolve("tinymce.Env"),m=function(e){return e.getParam("autolink_pattern",/^(https?:\/\/|ssh:\/\/|ftp:\/\/|file:\/|www\.|(?:mailto:)?[A-Z0-9._%+\-]+@)(.+)$/i)},y=function(e){return e.getParam("default_link_target","")},o=function(e,t){if(t<0&&(t=0),3===e.nodeType){var n=e.data.length;n<t&&(t=n)}return t},k=function(e,t,n){1!==t.nodeType||t.hasChildNodes()?e.setStart(t,o(t,n)):e.setStartBefore(t)},p=function(e,t,n){1!==t.nodeType||t.hasChildNodes()?e.setEnd(t,o(t,n)):e.setEndAfter(t)},r=function(e,t,n){var i,o,r,a,f,s,d,l,c,u,g=m(e),h=y(e);if("A"!==e.selection.getNode().tagName){if((i=e.selection.getRng(!0).cloneRange()).startOffset<5){if(!(l=i.endContainer.previousSibling)){if(!i.endContainer.firstChild||!i.endContainer.firstChild.nextSibling)return;l=i.endContainer.firstChild.nextSibling}if(c=l.length,k(i,l,c),p(i,l,c),i.endOffset<5)return;o=i.endOffset,a=l}else{if(3!==(a=i.endContainer).nodeType&&a.firstChild){for(;3!==a.nodeType&&a.firstChild;)a=a.firstChild;3===a.nodeType&&(k(i,a,0),p(i,a,a.nodeValue.length))}o=1===i.endOffset?2:i.endOffset-1-t}for(r=o;k(i,a,2<=o?o-2:0),p(i,a,1<=o?o-1:0),o-=1," "!==(u=i.toString())&&""!==u&&160!==u.charCodeAt(0)&&0<=o-2&&u!==n;);var C;(C=i.toString())===n||" "===C||160===C.charCodeAt(0)?(k(i,a,o),p(i,a,r),o+=1):(0===i.startOffset?k(i,a,0):k(i,a,o),p(i,a,r)),"."===(s=i.toString()).charAt(s.length-1)&&p(i,a,r-1),(d=(s=i.toString().trim()).match(g))&&("www."===d[1]?d[1]="http://www.":/@$/.test(d[1])&&!/^mailto:/.test(d[1])&&(d[1]="mailto:"+d[1]),f=e.selection.getBookmark(),e.selection.setRng(i),e.execCommand("createlink",!1,d[1]+d[2]),h&&e.dom.setAttrib(e.selection.getNode(),"target",h),e.selection.moveToBookmark(f),e.nodeChanged())}},t=function(t){var n;t.on("keydown",function(e){13!==e.keyCode||r(t,-1,"")}),i.ie?t.on("focus",function(){if(!n){n=!0;try{t.execCommand("AutoUrlDetect",!1,!0)}catch(e){}}}):(t.on("keypress",function(e){41!==e.keyCode||r(t,-1,"(")}),t.on("keyup",function(e){32!==e.keyCode||r(t,0,"")}))};e.add("autolink",function(e){t(e)})}();

View File

@@ -1,9 +1 @@
/**
* Copyright (c) Tiny Technologies, Inc. All rights reserved.
* Licensed under the LGPL or a commercial license.
* For LGPL see License.txt in the project root for license information.
* For commercial licenses see https://www.tiny.cloud/
*
* Version: 5.10.2 (2021-11-17)
*/
!function(){"use strict";function y(e){return e.getParam("min_height",e.getElement().offsetHeight,"number")}function p(e,t){var n=e.getBody();n&&(n.style.overflowY=t?"":"hidden",t||(n.scrollTop=0))}function v(e,t,n,i){var o=parseInt(e.getStyle(t,n,i),10);return isNaN(o)?0:o}var l=Object.hasOwnProperty,e=tinymce.util.Tools.resolve("tinymce.PluginManager"),b=tinymce.util.Tools.resolve("tinymce.Env"),r=tinymce.util.Tools.resolve("tinymce.util.Delay"),u=function(e,t,n,i,o){r.setEditorTimeout(e,function(){C(e,t),n--?u(e,t,n,i,o):o&&o()},i)},C=function(e,t,n){var i,o,r,s,a,l,u,g,c,m,f,d=e.dom,h=e.getDoc();h&&(e.plugins.fullscreen&&e.plugins.fullscreen.isFullscreen()?p(e,!0):(i=h.documentElement,o=e.getParam("autoresize_bottom_margin",50,"number"),r=y(e),s=v(d,i,"margin-top",!0),a=v(d,i,"margin-bottom",!0),(l=(l=i.offsetHeight+s+a+o)<0?0:l)+(u=e.getContainer().offsetHeight-e.getContentAreaContainer().offsetHeight)>y(e)&&(r=l+u),(g=e.getParam("max_height",0,"number"))&&g<r?(r=g,p(e,!0)):p(e,!1),r!==t.get()&&(c=r-t.get(),d.setStyle(e.getContainer(),"height",r+"px"),t.set(r),e.fire("ResizeEditor"),b.browser.isSafari()&&b.mac&&(m=e.getWin()).scrollTo(m.pageXOffset,m.pageYOffset),!e.hasFocus()||"setcontent"!==(null==(f=n)?void 0:f.type.toLowerCase())||!0!==f.selection&&!0!==f.paste||e.selection.scrollIntoView(),b.webkit&&c<0&&C(e,t,n))))};e.add("autoresize",function(e){var t,n,i,o,r,s,a=e.settings;l.call(a,"resize")||(e.settings.resize=!1),e.inline||(s=0,r=t={get:function(){return s},set:function(e){s=e}},(o=e).addCommand("mceAutoResize",function(){C(o,r)}),i=t,(n=e).on("init",function(){var e=n.getParam("autoresize_overflow_padding",1,"number"),t=n.dom;t.setStyles(n.getDoc().documentElement,{height:"auto"}),t.setStyles(n.getBody(),{paddingLeft:e,paddingRight:e,"min-height":0})}),n.on("NodeChange SetContent keyup FullscreenStateChanged ResizeContent",function(e){C(n,i,e)}),n.getParam("autoresize_on_init",!0,"boolean")&&n.on("init",function(){u(n,i,20,100,function(){u(n,i,5,1e3)})}))})}();
!function(){"use strict";var i=function(t){var e=t,n=function(){return e};return{get:n,set:function(t){e=t},clone:function(){return i(n())}}},t=tinymce.util.Tools.resolve("tinymce.PluginManager"),y=tinymce.util.Tools.resolve("tinymce.Env"),r=tinymce.util.Tools.resolve("tinymce.util.Delay"),h=function(t){return parseInt(t.getParam("autoresize_min_height",t.getElement().offsetHeight),10)},v=function(t){return parseInt(t.getParam("autoresize_max_height",0),10)},o=function(t){return t.getParam("autoresize_overflow_padding",1)},a=function(t){return t.getParam("autoresize_bottom_margin",50)},n=function(t){return t.getParam("autoresize_on_init",!0)},u=function(t,e,n,i,o){r.setEditorTimeout(t,function(){_(t,e),n--?u(t,e,n,i,o):o&&o()},i)},S=function(t,e){var n=t.getBody();n&&(n.style.overflowY=e?"":"hidden",e||(n.scrollTop=0))},_=function(t,e){var n,i,o,r,a,u,s,l,g,c,f,d=t.dom;if(i=t.getDoc())if((m=t).plugins.fullscreen&&m.plugins.fullscreen.isFullscreen())S(t,!0);else{var m;o=i.body,r=h(t),u=d.getStyle(o,"margin-top",!0),s=d.getStyle(o,"margin-bottom",!0),l=d.getStyle(o,"padding-top",!0),g=d.getStyle(o,"padding-bottom",!0),c=d.getStyle(o,"border-top-width",!0),f=d.getStyle(o,"border-bottom-width",!0),a=o.offsetHeight+parseInt(u,10)+parseInt(s,10)+parseInt(l,10)+parseInt(g,10)+parseInt(c,10)+parseInt(f,10),(isNaN(a)||a<=0)&&(a=y.ie?o.scrollHeight:y.webkit&&0===o.clientHeight?0:o.offsetHeight),a>h(t)&&(r=a);var p=v(t);p&&p<a?(r=p,S(t,!0)):S(t,!1),r!==e.get()&&(n=r-e.get(),d.setStyle(t.iframeElement,"height",r+"px"),e.set(r),y.webkit&&n<0&&_(t,e))}},s={setup:function(i,e){i.on("init",function(){var t,e,n=i.dom;t=o(i),e=a(i),!1!==t&&n.setStyles(i.getBody(),{paddingLeft:t,paddingRight:t}),!1!==e&&n.setStyles(i.getBody(),{paddingBottom:e})}),i.on("nodechange setcontent keyup FullscreenStateChanged",function(t){_(i,e)}),n(i)&&i.on("init",function(){u(i,e,20,100,function(){u(i,e,5,1e3)})})},resize:_},l=function(t,e){t.addCommand("mceAutoResize",function(){s.resize(t,e)})};t.add("autoresize",function(t){if(!t.inline){var e=i(0);l(t,e),s.setup(t,e)}})}();

View File

@@ -1,9 +1 @@
/**
* Copyright (c) Tiny Technologies, Inc. All rights reserved.
* Licensed under the LGPL or a commercial license.
* For LGPL see License.txt in the project root for license information.
* For commercial licenses see https://www.tiny.cloud/
*
* Version: 5.10.2 (2021-11-17)
*/
!function(){"use strict";function o(t,e){var r=t||e,n=/^(\d+)([ms]?)$/.exec(""+r);return(n[2]?{s:1e3,m:6e4}[n[2]]:1)*parseInt(r,10)}function n(t){var e=document.location;return t.getParam("autosave_prefix","tinymce-autosave-{path}{query}{hash}-{id}-").replace(/{path}/g,e.pathname).replace(/{query}/g,e.search).replace(/{hash}/g,e.hash).replace(/{id}/g,t.id)}function i(t,e){if(a(e))return t.dom.isEmpty(t.getBody());var r=d.trim(e);if(""===r)return!0;var n=(new DOMParser).parseFromString(r,"text/html");return t.dom.isEmpty(n)}function u(t){var e=parseInt(v.getItem(n(t)+"time"),10)||0;return!((new Date).getTime()-e>o(t.getParam("autosave_retention"),"20m")&&(g(t,!1),1))}function s(t){var e=n(t);!i(t)&&t.isDirty()&&(v.setItem(e+"draft",t.getContent({format:"raw",no_events:!0})),v.setItem(e+"time",(new Date).getTime().toString()),t.fire("StoreDraft"))}function f(t){var e=n(t);u(t)&&(t.setContent(v.getItem(e+"draft"),{format:"raw"}),t.fire("RestoreDraft"))}function c(t){t.undoManager.transact(function(){f(t),g(t)}),t.focus()}function m(r){return function(t){function e(){return t.setDisabled(!u(r))}return t.setDisabled(!u(r)),r.on("StoreDraft RestoreDraft RemoveDraft",e),function(){return r.off("StoreDraft RestoreDraft RemoveDraft",e)}}}var t=tinymce.util.Tools.resolve("tinymce.PluginManager"),a=function(t){return void 0===t},l=tinymce.util.Tools.resolve("tinymce.util.Delay"),v=tinymce.util.Tools.resolve("tinymce.util.LocalStorage"),d=tinymce.util.Tools.resolve("tinymce.util.Tools"),g=function(t,e){var r=n(t);v.removeItem(r+"draft"),v.removeItem(r+"time"),!1!==e&&t.fire("RemoveDraft")},y=tinymce.util.Tools.resolve("tinymce.EditorManager");t.add("autosave",function(t){var e,r,n,a;return t.editorManager.on("BeforeUnload",function(t){var e;d.each(y.get(),function(t){t.plugins.autosave&&t.plugins.autosave.storeDraft(),!e&&t.isDirty()&&t.getParam("autosave_ask_before_unload",!0)&&(e=t.translate("You have unsaved changes are you sure you want to navigate away?"))}),e&&(t.preventDefault(),t.returnValue=e)}),n=e=t,a=o(n.getParam("autosave_interval"),"30s"),l.setEditorInterval(n,function(){s(n)},a),e.ui.registry.addButton("restoredraft",{tooltip:"Restore last draft",icon:"restore-draft",onAction:function(){c(e)},onSetup:m(e)}),e.ui.registry.addMenuItem("restoredraft",{text:"Restore last draft",icon:"restore-draft",onAction:function(){c(e)},onSetup:m(e)}),t.on("init",function(){t.getParam("autosave_restore_when_empty",!1)&&t.dom.isEmpty(t.getBody())&&f(t)}),r=t,{hasDraft:function(){return u(r)},storeDraft:function(){return s(r)},restoreDraft:function(){return f(r)},removeDraft:function(t){return g(r,t)},isEmpty:function(t){return i(r,t)}}})}();
!function(a){"use strict";var i=function(t){var e=t,n=function(){return e};return{get:n,set:function(t){e=t},clone:function(){return i(n())}}},t=tinymce.util.Tools.resolve("tinymce.PluginManager"),r=tinymce.util.Tools.resolve("tinymce.util.LocalStorage"),o=tinymce.util.Tools.resolve("tinymce.util.Tools"),u=function(t,e){var n=t||e,r=/^(\d+)([ms]?)$/.exec(""+n);return(r[2]?{s:1e3,m:6e4}[r[2]]:1)*parseInt(n,10)},s=function(t){var e=t.getParam("autosave_prefix","tinymce-autosave-{path}{query}{hash}-{id}-");return e=(e=(e=(e=e.replace(/\{path\}/g,a.document.location.pathname)).replace(/\{query\}/g,a.document.location.search)).replace(/\{hash\}/g,a.document.location.hash)).replace(/\{id\}/g,t.id)},c=function(t,e){var n=t.settings.forced_root_block;return""===(e=o.trim(void 0===e?t.getBody().innerHTML:e))||new RegExp("^<"+n+"[^>]*>((\xa0|&nbsp;|[ \t]|<br[^>]*>)+?|)</"+n+">|<br>$","i").test(e)},f=function(t){var e=parseInt(r.getItem(s(t)+"time"),10)||0;return!((new Date).getTime()-e>u(t.settings.autosave_retention,"20m")&&(l(t,!1),1))},l=function(t,e){var n=s(t);r.removeItem(n+"draft"),r.removeItem(n+"time"),!1!==e&&t.fire("RemoveDraft")},m=function(t){var e=s(t);!c(t)&&t.isDirty()&&(r.setItem(e+"draft",t.getContent({format:"raw",no_events:!0})),r.setItem(e+"time",(new Date).getTime().toString()),t.fire("StoreDraft"))},v=function(t){var e=s(t);f(t)&&(t.setContent(r.getItem(e+"draft"),{format:"raw"}),t.fire("RestoreDraft"))},d=function(t,e){var n=u(t.settings.autosave_interval,"30s");e.get()||(setInterval(function(){t.removed||m(t)},n),e.set(!0))},g=function(t){t.undoManager.transact(function(){v(t),l(t)}),t.focus()};function y(r){for(var o=[],t=1;t<arguments.length;t++)o[t-1]=arguments[t];return function(){for(var t=[],e=0;e<arguments.length;e++)t[e]=arguments[e];var n=o.concat(t);return r.apply(null,n)}}var p=tinymce.util.Tools.resolve("tinymce.EditorManager");p._beforeUnloadHandler=function(){var e;return o.each(p.get(),function(t){t.plugins.autosave&&t.plugins.autosave.storeDraft(),!e&&t.isDirty()&&t.getParam("autosave_ask_before_unload",!0)&&(e=t.translate("You have unsaved changes are you sure you want to navigate away?"))}),e};var h=function(n,r){return function(t){var e=t.control;e.disabled(!f(n)),n.on("StoreDraft RestoreDraft RemoveDraft",function(){e.disabled(!f(n))}),d(n,r)}};t.add("autosave",function(t){var e,n,r,o=i(!1);return a.window.onbeforeunload=p._beforeUnloadHandler,n=o,(e=t).addButton("restoredraft",{title:"Restore last draft",onclick:function(){g(e)},onPostRender:h(e,n)}),e.addMenuItem("restoredraft",{text:"Restore last draft",onclick:function(){g(e)},onPostRender:h(e,n),context:"file"}),t.on("init",function(){t.getParam("autosave_restore_when_empty",!1)&&t.dom.isEmpty(t.getBody())&&v(t)}),{hasDraft:y(f,r=t),storeDraft:y(m,r),restoreDraft:y(v,r),removeDraft:y(l,r),isEmpty:y(c,r)}})}(window);

View File

@@ -1,9 +1 @@
/**
* Copyright (c) Tiny Technologies, Inc. All rights reserved.
* Licensed under the LGPL or a commercial license.
* For LGPL see License.txt in the project root for license information.
* For commercial licenses see https://www.tiny.cloud/
*
* Version: 5.10.2 (2021-11-17)
*/
!function(){"use strict";function i(t){function o(o,e){t=t.replace(o,e)}return t=n.trim(t),o(/\n/gi,"<br />"),o(/\[b\]/gi,"<strong>"),o(/\[\/b\]/gi,"</strong>"),o(/\[i\]/gi,"<em>"),o(/\[\/i\]/gi,"</em>"),o(/\[u\]/gi,"<u>"),o(/\[\/u\]/gi,"</u>"),o(/\[url=([^\]]+)\](.*?)\[\/url\]/gi,'<a href="$1">$2</a>'),o(/\[url\](.*?)\[\/url\]/gi,'<a href="$1">$1</a>'),o(/\[img\](.*?)\[\/img\]/gi,'<img src="$1" />'),o(/\[color=(.*?)\](.*?)\[\/color\]/gi,'<font color="$1">$2</font>'),o(/\[code\](.*?)\[\/code\]/gi,'<span class="codeStyle">$1</span>&nbsp;'),o(/\[quote.*?\](.*?)\[\/quote\]/gi,'<span class="quoteStyle">$1</span>&nbsp;'),t}var o=tinymce.util.Tools.resolve("tinymce.PluginManager"),n=tinymce.util.Tools.resolve("tinymce.util.Tools");o.add("bbcode",function(o){o.on("BeforeSetContent",function(o){o.content=i(o.content)}),o.on("PostProcess",function(o){function e(o,e){t=t.replace(o,e)}var t;o.set&&(o.content=i(o.content)),o.get&&(o.content=(t=o.content,t=n.trim(t),e(/<a.*?href=\"(.*?)\".*?>(.*?)<\/a>/gi,"[url=$1]$2[/url]"),e(/<font.*?color=\"(.*?)\".*?class=\"codeStyle\".*?>(.*?)<\/font>/gi,"[code][color=$1]$2[/color][/code]"),e(/<font.*?color=\"(.*?)\".*?class=\"quoteStyle\".*?>(.*?)<\/font>/gi,"[quote][color=$1]$2[/color][/quote]"),e(/<font.*?class=\"codeStyle\".*?color=\"(.*?)\".*?>(.*?)<\/font>/gi,"[code][color=$1]$2[/color][/code]"),e(/<font.*?class=\"quoteStyle\".*?color=\"(.*?)\".*?>(.*?)<\/font>/gi,"[quote][color=$1]$2[/color][/quote]"),e(/<span style=\"color: ?(.*?);\">(.*?)<\/span>/gi,"[color=$1]$2[/color]"),e(/<font.*?color=\"(.*?)\".*?>(.*?)<\/font>/gi,"[color=$1]$2[/color]"),e(/<span style=\"font-size:(.*?);\">(.*?)<\/span>/gi,"[size=$1]$2[/size]"),e(/<font>(.*?)<\/font>/gi,"$1"),e(/<img.*?src=\"(.*?)\".*?\/>/gi,"[img]$1[/img]"),e(/<span class=\"codeStyle\">(.*?)<\/span>/gi,"[code]$1[/code]"),e(/<span class=\"quoteStyle\">(.*?)<\/span>/gi,"[quote]$1[/quote]"),e(/<strong class=\"codeStyle\">(.*?)<\/strong>/gi,"[code][b]$1[/b][/code]"),e(/<strong class=\"quoteStyle\">(.*?)<\/strong>/gi,"[quote][b]$1[/b][/quote]"),e(/<em class=\"codeStyle\">(.*?)<\/em>/gi,"[code][i]$1[/i][/code]"),e(/<em class=\"quoteStyle\">(.*?)<\/em>/gi,"[quote][i]$1[/i][/quote]"),e(/<u class=\"codeStyle\">(.*?)<\/u>/gi,"[code][u]$1[/u][/code]"),e(/<u class=\"quoteStyle\">(.*?)<\/u>/gi,"[quote][u]$1[/u][/quote]"),e(/<\/(strong|b)>/gi,"[/b]"),e(/<(strong|b)>/gi,"[b]"),e(/<\/(em|i)>/gi,"[/i]"),e(/<(em|i)>/gi,"[i]"),e(/<\/u>/gi,"[/u]"),e(/<span style=\"text-decoration: ?underline;\">(.*?)<\/span>/gi,"[u]$1[/u]"),e(/<u>/gi,"[u]"),e(/<blockquote[^>]*>/gi,"[quote]"),e(/<\/blockquote>/gi,"[/quote]"),e(/<br \/>/gi,"\n"),e(/<br\/>/gi,"\n"),e(/<br>/gi,"\n"),e(/<p>/gi,""),e(/<\/p>/gi,"\n"),e(/&nbsp;|\u00a0/gi," "),e(/&quot;/gi,'"'),e(/&lt;/gi,"<"),e(/&gt;/gi,">"),e(/&amp;/gi,"&"),t))})})}();
!function(){"use strict";var o=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.util.Tools"),e=function(e){e=t.trim(e);var o=function(o,t){e=e.replace(o,t)};return o(/<a.*?href=\"(.*?)\".*?>(.*?)<\/a>/gi,"[url=$1]$2[/url]"),o(/<font.*?color=\"(.*?)\".*?class=\"codeStyle\".*?>(.*?)<\/font>/gi,"[code][color=$1]$2[/color][/code]"),o(/<font.*?color=\"(.*?)\".*?class=\"quoteStyle\".*?>(.*?)<\/font>/gi,"[quote][color=$1]$2[/color][/quote]"),o(/<font.*?class=\"codeStyle\".*?color=\"(.*?)\".*?>(.*?)<\/font>/gi,"[code][color=$1]$2[/color][/code]"),o(/<font.*?class=\"quoteStyle\".*?color=\"(.*?)\".*?>(.*?)<\/font>/gi,"[quote][color=$1]$2[/color][/quote]"),o(/<span style=\"color: ?(.*?);\">(.*?)<\/span>/gi,"[color=$1]$2[/color]"),o(/<font.*?color=\"(.*?)\".*?>(.*?)<\/font>/gi,"[color=$1]$2[/color]"),o(/<span style=\"font-size:(.*?);\">(.*?)<\/span>/gi,"[size=$1]$2[/size]"),o(/<font>(.*?)<\/font>/gi,"$1"),o(/<img.*?src=\"(.*?)\".*?\/>/gi,"[img]$1[/img]"),o(/<span class=\"codeStyle\">(.*?)<\/span>/gi,"[code]$1[/code]"),o(/<span class=\"quoteStyle\">(.*?)<\/span>/gi,"[quote]$1[/quote]"),o(/<strong class=\"codeStyle\">(.*?)<\/strong>/gi,"[code][b]$1[/b][/code]"),o(/<strong class=\"quoteStyle\">(.*?)<\/strong>/gi,"[quote][b]$1[/b][/quote]"),o(/<em class=\"codeStyle\">(.*?)<\/em>/gi,"[code][i]$1[/i][/code]"),o(/<em class=\"quoteStyle\">(.*?)<\/em>/gi,"[quote][i]$1[/i][/quote]"),o(/<u class=\"codeStyle\">(.*?)<\/u>/gi,"[code][u]$1[/u][/code]"),o(/<u class=\"quoteStyle\">(.*?)<\/u>/gi,"[quote][u]$1[/u][/quote]"),o(/<\/(strong|b)>/gi,"[/b]"),o(/<(strong|b)>/gi,"[b]"),o(/<\/(em|i)>/gi,"[/i]"),o(/<(em|i)>/gi,"[i]"),o(/<\/u>/gi,"[/u]"),o(/<span style=\"text-decoration: ?underline;\">(.*?)<\/span>/gi,"[u]$1[/u]"),o(/<u>/gi,"[u]"),o(/<blockquote[^>]*>/gi,"[quote]"),o(/<\/blockquote>/gi,"[/quote]"),o(/<br \/>/gi,"\n"),o(/<br\/>/gi,"\n"),o(/<br>/gi,"\n"),o(/<p>/gi,""),o(/<\/p>/gi,"\n"),o(/&nbsp;|\u00a0/gi," "),o(/&quot;/gi,'"'),o(/&lt;/gi,"<"),o(/&gt;/gi,">"),o(/&amp;/gi,"&"),e},i=function(e){e=t.trim(e);var o=function(o,t){e=e.replace(o,t)};return o(/\n/gi,"<br />"),o(/\[b\]/gi,"<strong>"),o(/\[\/b\]/gi,"</strong>"),o(/\[i\]/gi,"<em>"),o(/\[\/i\]/gi,"</em>"),o(/\[u\]/gi,"<u>"),o(/\[\/u\]/gi,"</u>"),o(/\[url=([^\]]+)\](.*?)\[\/url\]/gi,'<a href="$1">$2</a>'),o(/\[url\](.*?)\[\/url\]/gi,'<a href="$1">$1</a>'),o(/\[img\](.*?)\[\/img\]/gi,'<img src="$1" />'),o(/\[color=(.*?)\](.*?)\[\/color\]/gi,'<font color="$1">$2</font>'),o(/\[code\](.*?)\[\/code\]/gi,'<span class="codeStyle">$1</span>&nbsp;'),o(/\[quote.*?\](.*?)\[\/quote\]/gi,'<span class="quoteStyle">$1</span>&nbsp;'),e};o.add("bbcode",function(){return{init:function(o){o.on("beforeSetContent",function(o){o.content=i(o.content)}),o.on("postProcess",function(o){o.set&&(o.content=i(o.content)),o.get&&(o.content=e(o.content))})}}})}();

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1 @@
/**
* Copyright (c) Tiny Technologies, Inc. All rights reserved.
* Licensed under the LGPL or a commercial license.
* For LGPL see License.txt in the project root for license information.
* For commercial licenses see https://www.tiny.cloud/
*
* Version: 5.10.2 (2021-11-17)
*/
!function(){"use strict";tinymce.util.Tools.resolve("tinymce.PluginManager").add("code",function(e){var t,o;function n(){return o.execCommand("mceCodeEditor")}return(t=e).addCommand("mceCodeEditor",function(){var n,e;e=(n=t).getContent({source_view:!0}),n.windowManager.open({title:"Source Code",size:"large",body:{type:"panel",items:[{type:"textarea",name:"code"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{code:e},onSubmit:function(e){var t=n,o=e.getData().code;t.focus(),t.undoManager.transact(function(){t.setContent(o)}),t.selection.setCursorLocation(),t.nodeChanged(),e.close()}})}),(o=e).ui.registry.addButton("code",{icon:"sourcecode",tooltip:"Source code",onAction:n}),o.ui.registry.addMenuItem("code",{icon:"sourcecode",text:"Source code",onAction:n}),{}})}();
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager"),n=tinymce.util.Tools.resolve("tinymce.dom.DOMUtils"),o=function(t){return t.getParam("code_dialog_width",600)},i=function(t){return t.getParam("code_dialog_height",Math.min(n.DOM.getViewPort().h-200,500))},c=function(t,n){t.focus(),t.undoManager.transact(function(){t.setContent(n)}),t.selection.setCursorLocation(),t.nodeChanged()},d=function(t){return t.getContent({source_view:!0})},e=function(n){var t=o(n),e=i(n);n.windowManager.open({title:"Source code",body:{type:"textbox",name:"code",multiline:!0,minWidth:t,minHeight:e,spellcheck:!1,style:"direction: ltr; text-align: left"},onSubmit:function(t){c(n,t.data.code)}}).find("#code").value(d(n))},u=function(t){t.addCommand("mceCodeEditor",function(){e(t)})},a=function(t){t.addButton("code",{icon:"code",tooltip:"Source code",onclick:function(){e(t)}}),t.addMenuItem("code",{icon:"code",text:"Source code",onclick:function(){e(t)}})};t.add("code",function(t){return u(t),a(t),{}})}();

View File

@@ -0,0 +1,138 @@
/* http://prismjs.com/download.html?themes=prism&languages=markup+css+clike+javascript */
/**
* prism.js default theme for JavaScript, CSS and HTML
* Based on dabblet (http://dabblet.com)
* @author Lea Verou
*/
code[class*="language-"],
pre[class*="language-"] {
color: black;
text-shadow: 0 1px white;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
text-shadow: none;
background: #b3d4fc;
}
pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
code[class*="language-"]::selection, code[class*="language-"] ::selection {
text-shadow: none;
background: #b3d4fc;
}
@media print {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none;
}
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #f5f2f0;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: .1em;
border-radius: .3em;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: slategray;
}
.token.punctuation {
color: #999;
}
.namespace {
opacity: .7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #905;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #690;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #a67f59;
background: hsla(0, 0%, 100%, .5);
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #07a;
}
.token.function {
color: #DD4A68;
}
.token.regex,
.token.important,
.token.variable {
color: #e90;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}

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