# Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon
AVATAR_URL=
-# Enable draw.io integration
+# Enable diagrams.net integration
# Can simply be true/false to enable/disable the integration.
-# Alternatively, It can be URL to the draw.io instance you want to use.
+# Alternatively, It can be URL to the diagrams.net instance you want to use.
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1
DRAWIO=true
Jakub Bouček (jakubboucek) :: Czech
Marco (cdrfun) :: German
10935336 :: Chinese Simplified
+孟繁阳 (FanyangMeng) :: Chinese Simplified
use BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Entities\EntityProvider;
-use BookStack\Entities\Page;
use BookStack\Ownable;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use BookStack\Entities\Page;
use DOMDocument;
-use DOMElement;
use DOMNodeList;
use DOMXPath;
$container = $doc->documentElement;
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
+ $xPath = new DOMXPath($doc);
// Set ids on top-level nodes
$idMap = [];
foreach ($childNodes as $index => $childNode) {
- $this->setUniqueId($childNode, $idMap);
+ [$oldId, $newId] = $this->setUniqueId($childNode, $idMap);
+ if ($newId && $newId !== $oldId) {
+ $this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
+ }
}
// Ensure no duplicate ids within child items
- $xPath = new DOMXPath($doc);
$idElems = $xPath->query('//body//*//*[@id]');
foreach ($idElems as $domElem) {
- $this->setUniqueId($domElem, $idMap);
+ [$oldId, $newId] = $this->setUniqueId($domElem, $idMap);
+ if ($newId && $newId !== $oldId) {
+ $this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
+ }
}
// Generate inner html as a string
return $html;
}
+ /**
+ * Update the all links to the $old location to instead point to $new.
+ */
+ protected function updateLinks(DOMXPath $xpath, string $old, string $new)
+ {
+ $old = str_replace('"', '', $old);
+ $matchingLinks = $xpath->query('//body//*//*[@href="'.$old.'"]');
+ foreach ($matchingLinks as $domElem) {
+ $domElem->setAttribute('href', $new);
+ }
+ }
+
/**
* Set a unique id on the given DOMElement.
* A map for existing ID's should be passed in to check for current existence.
- * @param DOMElement $element
- * @param array $idMap
+ * Returns a pair of strings in the format [old_id, new_id]
*/
- protected function setUniqueId($element, array &$idMap)
+ protected function setUniqueId(\DOMNode $element, array &$idMap): array
{
if (get_class($element) !== 'DOMElement') {
- return;
+ return ['', ''];
}
- // Overwrite id if not a BookStack custom id
+ // Stop if there's an existing valid id that has not already been used.
$existingId = $element->getAttribute('id');
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
$idMap[$existingId] = true;
- return;
+ return [$existingId, $existingId];
}
// Create an unique id for the element
$element->setAttribute('id', $newId);
$idMap[$newId] = true;
+ return [$existingId, $newId];
}
/**
$scriptElem->parentNode->removeChild($scriptElem);
}
+ // Remove clickable links to JavaScript URI
+ $badLinks = $xPath->query('//*[contains(@href, \'javascript:\')]');
+ foreach ($badLinks as $badLink) {
+ $badLink->parentNode->removeChild($badLink);
+ }
+
+ // Remove forms with calls to JavaScript URI
+ $badForms = $xPath->query('//*[contains(@action, \'javascript:\')] | //*[contains(@formaction, \'javascript:\')]');
+ foreach ($badForms as $badForm) {
+ $badForm->parentNode->removeChild($badForm);
+ }
+
+ // Remove meta tag to prevent external redirects
+ $metaTags = $xPath->query('//meta[contains(@content, \'url\')]');
+ foreach ($metaTags as $metaTag) {
+ $metaTag->parentNode->removeChild($metaTag);
+ }
+
// Remove data or JavaScript iFrames
$badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
foreach ($badIframes as $badIframe) {
try {
$this->validate($request, [
'attachment_edit_name' => 'required|string|min:1|max:255',
- 'attachment_edit_url' => 'string|min:1|max:255'
+ 'attachment_edit_url' => 'string|min:1|max:255|safe_url'
]);
} catch (ValidationException $exception) {
return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [
$this->validate($request, [
'attachment_link_uploaded_to' => 'required|integer|exists:pages,id',
'attachment_link_name' => 'required|string|min:1|max:255',
- 'attachment_link_url' => 'required|string|min:1|max:255'
+ 'attachment_link_url' => 'required|string|min:1|max:255|safe_url'
]);
} catch (ValidationException $exception) {
return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [
$attachmentName = $request->get('attachment_link_name');
$link = $request->get('attachment_link_url');
- $attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
+ $attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId));
return view('attachments.manager-link-form', [
'pageId' => $pageId,
use BookStack\Entities\Page;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Http\Controllers\Controller;
-use BookStack\Repos\PageRepo;
+use BookStack\Entities\Repos\PageRepo;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo;
use Exception;
return substr_count($uploadName, '.') < 2;
});
+ Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) {
+ $cleanLinkName = strtolower(trim($value));
+ $isJs = strpos($cleanLinkName, 'javascript:') === 0;
+ $isData = strpos($cleanLinkName, 'data:') === 0;
+ return !$isJs && !$isData;
+ });
+
// Custom blade view directives
Blade::directive('icon', function ($expression) {
return "<?php echo icon($expression); ?>";
/**
* Save a new File attachment from a given link and name.
- * @param string $name
- * @param string $link
- * @param int $page_id
- * @return Attachment
*/
- public function saveNewFromLink($name, $link, $page_id)
+ public function saveNewFromLink(string $name, string $link, int $page_id): Attachment
{
$largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
return Attachment::forceCreate([
/**
* Update the details of a file.
- * @param Attachment $attachment
- * @param $requestData
- * @return Attachment
*/
- public function updateFile(Attachment $attachment, $requestData)
+ public function updateFile(Attachment $attachment, array $requestData): Attachment
{
$attachment->name = $requestData['name'];
+
if (isset($requestData['link']) && trim($requestData['link']) !== '') {
$attachment->path = $requestData['link'];
if (!$attachment->external) {
$attachment->external = true;
}
}
+
$attachment->save();
return $attachment;
}
/**
* Get the path to a versioned file.
- *
- * @param string $file
- * @return string
* @throws Exception
*/
function versioned_asset(string $file = ''): string
/**
* Helper method to get the current User.
* Defaults to public 'Guest' user if not logged in.
- * @return User
*/
function user(): User
{
}
/**
- * Check if the current user has a permission.
- * If an ownable element is passed in the jointPermissions are checked against
- * that particular item.
+ * Check if the current user has a permission. If an ownable element
+ * is passed in the jointPermissions are checked against that particular item.
*/
function userCan(string $permission, Ownable $ownable = null): bool
{
/**
* Check if the current user has the given permission
* on any item in the system.
- * @param string $permission
- * @param string|null $entityClass
- * @return bool
*/
function userCanOnAny(string $permission, string $entityClass = null): bool
{
/**
* Helper to access system settings.
- * @param string $key
- * @param $default
* @return bool|string|SettingService
*/
function setting(string $key = null, $default = false)
{
$settingService = resolve(SettingService::class);
+
if (is_null($key)) {
return $settingService;
}
+
return $settingService->get($key, $default);
}
/**
* Get a path to a theme resource.
- * @param string $path
- * @return string
*/
function theme_path(string $path = ''): string
{
$theme = config('view.theme');
+
if (!$theme) {
return '';
}
* to the 'resources/assets/icons' folder.
*
* Returns an empty string if icon file not found.
- * @param $name
- * @param array $attrs
- * @return mixed
*/
function icon(string $name, array $attrs = []): string
{
$iconPath = resource_path('icons/' . $name . '.svg');
$themeIconPath = theme_path('icons/' . $name . '.svg');
+
if ($themeIconPath && file_exists($themeIconPath)) {
$iconPath = $themeIconPath;
} else if (!file_exists($iconPath)) {
/*
|--------------------------------------------------------------------------
-| Initialize The App
+| Register The Auto Loader
|--------------------------------------------------------------------------
|
-| We need to get things going before we start up the app.
-| The init file loads everything in, in the correct order.
+| Composer provides a convenient, automatically generated class loader
+| for our application. We just need to utilize it! We'll require it
+| into the script here so that we do not have to worry about the
+| loading of any our classes "manually". Feels great to relax.
|
*/
-require __DIR__.'/bootstrap/init.php';
+require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
+++ /dev/null
-<?php
-
-/*
-|--------------------------------------------------------------------------
-| Load Our Own Helpers
-|--------------------------------------------------------------------------
-|
-| This custom function loads any helpers, before the Laravel Framework
-| is built so we can override any helpers as we please.
-|
-*/
-require __DIR__.'/../app/helpers.php';
-
-/*
-|--------------------------------------------------------------------------
-| Register The Composer Auto Loader
-|--------------------------------------------------------------------------
-|
-| Composer provides a convenient, automatically generated class loader
-| for our application. We just need to utilize it! We'll require it
-| into the script here so that we do not have to worry about the
-| loading of any our classes "manually". Feels great to relax.
-|
-*/
-require __DIR__.'/../vendor/autoload.php';
\ No newline at end of file
],
"psr-4": {
"BookStack\\": "app/"
- }
+ },
+ "files": [
+ "app/helpers.php"
+ ]
},
"autoload-dev": {
"psr-4": {
if [[ -n "$1" ]]; then
exec "$@"
else
+ composer install
wait-for-it db:3306 -t 45
php artisan migrate --database=mysql
chown -R www-data:www-data storage
exec apache2-foreground
-fi
\ No newline at end of file
+fi
npm install
npm rebuild node-sass
-exec npm run watch
\ No newline at end of file
+SHELL=/bin/sh exec npm run watch
}
},
"esbuild": {
- "version": "0.6.30",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.6.30.tgz",
- "integrity": "sha512-ZSZY461UPzTYYC3rqy1QiMtngk2WyXf+58MgC7tC22jkI90FXNgEl0hN3ipfn/UgZYzTW2GBcHiO7t0rSbHT7g==",
+ "version": "0.7.8",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.7.8.tgz",
+ "integrity": "sha512-6UT1nZB+8ja5avctUC6d3kGOUAhy6/ZYHljL4nk3++1ipadghBhUCAcwsTHsmUvdu04CcGKzo13mE+ZQ2O3zrA==",
"dev": true
},
"escape-string-regexp": {
"dev": true
},
"markdown-it": {
- "version": "11.0.0",
- "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-11.0.0.tgz",
- "integrity": "sha512-+CvOnmbSubmQFSA9dKz1BRiaSMV7rhexl3sngKqFyXSagoA3fBdJQ8oZWtRy2knXdpDXaBw44euz37DeJQ9asg==",
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-11.0.1.tgz",
+ "integrity": "sha512-aU1TzmBKcWNNYvH9pjq6u92BML+Hz3h5S/QpfTFwiQF852pLT+9qHsrhM9JYipkOXZxGn+sGH8oyJE9FD9WezQ==",
"requires": {
"argparse": "^1.0.7",
"entities": "~2.0.0",
}
},
"sass": {
- "version": "1.26.10",
- "resolved": "https://registry.npmjs.org/sass/-/sass-1.26.10.tgz",
- "integrity": "sha512-bzN0uvmzfsTvjz0qwccN1sPm2HxxpNI/Xa+7PlUEMS+nQvbyuEK7Y0qFqxlPHhiNHb1Ze8WQJtU31olMObkAMw==",
+ "version": "1.26.11",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.26.11.tgz",
+ "integrity": "sha512-W1l/+vjGjIamsJ6OnTe0K37U2DBO/dgsv2Z4c89XQ8ZOO6l/VwkqwLSqoYzJeJs6CLuGSTRWc91GbQFL3lvrvw==",
"dev": true,
"requires": {
"chokidar": ">=2.0.0 <4.0.0"
"dev": true
},
"sortablejs": {
- "version": "1.10.2",
- "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz",
- "integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A=="
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.12.0.tgz",
+ "integrity": "sha512-bPn57rCjBRlt2sC24RBsu40wZsmLkSo2XeqG8k6DC1zru5eObQUIPPZAQG7W2SJ8FZQYq+BEJmvuw1Zxb3chqg=="
},
"spdx-correct": {
"version": "3.1.1",
"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/index.js --outfile=public/dist/app.js --sourcemap --target=es2020",
- "build:js:watch": "chokidar \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
- "build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --minify",
+ "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/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main --minify",
"build": "npm-run-all --parallel build:*:dev",
"production": "npm-run-all --parallel build:*:production",
"dev": "npm-run-all --parallel watch livereload",
},
"devDependencies": {
"chokidar-cli": "^2.1.0",
- "esbuild": "0.6.30",
+ "esbuild": "0.7.8",
"livereload": "^0.9.1",
"npm-run-all": "^4.1.5",
"punycode": "^2.1.1",
- "sass": "^1.26.10"
+ "sass": "^1.26.11"
},
"dependencies": {
"clipboard": "^2.0.6",
"codemirror": "^5.58.1",
"dropzone": "^5.7.2",
- "markdown-it": "^11.0.0",
+ "markdown-it": "^11.0.1",
"markdown-it-task-lists": "^2.1.1",
- "sortablejs": "^1.10.2"
+ "sortablejs": "^1.12.0"
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
- bootstrap="bootstrap/init.php"
+ bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
- RewriteRule ^(.*)/$ /$1 [L,R=301]
+ RewriteCond %{REQUEST_URI} (.+)/$
+ RewriteRule ^ %1 [L,R=301]
- # Handle Front Controller...
+ # Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
/*
|--------------------------------------------------------------------------
-| Initialize The App
+| Register The Auto Loader
|--------------------------------------------------------------------------
|
-| We need to get things going before we start up the app.
-| The init file loads everything in, in the correct order.
+| Composer provides a convenient, automatically generated class loader for
+| our application. We just need to utilize it! We'll simply require it
+| into the script here so that we don't have to worry about manual
+| loading any of our classes later on. It feels great to relax.
|
*/
-require __DIR__.'/../bootstrap/init.php';
+require __DIR__.'/../vendor/autoload.php';
/*
|--------------------------------------------------------------------------
If all the conditions are met, you can proceed with the following steps:
-1. Install PHP/Composer dependencies with **`docker-compose run app composer install`** (first time can take a while because the image has to be built).
-2. **Copy `.env.example` to `.env`** and change `APP_KEY` to a random 32 char string.
-3. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
-4. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
-5. **Run `docker-compose up`** and wait until all database migrations have been done.
-6. You can now login with `
[email protected]` and `password` as password on `localhost:8080` (or another port if specified).
+1. **Copy `.env.example` to `.env`**, change `APP_KEY` to a random 32 char string and set `APP_ENV` to `local`.
+2. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
+3. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
+4. **Run `docker-compose up`** and wait until the image is built and all database migrations have been done.
+5. You can now login with `
[email protected]` and `password` as password on `localhost:8080` (or another port if specified).
If needed, You'll be able to run any artisan commands via docker-compose like so:
* [Snappy (WKHTML2PDF)](https://github.com/barryvdh/laravel-snappy)
* [Laravel IDE helper](https://github.com/barryvdh/laravel-ide-helper)
* [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html)
-* [Draw.io](https://github.com/jgraph/drawio)
+* [diagrams.net](https://github.com/jgraph/drawio)
* [Laravel Stats](https://github.com/stefanzweifel/laravel-stats)
-* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
\ No newline at end of file
+* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
-import {Sortable, MultiDrag} from "sortablejs";
+import Sortable from "sortablejs";
// Auto sort control
const sortOperations = {
this.input = elem.querySelector('[book-sort-input]');
const initialSortBox = elem.querySelector('.sort-box');
- Sortable.mount(new MultiDrag());
this.setupBookSortable(initialSortBox);
this.setupSortPresets();
const data = {
image: pngData,
- uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
+ uploaded_to: Number(this.pageId),
};
- window.$http.post(window.baseUrl('/images/drawio'), data).then(resp => {
+ window.$http.post("/images/drawio", data).then(resp => {
this.insertDrawing(resp.data, cursorPos);
DrawIO.close();
}).catch(err => {
let data = {
image: pngData,
- uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
+ uploaded_to: Number(this.pageId),
};
- window.$http.post(window.baseUrl(`/images/drawio`), data).then(resp => {
+ window.$http.post("/images/drawio", data).then(resp => {
let newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
let newContent = this.cm.getValue().split('\n').map(line => {
if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
import 'codemirror/mode/shell/shell';
import 'codemirror/mode/sql/sql';
import 'codemirror/mode/toml/toml';
+import 'codemirror/mode/vbscript/vbscript';
import 'codemirror/mode/xml/xml';
import 'codemirror/mode/yaml/yaml';
bash: 'shell',
toml: 'toml',
sql: 'text/x-sql',
+ vbs: 'vbscript',
+ vbscript: 'vbscript',
xml: 'xml',
yaml: 'yaml',
yml: 'yaml',
/**
* Get the content from a fetch response.
* Checks the content-type header to determine the format.
- * @param response
+ * @param {Response} response
* @returns {Promise<Object|String>}
*/
async function getResponseContent(response) {
+ if (response.status === 204) {
+ return null;
+ }
+
const responseContentType = response.headers.get('Content-Type');
const subType = responseContentType.split('/').pop();
'required_without' => 'The :attribute field is required when :values is not present.',
'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute and :other must match.',
+ 'safe_url' => 'The provided link may not be safe.',
'size' => [
'numeric' => 'The :attribute must be :size.',
'file' => 'The :attribute must be :size kilobytes.',
'user_api_token_name_desc' => 'Dale a tu token un nombre legible como un recordatorio futuro de su propósito.',
'user_api_token_expiry' => 'Fecha de expiración',
'user_api_token_expiry_desc' => 'Establece una fecha en la que este token expira. Después de esta fecha, las solicitudes realizadas usando este token ya no funcionarán. Dejar este campo en blanco fijará un vencimiento de 100 años en el futuro.',
- 'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.',
+ 'user_api_token_create_secret_message' => 'Inmediatamente después de crear este token se generarán y mostrarán sus correspondientes "Token ID" y "Token Secret". El "Token Secret" sólo se mostrará una vez, así que asegúrese de copiar el valor a un lugar seguro antes de proceder.',
'user_api_token_create_success' => 'Token API creado correctamente',
'user_api_token_update_success' => 'Token API actualizado correctamente',
'user_api_token' => 'Token API',
'user_api_token_id_desc' => 'Este es un identificador no editable generado por el sistema y único para este token que necesitará ser proporcionado en solicitudes de API.',
'user_api_token_secret' => 'Token Secret',
'user_api_token_secret_desc' => 'Esta es una clave no editable generada por el sistema que necesitará ser proporcionada en solicitudes de API. Solo se monstraré esta vez así que guarde su valor en un lugar seguro.',
- 'user_api_token_created' => 'Token created :timeAgo',
- 'user_api_token_updated' => 'Token updated :timeAgo',
+ 'user_api_token_created' => 'Token creado :timeAgo',
+ 'user_api_token_updated' => 'Token actualizado :timeAgo',
'user_api_token_delete' => 'Borrar token',
'user_api_token_delete_warning' => 'Esto eliminará completamente este token API con el nombre \':tokenName\' del sistema.',
'user_api_token_delete_confirm' => '¿Está seguro de que desea borrar este API token?',
'reg_enable_desc' => '启用注册后,用户将可以自己注册为站点用户。 注册后,他们将获得一个默认的单一用户角色。',
'reg_default_role' => '注册后的默认用户角色',
'reg_enable_external_warning' => '当启用外部LDAP或者SAML认证时,上面的选项会被忽略。当使用外部系统认证认证成功时,将自动创建非现有会员的用户账户。',
- 'reg_email_confirmation' => '邮箱确认n',
+ 'reg_email_confirmation' => '邮件确认',
'reg_email_confirmation_toggle' => '需要电子邮件确认',
'reg_confirm_email_desc' => '如果使用域名限制,则需要Email验证,并且该值将被忽略。',
'reg_confirm_restrict_domain' => '域名限制',
'audit_table_event' => '事件',
'audit_table_item' => '相关项目',
'audit_table_date' => '活动日期',
- 'audit_date_from' => 'Date Range From',
- 'audit_date_to' => 'Date Range To',
+ 'audit_date_from' => '日期范围从',
+ 'audit_date_to' => '日期范围至',
// Role Settings
'roles' => '角色',
<div component="ajax-form"
option:ajax-form:url="/attachments/{{ $attachment->id }}"
option:ajax-form:method="put"
+ option:ajax-form:response-container=".attachment-edit-container"
option:ajax-form:success-message="{{ trans('entities.attachments_updated_success') }}">
<h5>{{ trans('entities.attachments_edit_file') }}</h5>
<div component="ajax-form"
option:ajax-form:url="/attachments/link"
option:ajax-form:method="post"
+ option:ajax-form:response-container=".link-form-container"
option:ajax-form:success-message="{{ trans('entities.attachments_link_attached') }}">
<input type="hidden" name="attachment_link_uploaded_to" value="{{ $pageId }}">
<p class="text-muted small">{{ trans('entities.attachments_explain_link') }}</p>
'successMessage' => trans('entities.attachments_file_uploaded'),
])
</div>
- <div refs="tabs@contentLinks" class="hidden">
+ <div refs="tabs@contentLinks" class="hidden link-form-container">
@include('attachments.manager-link-form', ['pageId' => $page->id])
</div>
</div>
</div>
- <div refs="attachments@editContainer" class="hidden">
+ <div refs="attachments@editContainer" class="hidden attachment-edit-container">
</div>
<a refs="code-editor@languageLink" data-lang="Ruby">Ruby</a>
<a refs="code-editor@languageLink" data-lang="shell">Shell/Bash</a>
<a refs="code-editor@languageLink" data-lang="SQL">SQL</a>
+ <a refs="code-editor@languageLink" data-lang="VBScript">VBScript</a>
<a refs="code-editor@languageLink" data-lang="XML">XML</a>
<a refs="code-editor@languageLink" data-lang="YAML">YAML</a>
</small>
</div>
</div>
-</div>
\ No newline at end of file
+</div>
<div component="page-editor" class="page-editor flex-fill flex"
option:page-editor:drafts-enabled="{{ $draftsEnabled ? 'true' : 'false' }}"
@if(config('services.drawio'))
- drawio-url="{{ is_string(config('services.drawio')) ? config('services.drawio') : 'https://www.draw.io/?embed=1&proto=json&spin=1' }}"
+ drawio-url="{{ is_string(config('services.drawio')) ? config('services.drawio') : 'https://embed.diagrams.net/?embed=1&proto=json&spin=1' }}"
@endif
@if($model->name === trans('entities.pages_initial_name'))
option:page-editor:has-default-title="true"
}
+ public function test_javascript_uri_links_are_removed()
+ {
+ $checks = [
+ '<a id="xss" href="javascript:alert(document.cookie)>Click me</a>',
+ '<a id="xss" href="javascript: alert(document.cookie)>Click me</a>'
+ ];
+
+ $this->asEditor();
+ $page = Page::first();
+
+ foreach ($checks as $check) {
+ $page->html = $check;
+ $page->save();
+
+ $pageView = $this->get($page->getUrl());
+ $pageView->assertStatus(200);
+ $pageView->assertElementNotContains('.page-content', '<a id="xss">');
+ $pageView->assertElementNotContains('.page-content', 'href=javascript:');
+ }
+ }
+ public function test_form_actions_with_javascript_are_removed()
+ {
+ $checks = [
+ '<form><input id="xss" type=submit formaction=javascript:alert(document.domain) value=Submit><input></form>',
+ '<form ><button id="xss" formaction=javascript:alert(document.domain)>Click me</button></form>',
+ '<form id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></form>'
+ ];
+
+ $this->asEditor();
+ $page = Page::first();
+
+ foreach ($checks as $check) {
+ $page->html = $check;
+ $page->save();
+
+ $pageView = $this->get($page->getUrl());
+ $pageView->assertStatus(200);
+ $pageView->assertElementNotContains('.page-content', '<button id="xss"');
+ $pageView->assertElementNotContains('.page-content', '<input id="xss"');
+ $pageView->assertElementNotContains('.page-content', '<form id="xss"');
+ $pageView->assertElementNotContains('.page-content', 'action=javascript:');
+ $pageView->assertElementNotContains('.page-content', 'formaction=javascript:');
+ }
+ }
+
+ public function test_metadata_redirects_are_removed()
+ {
+ $checks = [
+ '<meta http-equiv="refresh" content="0; url=//external_url">',
+ ];
+
+ $this->asEditor();
+ $page = Page::first();
+
+ foreach ($checks as $check) {
+ $page->html = $check;
+ $page->save();
+
+ $pageView = $this->get($page->getUrl());
+ $pageView->assertStatus(200);
+ $pageView->assertElementNotContains('.page-content', '<meta>');
+ $pageView->assertElementNotContains('.page-content', '</meta>');
+ $pageView->assertElementNotContains('.page-content', 'content=');
+ $pageView->assertElementNotContains('.page-content', 'external_url');
+ }
+ }
public function test_page_inline_on_attributes_removed_by_default()
{
$this->asEditor();
$this->assertEquals(substr_count($updatedPage->html, "bkmrk-test\""), 1);
}
+ public function test_anchors_referencing_non_bkmrk_ids_rewritten_after_save()
+ {
+ $this->asEditor();
+ $page = Page::first();
+
+ $content = '<h1 id="non-standard-id">test</h1><p><a href="#non-standard-id">link</a></p>';
+ $this->put($page->getUrl(), [
+ 'name' => $page->name,
+ 'html' => $content,
+ 'summary' => ''
+ ]);
+
+ $updatedPage = Page::where('id', '=', $page->id)->first();
+ $this->assertStringContainsString('id="bkmrk-test"', $updatedPage->html);
+ $this->assertStringContainsString('href="#bkmrk-test"', $updatedPage->html);
+ }
+
public function test_get_page_nav_sets_correct_properties()
{
$content = '<h1 id="testa">Hello</h1><h2 id="testb">There</h2><h3 id="testc">Donkey</h3>';
public function test_bookshelf_update_restriction()
{
- $shelf = BookShelf::first();
+ $shelf = Bookshelf::first();
$this->actingAs($this->user)
->visit($shelf->getUrl('/edit'))
use BookStack\Uploads\Attachment;
use BookStack\Entities\Page;
use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Uploads\AttachmentService;
+use Illuminate\Http\UploadedFile;
use Tests\TestCase;
+use Tests\TestResponse;
class AttachmentTest extends TestCase
{
/**
* Get a test file that can be uploaded
- * @param $fileName
- * @return \Illuminate\Http\UploadedFile
*/
- protected function getTestFile($fileName)
+ protected function getTestFile(string $fileName): UploadedFile
{
- return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
+ return new UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
}
/**
* Uploads a file with the given name.
- * @param $name
- * @param int $uploadedTo
- * @return \Illuminate\Foundation\Testing\TestResponse
*/
- protected function uploadFile($name, $uploadedTo = 0)
+ protected function uploadFile(string $name, int $uploadedTo = 0): \Illuminate\Foundation\Testing\TestResponse
{
$file = $this->getTestFile($name);
return $this->call('POST', '/attachments/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
}
+ /**
+ * Create a new attachment
+ */
+ protected function createAttachment(Page $page): Attachment
+ {
+ $this->post('attachments/link', [
+ 'attachment_link_url' => 'https://example.com',
+ 'attachment_link_name' => 'Example Attachment Link',
+ 'attachment_link_uploaded_to' => $page->id,
+ ]);
+
+ return Attachment::query()->latest()->first();
+ }
+
/**
* Delete all uploaded files.
* To assist with cleanup.
*/
protected function deleteUploads()
{
- $fileService = $this->app->make(\BookStack\Uploads\AttachmentService::class);
+ $fileService = $this->app->make(AttachmentService::class);
foreach (Attachment::all() as $file) {
$fileService->deleteFile($file);
}
$page = Page::first();
$this->asAdmin();
- $this->call('POST', 'attachments/link', [
- 'attachment_link_url' => 'https://example.com',
- 'attachment_link_name' => 'Example Attachment Link',
- 'attachment_link_uploaded_to' => $page->id,
- ]);
-
- $attachmentId = Attachment::first()->id;
-
- $update = $this->call('PUT', 'attachments/' . $attachmentId, [
+ $attachment = $this->createAttachment($page);
+ $update = $this->call('PUT', 'attachments/' . $attachment->id, [
'attachment_edit_name' => 'My new attachment name',
'attachment_edit_url' => 'https://test.example.com'
]);
$expectedData = [
- 'id' => $attachmentId,
+ 'id' => $attachment->id,
'path' => 'https://test.example.com',
'name' => 'My new attachment name',
'uploaded_to' => $page->id
$this->deleteUploads();
}
+
+ public function test_data_and_js_links_cannot_be_attached_to_a_page()
+ {
+ $page = Page::first();
+ $this->asAdmin();
+
+ $badLinks = [
+ 'javascript:alert("bunny")',
+ ' javascript:alert("bunny")',
+ 'JavaScript:alert("bunny")',
+ "\t\n\t\nJavaScript:alert(\"bunny\")",
+ "data:text/html;<a></a>",
+ "Data:text/html;<a></a>",
+ "Data:text/html;<a></a>",
+ ];
+
+ foreach ($badLinks as $badLink) {
+ $linkReq = $this->post('attachments/link', [
+ 'attachment_link_url' => $badLink,
+ 'attachment_link_name' => 'Example Attachment Link',
+ 'attachment_link_uploaded_to' => $page->id,
+ ]);
+ $linkReq->assertStatus(422);
+ $this->assertDatabaseMissing('attachments', [
+ 'path' => $badLink,
+ ]);
+ }
+
+ $attachment = $this->createAttachment($page);
+
+ foreach ($badLinks as $badLink) {
+ $linkReq = $this->put('attachments/' . $attachment->id, [
+ 'attachment_edit_url' => $badLink,
+ 'attachment_edit_name' => 'Example Attachment Link',
+ ]);
+ $linkReq->assertStatus(422);
+ $this->assertDatabaseMissing('attachments', [
+ 'path' => $badLink,
+ ]);
+ }
+ }
}
$editor = $this->getEditor();
$resp = $this->actingAs($editor)->get($page->getUrl('/edit'));
- $resp->assertSee('drawio-url="https://www.draw.io/?embed=1&proto=json&spin=1"');
+ $resp->assertSee('drawio-url="https://embed.diagrams.net/?embed=1&proto=json&spin=1"');
config()->set('services.drawio', false);
$resp = $this->actingAs($editor)->get($page->getUrl('/edit'));
-<?php namespace Test\User;
+<?php namespace Tests\User;
use BookStack\Api\ApiToken;
use Carbon\Carbon;
-<?php namespace Test\User;
+<?php namespace Tests\User;
use Tests\TestCase;
-<?php namespace Test\User;
+<?php namespace Tests\User;
use Activity;
use BookStack\Auth\User;