Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Leonardo 2019-09-02 17:04:11 -03:00
commit a1f89ad589
283 changed files with 6323 additions and 5517 deletions

View File

@ -1,2 +0,0 @@
>0.25%
not op_mini all

View File

@ -95,6 +95,16 @@ QUEUE_DRIVER=sync
# Can be 'local', 'local_secure' or 's3'
STORAGE_TYPE=local
# Image storage system to use
# Defaults to the value of STORAGE_TYPE if unset.
# Accepts the same values as STORAGE_TYPE.
STORAGE_IMAGE_TYPE=local
# Attachment storage system to use
# Defaults to the value of STORAGE_TYPE if unset.
# Accepts the same values as STORAGE_TYPE although 'local' will be forced to 'local_secure'.
STORAGE_ATTACHMENT_TYPE=local_secure
# Amazon S3 storage configuration
STORAGE_S3_KEY=your-s3-key
STORAGE_S3_SECRET=your-s3-secret

19
app/Application.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace BookStack;
class Application extends \Illuminate\Foundation\Application
{
/**
* Get the path to the application configuration files.
*
* @param string $path Optionally, a path to append to the config path
* @return string
*/
public function configPath($path = '')
{
return $this->basePath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Config'.($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}

View File

@ -1,33 +1,18 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Notifications\ConfirmEmail;
use Carbon\Carbon;
use Illuminate\Database\Connection as Database;
class EmailConfirmationService
class EmailConfirmationService extends UserTokenService
{
protected $db;
protected $users;
/**
* EmailConfirmationService constructor.
* @param Database $db
* @param \BookStack\Auth\UserRepo $users
*/
public function __construct(Database $db, UserRepo $users)
{
$this->db = $db;
$this->users = $users;
}
protected $tokenTable = 'email_confirmations';
protected $expiryTime = 24;
/**
* Create new confirmation for a user,
* Also removes any existing old ones.
* @param \BookStack\Auth\User $user
* @param User $user
* @throws ConfirmationEmailException
*/
public function sendConfirmation(User $user)
@ -36,76 +21,20 @@ class EmailConfirmationService
throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');
}
$this->deleteConfirmationsByUser($user);
$token = $this->createEmailConfirmation($user);
$this->deleteByUser($user);
$token = $this->createTokenForUser($user);
$user->notify(new ConfirmEmail($token));
}
/**
* Creates a new email confirmation in the database and returns the token.
* @param User $user
* @return string
* Check if confirmation is required in this instance.
* @return bool
*/
public function createEmailConfirmation(User $user)
public function confirmationRequired() : bool
{
$token = $this->getToken();
$this->db->table('email_confirmations')->insert([
'user_id' => $user->id,
'token' => $token,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now()
]);
return $token;
return setting('registration-confirmation')
|| setting('registration-restrict');
}
/**
* Gets an email confirmation by looking up the token,
* Ensures the token has not expired.
* @param string $token
* @return array|null|\stdClass
* @throws UserRegistrationException
*/
public function getEmailConfirmationFromToken($token)
{
$emailConfirmation = $this->db->table('email_confirmations')->where('token', '=', $token)->first();
// If not found show error
if ($emailConfirmation === null) {
throw new UserRegistrationException(trans('errors.email_confirmation_invalid'), '/register');
}
// If more than a day old
if (Carbon::now()->subDay()->gt(new Carbon($emailConfirmation->created_at))) {
$user = $this->users->getById($emailConfirmation->user_id);
$this->sendConfirmation($user);
throw new UserRegistrationException(trans('errors.email_confirmation_expired'), '/register/confirm');
}
$emailConfirmation->user = $this->users->getById($emailConfirmation->user_id);
return $emailConfirmation;
}
/**
* Delete all email confirmations that belong to a user.
* @param \BookStack\Auth\User $user
* @return mixed
*/
public function deleteConfirmationsByUser(User $user)
{
return $this->db->table('email_confirmations')->where('user_id', '=', $user->id)->delete();
}
/**
* Creates a unique token within the email confirmation database.
* @return string
*/
protected function getToken()
{
$token = str_random(24);
while ($this->db->table('email_confirmations')->where('token', '=', $token)->exists()) {
$token = str_random(25);
}
return $token;
}
}

View File

@ -0,0 +1,23 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Notifications\UserInvite;
class UserInviteService extends UserTokenService
{
protected $tokenTable = 'user_invites';
protected $expiryTime = 336; // Two weeks
/**
* Send an invitation to a user to sign into BookStack
* Removes existing invitation tokens.
* @param User $user
*/
public function sendInvitation(User $user)
{
$this->deleteByUser($user);
$token = $this->createTokenForUser($user);
$user->notify(new UserInvite($token));
}
}

View File

@ -0,0 +1,134 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use Carbon\Carbon;
use Illuminate\Database\Connection as Database;
use stdClass;
class UserTokenService
{
/**
* Name of table where user tokens are stored.
* @var string
*/
protected $tokenTable = 'user_tokens';
/**
* Token expiry time in hours.
* @var int
*/
protected $expiryTime = 24;
protected $db;
/**
* UserTokenService constructor.
* @param Database $db
*/
public function __construct(Database $db)
{
$this->db = $db;
}
/**
* Delete all email confirmations that belong to a user.
* @param User $user
* @return mixed
*/
public function deleteByUser(User $user)
{
return $this->db->table($this->tokenTable)
->where('user_id', '=', $user->id)
->delete();
}
/**
* Get the user id from a token, while check the token exists and has not expired.
* @param string $token
* @return int
* @throws UserTokenNotFoundException
* @throws UserTokenExpiredException
*/
public function checkTokenAndGetUserId(string $token) : int
{
$entry = $this->getEntryByToken($token);
if (is_null($entry)) {
throw new UserTokenNotFoundException('Token "' . $token . '" not found');
}
if ($this->entryExpired($entry)) {
throw new UserTokenExpiredException("Token of id {$entry->id} has expired.", $entry->user_id);
}
return $entry->user_id;
}
/**
* Creates a unique token within the email confirmation database.
* @return string
*/
protected function generateToken() : string
{
$token = str_random(24);
while ($this->tokenExists($token)) {
$token = str_random(25);
}
return $token;
}
/**
* Generate and store a token for the given user.
* @param User $user
* @return string
*/
protected function createTokenForUser(User $user) : string
{
$token = $this->generateToken();
$this->db->table($this->tokenTable)->insert([
'user_id' => $user->id,
'token' => $token,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now()
]);
return $token;
}
/**
* Check if the given token exists.
* @param string $token
* @return bool
*/
protected function tokenExists(string $token) : bool
{
return $this->db->table($this->tokenTable)
->where('token', '=', $token)->exists();
}
/**
* Get a token entry for the given token.
* @param string $token
* @return object|null
*/
protected function getEntryByToken(string $token)
{
return $this->db->table($this->tokenTable)
->where('token', '=', $token)
->first();
}
/**
* Check if the given token entry has expired.
* @param stdClass $tokenEntry
* @return bool
*/
protected function entryExpired(stdClass $tokenEntry) : bool
{
return Carbon::now()->subHours($this->expiryTime)
->gt(new Carbon($tokenEntry->created_at));
}
}

View File

@ -3,6 +3,7 @@
use BookStack\Model;
use BookStack\Notifications\ResetPassword;
use BookStack\Uploads\Image;
use Carbon\Carbon;
use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
@ -10,6 +11,20 @@ use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Notifications\Notifiable;
/**
* Class User
* @package BookStack\Auth
* @property string $id
* @property string $name
* @property string $email
* @property string $password
* @property Carbon $created_at
* @property Carbon $updated_at
* @property bool $email_confirmed
* @property int $image_id
* @property string $external_auth_id
* @property string $system_name
*/
class User extends Model implements AuthenticatableContract, CanResetPasswordContract
{
use Authenticatable, CanResetPassword, Notifiable;
@ -168,14 +183,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function getAvatar($size = 50)
{
$default = baseUrl('/user_avatar.png');
$default = url('/user_avatar.png');
$imageId = $this->image_id;
if ($imageId === 0 || $imageId === '0' || $imageId === null) {
return $default;
}
try {
$avatar = $this->avatar ? baseUrl($this->avatar->getThumb($size, $size, false)) : $default;
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
} catch (\Exception $err) {
$avatar = $default;
}
@ -197,7 +212,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function getEditUrl()
{
return baseUrl('/settings/users/' . $this->id);
return url('/settings/users/' . $this->id);
}
/**
@ -206,7 +221,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function getProfileUrl()
{
return baseUrl('/user/' . $this->id);
return url('/user/' . $this->id);
}
/**
@ -216,12 +231,12 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function getShortName($chars = 8)
{
if (strlen($this->name) <= $chars) {
if (mb_strlen($this->name) <= $chars) {
return $this->name;
}
$splitName = explode(' ', $this->name);
if (strlen($splitName[0]) <= $chars) {
if (mb_strlen($splitName[0]) <= $chars) {
return $splitName[0];
}

View File

@ -52,7 +52,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'de', 'de_informal', 'es', 'es_AR', 'fr', 'nl', 'pt_BR', 'sk', 'cs', 'sv', 'kr', 'ja', 'pl', 'it', 'ru', 'uk', 'zh_CN', 'zh_TW'],
'locales' => ['en', 'ar', 'de', 'de_informal', 'es', 'es_AR', 'fr', 'hu', 'nl', 'pt_BR', 'sk', 'cs', 'sv', 'kr', 'ja', 'pl', 'it', 'ru', 'uk', 'zh_CN', 'zh_TW'],
// Application Fallback Locale
'fallback_locale' => 'en',

132
app/Config/debugbar.php Normal file
View File

@ -0,0 +1,132 @@
<?php
/**
* Debugbar Configuration Options
*
* Changes to these config files are not supported by BookStack and may break upon updates.
* 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.
*/
return [
// Debugbar is enabled by default, when debug is set to true in app.php.
// You can override the value by setting enable to true or false instead of null.
//
// You can provide an array of URI's that must be ignored (eg. 'api/*')
'enabled' => env('DEBUGBAR_ENABLED', false),
'except' => [
'telescope*'
],
// DebugBar stores data for session/ajax requests.
// You can disable this, so the debugbar stores data in headers/session,
// but this can cause problems with large data collectors.
// By default, file storage (in the storage folder) is used. Redis and PDO
// can also be used. For PDO, run the package migrations first.
'storage' => [
'enabled' => true,
'driver' => 'file', // redis, file, pdo, custom
'path' => storage_path('debugbar'), // For file driver
'connection' => null, // Leave null for default connection (Redis/PDO)
'provider' => '' // Instance of StorageInterface for custom driver
],
// Vendor files are included by default, but can be set to false.
// This can also be set to 'js' or 'css', to only include javascript or css vendor files.
// Vendor files are for css: font-awesome (including fonts) and highlight.js (css files)
// and for js: jquery and and highlight.js
// So if you want syntax highlighting, set it to true.
// jQuery is set to not conflict with existing jQuery scripts.
'include_vendors' => true,
// The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors),
// you can use this option to disable sending the data through the headers.
// Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools.
'capture_ajax' => true,
'add_ajax_timing' => false,
// When enabled, the Debugbar shows deprecated warnings for Symfony components
// in the Messages tab.
'error_handler' => false,
// The Debugbar can emulate the Clockwork headers, so you can use the Chrome
// Extension, without the server-side code. It uses Debugbar collectors instead.
'clockwork' => false,
// Enable/disable DataCollectors
'collectors' => [
'phpinfo' => true, // Php version
'messages' => true, // Messages
'time' => true, // Time Datalogger
'memory' => true, // Memory usage
'exceptions' => true, // Exception displayer
'log' => true, // Logs from Monolog (merged in messages if enabled)
'db' => true, // Show database (PDO) queries and bindings
'views' => true, // Views with their data
'route' => true, // Current route information
'auth' => true, // Display Laravel authentication status
'gate' => true, // Display Laravel Gate checks
'session' => true, // Display session data
'symfony_request' => true, // Only one can be enabled..
'mail' => true, // Catch mail messages
'laravel' => false, // Laravel version and environment
'events' => false, // All events fired
'default_request' => false, // Regular or special Symfony request logger
'logs' => false, // Add the latest log messages
'files' => false, // Show the included files
'config' => false, // Display config settings
'cache' => false, // Display cache events
],
// Configure some DataCollectors
'options' => [
'auth' => [
'show_name' => true, // Also show the users name/email in the debugbar
],
'db' => [
'with_params' => true, // Render SQL with the parameters substituted
'backtrace' => true, // Use a backtrace to find the origin of the query in your files.
'timeline' => false, // Add the queries to the timeline
'explain' => [ // Show EXPLAIN output on queries
'enabled' => false,
'types' => ['SELECT'], // ['SELECT', 'INSERT', 'UPDATE', 'DELETE']; for MySQL 5.6.3+
],
'hints' => true, // Show hints for common mistakes
],
'mail' => [
'full_log' => false
],
'views' => [
'data' => false, //Note: Can slow down the application, because the data can be quite large..
],
'route' => [
'label' => true // show complete route on bar
],
'logs' => [
'file' => null
],
'cache' => [
'values' => true // collect cache values
],
],
// Inject Debugbar into the response
// Usually, the debugbar is added just before </body>, by listening to the
// Response after the App is done. If you disable this, you have to add them
// in your template yourself. See http://phpdebugbar.com/docs/rendering.html
'inject' => true,
// DebugBar route prefix
// Sometimes you want to set route prefix to be used by DebugBar to load
// its resources from. Usually the need comes from misconfigured web server or
// from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97
'route_prefix' => '_debugbar',
// DebugBar route domain
// By default DebugBar route served from the same domain that request served.
// To override default domain, specify it as a non-empty value.
'route_domain' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''),
];

View File

@ -14,6 +14,12 @@ return [
// Options: local, local_secure, s3
'default' => env('STORAGE_TYPE', 'local'),
// Filesystem to use specifically for image uploads.
'images' => env('STORAGE_IMAGE_TYPE', env('STORAGE_TYPE', 'local')),
// Filesystem to use specifically for file attachments.
'attachments' => env('STORAGE_ATTACHMENT_TYPE', env('STORAGE_TYPE', 'local')),
// Storage URL
// This is the url to where the storage is located for when using an external
// file storage service, such as s3, to store publicly accessible assets.

View File

@ -14,8 +14,8 @@ return [
'app-logo' => '',
'app-name-header' => true,
'app-editor' => 'wysiwyg',
'app-color' => '#0288D1',
'app-color-light' => 'rgba(21, 101, 192, 0.15)',
'app-color' => '#206ea7',
'app-color-light' => 'rgba(32,110,167,0.15)',
'app-custom-head' => false,
'registration-enabled' => false,

View File

@ -49,7 +49,7 @@ class CreateAdmin extends Command
if (empty($email)) {
$email = $this->ask('Please specify an email address for the new admin user');
}
if (strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
if (mb_strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->error('Invalid email address provided');
}
@ -61,7 +61,7 @@ class CreateAdmin extends Command
if (empty($name)) {
$name = $this->ask('Please specify an name for the new admin user');
}
if (strlen($name) < 2) {
if (mb_strlen($name) < 2) {
return $this->error('Invalid name provided');
}
@ -69,7 +69,7 @@ class CreateAdmin extends Command
if (empty($password)) {
$password = $this->secret('Please specify a password for the new admin user');
}
if (strlen($password) < 5) {
if (mb_strlen($password) < 5) {
return $this->error('Invalid password provided, Must be at least 5 characters');
}

View File

@ -25,9 +25,9 @@ class Book extends Entity
public function getUrl($path = false)
{
if ($path !== false) {
return baseUrl('/books/' . urlencode($this->slug) . '/' . trim($path, '/'));
return url('/books/' . urlencode($this->slug) . '/' . trim($path, '/'));
}
return baseUrl('/books/' . urlencode($this->slug));
return url('/books/' . urlencode($this->slug));
}
/**
@ -44,7 +44,7 @@ class Book extends Entity
}
try {
$cover = $this->cover ? baseUrl($this->cover->getThumb($width, $height, false)) : $default;
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
} catch (\Exception $err) {
$cover = $default;
}
@ -104,7 +104,7 @@ class Book extends Entity
public function getExcerpt(int $length = 100)
{
$description = $this->description;
return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description;
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
}
/**

View File

@ -39,9 +39,9 @@ class Bookshelf extends Entity
public function getUrl($path = false)
{
if ($path !== false) {
return baseUrl('/shelves/' . urlencode($this->slug) . '/' . trim($path, '/'));
return url('/shelves/' . urlencode($this->slug) . '/' . trim($path, '/'));
}
return baseUrl('/shelves/' . urlencode($this->slug));
return url('/shelves/' . urlencode($this->slug));
}
/**
@ -59,7 +59,7 @@ class Bookshelf extends Entity
}
try {
$cover = $this->cover ? baseUrl($this->cover->getThumb($width, $height, false)) : $default;
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
} catch (\Exception $err) {
$cover = $default;
}
@ -83,7 +83,7 @@ class Bookshelf extends Entity
public function getExcerpt(int $length = 100)
{
$description = $this->description;
return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description;
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
}
/**

View File

@ -42,10 +42,13 @@ class Chapter extends Entity
public function getUrl($path = false)
{
$bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
$fullPath = '/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug);
if ($path !== false) {
return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug) . '/' . trim($path, '/'));
$fullPath .= '/' . trim($path, '/');
}
return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug));
return url($fullPath);
}
/**
@ -56,7 +59,7 @@ class Chapter extends Entity
public function getExcerpt(int $length = 100)
{
$description = $this->text ?? $this->description;
return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description;
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
}
/**

View File

@ -96,10 +96,10 @@ class Page extends Entity
$idComponent = $this->draft ? $this->id : urlencode($this->slug);
if ($path !== false) {
return baseUrl('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/'));
return url('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/'));
}
return baseUrl('/books/' . urlencode($bookSlug) . $midText . $idComponent);
return url('/books/' . urlencode($bookSlug) . $midText . $idComponent);
}
/**

View File

@ -760,13 +760,19 @@ class EntityRepo
$xPath = new DOMXPath($doc);
// Remove standard script tags
$scriptElems = $xPath->query('//body//*//script');
$scriptElems = $xPath->query('//script');
foreach ($scriptElems as $scriptElem) {
$scriptElem->parentNode->removeChild($scriptElem);
}
// Remove data or JavaScript iFrames
$badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
foreach ($badIframes as $badIframe) {
$badIframe->parentNode->removeChild($badIframe);
}
// Remove 'on*' attributes
$onAttributes = $xPath->query('//body//*/@*[starts-with(name(), \'on\')]');
$onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
foreach ($onAttributes as $attr) {
/** @var \DOMAttr $attr*/
$attrName = $attr->nodeName;
@ -852,10 +858,13 @@ class EntityRepo
*/
public function destroyPage(Page $page)
{
// Check if set as custom homepage
// 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])) {
throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
if (setting('app-homepage-type') === 'page') {
throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
}
setting()->remove('app-homepage');
}
$this->destroyEntityCommonRelations($page);

View File

@ -9,6 +9,7 @@ use Carbon\Carbon;
use DOMDocument;
use DOMElement;
use DOMXPath;
use Illuminate\Support\Collection;
class PageRepo extends EntityRepo
{
@ -69,6 +70,10 @@ class PageRepo extends EntityRepo
$this->tagRepo->saveTagsToEntity($page, $input['tags']);
}
if (isset($input['template']) && userCan('templates-manage')) {
$page->template = ($input['template'] === 'true');
}
// Update with new details
$userId = user()->id;
$page->fill($input);
@ -85,8 +90,9 @@ class PageRepo extends EntityRepo
$this->userUpdatePageDraftsQuery($page, $userId)->delete();
// Save a revision after updating
if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $input['summary'] !== null) {
$this->savePageRevision($page, $input['summary']);
$summary = $input['summary'] ?? null;
if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $summary !== null) {
$this->savePageRevision($page, $summary);
}
$this->searchService->indexEntity($page);
@ -192,7 +198,7 @@ class PageRepo extends EntityRepo
// Create an unique id for the element
// Uses the content as a basis to ensure output is the same every time
// the same content is passed through.
$contentId = 'bkmrk-' . substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
$newId = urlencode($contentId);
$loopIndex = 0;
@ -300,6 +306,10 @@ class PageRepo extends EntityRepo
$this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
}
if (isset($input['template']) && userCan('templates-manage')) {
$draftPage->template = ($input['template'] === 'true');
}
$draftPage->slug = $this->findSuitableSlug('page', $draftPage->name, false, $draftPage->book->id);
$draftPage->html = $this->formatHtml($input['html']);
$draftPage->text = $this->pageToPlainText($draftPage);
@ -424,9 +434,7 @@ class PageRepo extends EntityRepo
$tree = collect($headers)->map(function($header) {
$text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
if (strlen($text) > 30) {
$text = substr($text, 0, 27) . '...';
}
$text = mb_substr($text, 0, 100);
return [
'nodeName' => strtolower($header->nodeName),
@ -435,13 +443,13 @@ class PageRepo extends EntityRepo
'text' => $text,
];
})->filter(function($header) {
return strlen($header['text']) > 0;
return mb_strlen($header['text']) > 0;
});
// Normalise headers if only smaller headers have been used
$minLevel = $tree->pluck('level')->min();
$tree = $tree->map(function ($header) use ($minLevel) {
$header['level'] -= ($minLevel - 2);
// Shift headers if only smaller headers have been used
$levelChange = ($tree->pluck('level')->min() - 1);
$tree = $tree->map(function ($header) use ($levelChange) {
$header['level'] -= ($levelChange);
return $header;
});
@ -525,4 +533,29 @@ class PageRepo extends EntityRepo
return $this->publishPageDraft($copyPage, $pageData);
}
/**
* Get pages that have been marked as templates.
* @param int $count
* @param int $page
* @param string $search
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function getPageTemplates(int $count = 10, int $page = 1, string $search = '')
{
$query = $this->entityQuery('page')
->where('template', '=', true)
->orderBy('name', 'asc')
->skip( ($page - 1) * $count)
->take($count);
if ($search) {
$query->where('name', 'like', '%' . $search . '%');
}
$paginator = $query->paginate($count, ['*'], 'page', $page);
$paginator->withPath('/templates');
return $paginator;
}
}

View File

@ -0,0 +1,19 @@
<?php namespace BookStack\Exceptions;
class UserTokenExpiredException extends \Exception {
public $userId;
/**
* UserTokenExpiredException constructor.
* @param string $message
* @param int $userId
*/
public function __construct(string $message, int $userId)
{
$this->userId = $userId;
parent::__construct($message);
}
}

View File

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

View File

@ -0,0 +1,118 @@
<?php
namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\EmailConfirmationService;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use BookStack\Http\Controllers\Controller;
use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\View\View;
class ConfirmEmailController extends Controller
{
protected $emailConfirmationService;
protected $userRepo;
/**
* Create a new controller instance.
*
* @param EmailConfirmationService $emailConfirmationService
* @param UserRepo $userRepo
*/
public function __construct(EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
{
$this->emailConfirmationService = $emailConfirmationService;
$this->userRepo = $userRepo;
parent::__construct();
}
/**
* Show the page to tell the user to check their email
* and confirm their address.
*/
public function show()
{
return view('auth.register-confirm');
}
/**
* Shows a notice that a user's email address has not been confirmed,
* Also has the option to re-send the confirmation email.
* @return View
*/
public function showAwaiting()
{
return view('auth.user-unconfirmed');
}
/**
* Confirms an email via a token and logs the user into the system.
* @param $token
* @return RedirectResponse|Redirector
* @throws ConfirmationEmailException
* @throws Exception
*/
public function confirm($token)
{
try {
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
} catch (Exception $exception) {
if ($exception instanceof UserTokenNotFoundException) {
session()->flash('error', trans('errors.email_confirmation_invalid'));
return redirect('/register');
}
if ($exception instanceof UserTokenExpiredException) {
$user = $this->userRepo->getById($exception->userId);
$this->emailConfirmationService->sendConfirmation($user);
session()->flash('error', trans('errors.email_confirmation_expired'));
return redirect('/register/confirm');
}
throw $exception;
}
$user = $this->userRepo->getById($userId);
$user->email_confirmed = true;
$user->save();
auth()->login($user);
session()->flash('success', trans('auth.email_confirm_success'));
$this->emailConfirmationService->deleteByUser($user);
return redirect('/');
}
/**
* Resend the confirmation email
* @param Request $request
* @return View
*/
public function resend(Request $request)
{
$this->validate($request, [
'email' => 'required|email|exists:users,email'
]);
$user = $this->userRepo->getByEmail($request->get('email'));
try {
$this->emailConfirmationService->sendConfirmation($user);
} catch (Exception $e) {
session()->flash('error', trans('auth.email_confirm_send_error'));
return redirect('/register/confirm');
}
session()->flash('success', trans('auth.email_confirm_resent'));
return redirect('/register/confirm');
}
}

View File

@ -53,8 +53,8 @@ class LoginController extends Controller
$this->socialAuthService = $socialAuthService;
$this->ldapService = $ldapService;
$this->userRepo = $userRepo;
$this->redirectPath = baseUrl('/');
$this->redirectAfterLogout = baseUrl('/login');
$this->redirectPath = url('/');
$this->redirectAfterLogout = url('/login');
parent::__construct();
}
@ -106,9 +106,7 @@ class LoginController extends Controller
$this->ldapService->syncGroups($user, $request->get($this->username()));
}
$path = session()->pull('url.intended', '/');
$path = baseUrl($path, true);
return redirect($path);
return redirect()->intended('/');
}
/**

View File

@ -2,17 +2,22 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\EmailConfirmationService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\SocialSignInException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controllers\Controller;
use Exception;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Redirector;
use Laravel\Socialite\Contracts\User as SocialUser;
use Validator;
@ -46,18 +51,18 @@ class RegisterController extends Controller
/**
* Create a new controller instance.
*
* @param \BookStack\Auth\Access\SocialAuthService $socialAuthService
* @param \BookStack\Auth\EmailConfirmationService $emailConfirmationService
* @param \BookStack\Auth\UserRepo $userRepo
* @param SocialAuthService $socialAuthService
* @param EmailConfirmationService $emailConfirmationService
* @param UserRepo $userRepo
*/
public function __construct(\BookStack\Auth\Access\SocialAuthService $socialAuthService, \BookStack\Auth\Access\EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
public function __construct(SocialAuthService $socialAuthService, EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
{
$this->middleware('guest')->only(['getRegister', 'postRegister', 'socialRegister']);
$this->socialAuthService = $socialAuthService;
$this->emailConfirmationService = $emailConfirmationService;
$this->userRepo = $userRepo;
$this->redirectTo = baseUrl('/');
$this->redirectPath = baseUrl('/');
$this->redirectTo = url('/');
$this->redirectPath = url('/');
parent::__construct();
}
@ -101,8 +106,8 @@ class RegisterController extends Controller
/**
* Handle a registration request for the application.
* @param Request|\Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @param Request|Request $request
* @return RedirectResponse|Redirector
* @throws UserRegistrationException
*/
public function postRegister(Request $request)
@ -117,7 +122,7 @@ class RegisterController extends Controller
/**
* Create a new user instance after a valid registration.
* @param array $data
* @return \BookStack\Auth\User
* @return User
*/
protected function create(array $data)
{
@ -133,7 +138,7 @@ class RegisterController extends Controller
* @param array $userData
* @param bool|false|SocialAccount $socialAccount
* @param bool $emailVerified
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @return RedirectResponse|Redirector
* @throws UserRegistrationException
*/
protected function registerUser(array $userData, $socialAccount = false, $emailVerified = false)
@ -142,7 +147,7 @@ class RegisterController extends Controller
if ($registrationRestrict) {
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
$userEmailDomain = $domain = substr(strrchr($userData['email'], "@"), 1);
$userEmailDomain = $domain = mb_substr(mb_strrchr($userData['email'], "@"), 1);
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), '/register');
}
@ -153,7 +158,7 @@ class RegisterController extends Controller
$newUser->socialAccounts()->save($socialAccount);
}
if ((setting('registration-confirmation') || $registrationRestrict) && !$emailVerified) {
if ($this->emailConfirmationService->confirmationRequired() && !$emailVerified) {
$newUser->save();
try {
@ -170,72 +175,12 @@ class RegisterController extends Controller
return redirect($this->redirectPath());
}
/**
* Show the page to tell the user to check their email
* and confirm their address.
*/
public function getRegisterConfirmation()
{
return view('auth.register-confirm');
}
/**
* Confirms an email via a token and logs the user into the system.
* @param $token
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws UserRegistrationException
*/
public function confirmEmail($token)
{
$confirmation = $this->emailConfirmationService->getEmailConfirmationFromToken($token);
$user = $confirmation->user;
$user->email_confirmed = true;
$user->save();
auth()->login($user);
session()->flash('success', trans('auth.email_confirm_success'));
$this->emailConfirmationService->deleteConfirmationsByUser($user);
return redirect($this->redirectPath);
}
/**
* Shows a notice that a user's email address has not been confirmed,
* Also has the option to re-send the confirmation email.
* @return \Illuminate\View\View
*/
public function showAwaitingConfirmation()
{
return view('auth.user-unconfirmed');
}
/**
* Resend the confirmation email
* @param Request $request
* @return \Illuminate\View\View
*/
public function resendConfirmation(Request $request)
{
$this->validate($request, [
'email' => 'required|email|exists:users,email'
]);
$user = $this->userRepo->getByEmail($request->get('email'));
try {
$this->emailConfirmationService->sendConfirmation($user);
} catch (Exception $e) {
session()->flash('error', trans('auth.email_confirm_send_error'));
return redirect('/register/confirm');
}
session()->flash('success', trans('auth.email_confirm_resent'));
return redirect('/register/confirm');
}
/**
* Redirect to the social site for authentication intended to register.
* @param $socialDriver
* @return mixed
* @throws UserRegistrationException
* @throws \BookStack\Exceptions\SocialDriverNotConfigured
* @throws SocialDriverNotConfigured
*/
public function socialRegister($socialDriver)
{
@ -248,10 +193,10 @@ class RegisterController extends Controller
* The callback for social login services.
* @param $socialDriver
* @param Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @return RedirectResponse|Redirector
* @throws SocialSignInException
* @throws UserRegistrationException
* @throws \BookStack\Exceptions\SocialDriverNotConfigured
* @throws SocialDriverNotConfigured
*/
public function socialCallback($socialDriver, Request $request)
{
@ -292,7 +237,7 @@ class RegisterController extends Controller
/**
* Detach a social account from a user.
* @param $socialDriver
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @return RedirectResponse|Redirector
*/
public function detachSocialAccount($socialDriver)
{
@ -303,7 +248,7 @@ class RegisterController extends Controller
* Register a new user after a registration callback.
* @param string $socialDriver
* @param SocialUser $socialUser
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @return RedirectResponse|Redirector
* @throws UserRegistrationException
*/
protected function socialRegisterCallback(string $socialDriver, SocialUser $socialUser)

View File

@ -0,0 +1,106 @@
<?php
namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use BookStack\Http\Controllers\Controller;
use Exception;
use Illuminate\Contracts\View\Factory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\View\View;
class UserInviteController extends Controller
{
protected $inviteService;
protected $userRepo;
/**
* Create a new controller instance.
*
* @param UserInviteService $inviteService
* @param UserRepo $userRepo
*/
public function __construct(UserInviteService $inviteService, UserRepo $userRepo)
{
$this->inviteService = $inviteService;
$this->userRepo = $userRepo;
$this->middleware('guest');
parent::__construct();
}
/**
* Show the page for the user to set the password for their account.
* @param string $token
* @return Factory|View|RedirectResponse
* @throws Exception
*/
public function showSetPassword(string $token)
{
try {
$this->inviteService->checkTokenAndGetUserId($token);
} catch (Exception $exception) {
return $this->handleTokenException($exception);
}
return view('auth.invite-set-password', [
'token' => $token,
]);
}
/**
* Sets the password for an invited user and then grants them access.
* @param string $token
* @param Request $request
* @return RedirectResponse|Redirector
* @throws Exception
*/
public function setPassword(string $token, Request $request)
{
$this->validate($request, [
'password' => 'required|min:6'
]);
try {
$userId = $this->inviteService->checkTokenAndGetUserId($token);
} catch (Exception $exception) {
return $this->handleTokenException($exception);
}
$user = $this->userRepo->getById($userId);
$user->password = bcrypt($request->get('password'));
$user->email_confirmed = true;
$user->save();
auth()->login($user);
session()->flash('success', trans('auth.user_invite_success', ['appName' => setting('app-name')]));
$this->inviteService->deleteByUser($user);
return redirect('/');
}
/**
* Check and validate the exception thrown when checking an invite token.
* @param Exception $exception
* @return RedirectResponse|Redirector
* @throws Exception
*/
protected function handleTokenException(Exception $exception)
{
if ($exception instanceof UserTokenNotFoundException) {
return redirect('/');
}
if ($exception instanceof UserTokenExpiredException) {
session()->flash('error', trans('errors.invite_token_expired'));
return redirect('/password/email');
}
throw $exception;
}
}

View File

@ -91,35 +91,6 @@ class HomeController extends Controller
return view('common.home', $commonData);
}
/**
* Get a js representation of the current translations
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function getTranslations()
{
$locale = app()->getLocale();
$cacheKey = 'GLOBAL_TRANSLATIONS_' . $locale;
if (cache()->has($cacheKey) && config('app.env') !== 'development') {
$resp = cache($cacheKey);
} else {
$translations = [
// Get only translations which might be used in JS
'common' => trans('common'),
'components' => trans('components'),
'entities' => trans('entities'),
'errors' => trans('errors')
];
$resp = 'window.translations = ' . json_encode($translations);
cache()->put($cacheKey, $resp, 120);
}
return response($resp, 200, [
'Content-Type' => 'application/javascript'
]);
}
/**
* Get custom head HTML, Used in ajax calls to show in editor.
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View

View File

@ -110,11 +110,14 @@ class PageController extends Controller
$this->setPageTitle(trans('entities.pages_edit_draft'));
$draftsEnabled = $this->signedIn;
$templates = $this->pageRepo->getPageTemplates(10);
return view('pages.edit', [
'page' => $draft,
'book' => $draft->book,
'isDraft' => true,
'draftsEnabled' => $draftsEnabled
'draftsEnabled' => $draftsEnabled,
'templates' => $templates,
]);
}
@ -239,11 +242,14 @@ class PageController extends Controller
}
$draftsEnabled = $this->signedIn;
$templates = $this->pageRepo->getPageTemplates(10);
return view('pages.edit', [
'page' => $page,
'book' => $page->book,
'current' => $page,
'draftsEnabled' => $draftsEnabled
'draftsEnabled' => $draftsEnabled,
'templates' => $templates,
]);
}
@ -489,7 +495,7 @@ class PageController extends Controller
$revision->delete();
session()->flash('success', trans('entities.revision_delete_success'));
return view('pages.revisions', ['page' => $page, 'book' => $page->book, 'current' => $page]);
return redirect($page->getUrl('/revisions'));
}
/**
@ -541,7 +547,7 @@ class PageController extends Controller
public function showRecentlyUpdated()
{
// TODO - Still exist?
$pages = $this->pageRepo->getRecentlyUpdatedPaginated('page', 20)->setPath(baseUrl('/pages/recently-updated'));
$pages = $this->pageRepo->getRecentlyUpdatedPaginated('page', 20)->setPath(url('/pages/recently-updated'));
return view('pages.detailed-listing', [
'title' => trans('entities.recently_updated_pages'),
'pages' => $pages

View File

@ -0,0 +1,63 @@
<?php
namespace BookStack\Http\Controllers;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Http\Request;
class PageTemplateController extends Controller
{
protected $pageRepo;
/**
* PageTemplateController constructor.
* @param $pageRepo
*/
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
parent::__construct();
}
/**
* Fetch a list of templates from the system.
* @param Request $request
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function list(Request $request)
{
$page = $request->get('page', 1);
$search = $request->get('search', '');
$templates = $this->pageRepo->getPageTemplates(10, $page, $search);
if ($search) {
$templates->appends(['search' => $search]);
}
return view('pages.template-manager-list', [
'templates' => $templates
]);
}
/**
* Get the content of a template.
* @param $templateId
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Symfony\Component\HttpFoundation\Response
* @throws NotFoundException
*/
public function get($templateId)
{
$page = $this->pageRepo->getById('page', $templateId);
if (!$page->template) {
throw new NotFoundException();
}
return response()->json([
'html' => $page->html,
'markdown' => $page->markdown,
]);
}
}

View File

@ -48,7 +48,7 @@ class SearchController extends Controller
$this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm]));
$page = intval($request->get('page', '0')) ?: 1;
$nextPageLink = baseUrl('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1));
$nextPageLink = url('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1));
$results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20);

View File

@ -1,6 +1,7 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserUpdateException;
@ -13,18 +14,21 @@ class UserController extends Controller
protected $user;
protected $userRepo;
protected $inviteService;
protected $imageRepo;
/**
* UserController constructor.
* @param User $user
* @param UserRepo $userRepo
* @param UserInviteService $inviteService
* @param ImageRepo $imageRepo
*/
public function __construct(User $user, 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;
parent::__construct();
}
@ -75,8 +79,10 @@ class UserController extends Controller
];
$authMethod = config('auth.method');
if ($authMethod === 'standard') {
$validationRules['password'] = 'required|min:5';
$sendInvite = ($request->get('send_invite', 'false') === 'true');
if ($authMethod === 'standard' && !$sendInvite) {
$validationRules['password'] = 'required|min:6';
$validationRules['password-confirm'] = 'required|same:password';
} elseif ($authMethod === 'ldap') {
$validationRules['external_auth_id'] = 'required';
@ -86,13 +92,17 @@ class UserController extends Controller
$user = $this->user->fill($request->all());
if ($authMethod === 'standard') {
$user->password = bcrypt($request->get('password'));
$user->password = bcrypt($request->get('password', str_random(32)));
} elseif ($authMethod === 'ldap') {
$user->external_auth_id = $request->get('external_auth_id');
}
$user->save();
if ($sendInvite) {
$this->inviteService->sendInvitation($user);
}
if ($request->filled('roles')) {
$roles = $request->get('roles');
$this->userRepo->setUserRoles($user, $roles);
@ -139,14 +149,19 @@ class UserController extends Controller
$this->validate($request, [
'name' => 'min:2',
'email' => 'min:2|email|unique:users,email,' . $id,
'password' => 'min:5|required_with:password_confirm',
'password' => 'min:6|required_with:password_confirm',
'password-confirm' => 'same:password|required_with:password',
'setting' => 'array',
'profile_image' => $this->imageRepo->getImageValidationRules(),
]);
$user = $this->userRepo->getById($id);
$user->fill($request->all());
$user->fill($request->except(['email']));
// Email updates
if (userCan('users-manage') && $request->filled('email')) {
$user->email = $request->get('email');
}
// Role updates
if (userCan('users-manage') && $request->filled('roles')) {

View File

@ -41,7 +41,7 @@ class Authenticate
if ($request->ajax()) {
return response('Unauthorized.', 401);
} else {
return redirect()->guest(baseUrl('/login'));
return redirect()->guest(url('/login'));
}
}

View File

@ -31,12 +31,10 @@ class Localization
'nl' => 'nl_NL',
'pl' => 'pl_PL',
'pt_BR' => 'pt_BR',
'pt_BR' => 'pt_BR',
'ru' => 'ru',
'sk' => 'sk_SK',
'sv' => 'sv_SE',
'uk' => 'uk_UA',
'uk' => 'uk_UA',
'zh_CN' => 'zh_CN',
'zh_TW' => 'zh_TW',
];
@ -59,6 +57,8 @@ class Localization
$locale = setting()->getUser(user(), 'language', $defaultLang);
}
config()->set('app.lang', str_replace('_', '-', $this->getLocaleIso($locale)));
// Set text direction
if (in_array($locale, $this->rtlLocales)) {
config()->set('app.rtl', true);
@ -88,6 +88,16 @@ class Localization
return $default;
}
/**
* Get the ISO version of a BookStack language name
* @param string $locale
* @return string
*/
public function getLocaleIso(string $locale)
{
return $this->localeMap[$locale] ?? $locale;
}
/**
* Set the system date locale for localized date formatting.
* Will try both the standard locale name and the UTF8 variant.
@ -95,7 +105,7 @@ class Localization
*/
protected function setSystemDateLocale(string $locale)
{
$systemLocale = $this->localeMap[$locale] ?? $locale;
$systemLocale = $this->getLocaleIso($locale);
$set = setlocale(LC_TIME, $systemLocale);
if ($set === false) {
setlocale(LC_TIME, $systemLocale . '.utf8');

26
app/Http/Request.php Normal file
View File

@ -0,0 +1,26 @@
<?php namespace BookStack\Http;
use Illuminate\Http\Request as LaravelRequest;
class Request extends LaravelRequest
{
/**
* Override the default request methods to get the scheme and host
* to set the custom APP_URL, if set.
* @return \Illuminate\Config\Repository|mixed|string
*/
public function getSchemeAndHttpHost()
{
$base = config('app.url', null);
if ($base) {
$base = trim($base, '/');
} else {
$base = $this->getScheme().'://'.$this->getHttpHost();
}
return $base;
}
}

View File

@ -26,6 +26,6 @@ class ConfirmEmail extends MailNotification
->subject(trans('auth.email_confirm_subject', $appName))
->greeting(trans('auth.email_confirm_greeting', $appName))
->line(trans('auth.email_confirm_text'))
->action(trans('auth.email_confirm_action'), baseUrl('/register/confirm/' . $this->token));
->action(trans('auth.email_confirm_action'), url('/register/confirm/' . $this->token));
}
}

View File

@ -29,7 +29,7 @@ class ResetPassword extends MailNotification
return $this->newMailMessage()
->subject(trans('auth.email_reset_subject', ['appName' => setting('app-name')]))
->line(trans('auth.email_reset_text'))
->action(trans('auth.reset_password'), baseUrl('password/reset/' . $this->token))
->action(trans('auth.reset_password'), url('password/reset/' . $this->token))
->line(trans('auth.email_reset_not_requested'));
}
}

View File

@ -0,0 +1,31 @@
<?php namespace BookStack\Notifications;
class UserInvite extends MailNotification
{
public $token;
/**
* Create a new notification instance.
* @param string $token
*/
public function __construct($token)
{
$this->token = $token;
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
$appName = ['appName' => setting('app-name')];
return $this->newMailMessage()
->subject(trans('auth.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

@ -9,10 +9,10 @@ use BookStack\Entities\Page;
use BookStack\Settings\Setting;
use BookStack\Settings\SettingService;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Schema;
use URL;
use Validator;
class AppServiceProvider extends ServiceProvider
@ -24,6 +24,14 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot()
{
// Set root URL
$appUrl = config('app.url');
if ($appUrl) {
$isHttps = (strpos($appUrl, 'https://') === 0);
URL::forceRootUrl($appUrl);
URL::forceScheme($isHttps ? 'https' : 'http');
}
// Custom validation methods
Validator::extend('image_extension', function ($attribute, $value, $parameters, $validator) {
$validImageExtensions = ['png', 'jpg', 'jpeg', 'bmp', 'gif', 'tiff', 'webp'];
@ -40,6 +48,14 @@ class AppServiceProvider extends ServiceProvider
return "<?php echo icon($expression); ?>";
});
Blade::directive('exposeTranslations', function($expression) {
return "<?php \$__env->startPush('translations'); ?>" .
"<?php foreach({$expression} as \$key): ?>" .
'<meta name="translation" key="<?php echo e($key); ?>" value="<?php echo e(trans($key)); ?>">' . "\n" .
"<?php endforeach; ?>" .
'<?php $__env->stopPush(); ?>';
});
// Allow longer string lengths after upgrade to utf8mb4
Schema::defaultStringLength(191);

View File

@ -18,7 +18,7 @@ class PaginationServiceProvider extends IlluminatePaginationServiceProvider
});
Paginator::currentPathResolver(function () {
return baseUrl($this->app['request']->path());
return url($this->app['request']->path());
});
Paginator::currentPageResolver(function ($pageName = 'page') {

View File

@ -37,6 +37,6 @@ class Attachment extends Ownable
if ($this->external && strpos($this->path, 'http') !== 0) {
return $this->path;
}
return baseUrl('/attachments/' . $this->id);
return url('/attachments/' . $this->id);
}
}

View File

@ -13,7 +13,7 @@ class AttachmentService extends UploadService
*/
protected function getStorage()
{
$storageType = config('filesystems.default');
$storageType = config('filesystems.attachments');
// Override default location if set to local public to ensure not visible.
if ($storageType === 'local') {

View File

@ -45,9 +45,9 @@ class ImageService extends UploadService
*/
protected function getStorage($type = '')
{
$storageType = config('filesystems.default');
$storageType = config('filesystems.images');
// Override default location if set to local public to ensure not visible.
// Ensure system images (App logo) are uploaded to a public space
if ($type === 'system' && $storageType === 'local_secure') {
$storageType = 'local';
}
@ -417,7 +417,7 @@ class ImageService extends UploadService
$isLocal = strpos(trim($uri), 'http') !== 0;
// Attempt to find local files even if url not absolute
$base = baseUrl('/');
$base = url('/');
if (!$isLocal && strpos($uri, $base) === 0) {
$isLocal = true;
$uri = str_replace($base, '', $uri);
@ -442,7 +442,12 @@ class ImageService extends UploadService
return null;
}
return 'data:image/' . pathinfo($uri, PATHINFO_EXTENSION) . ';base64,' . base64_encode($imageData);
$extension = pathinfo($uri, PATHINFO_EXTENSION);
if ($extension === 'svg') {
$extension = 'svg+xml';
}
return 'data:image/' . $extension . ';base64,' . base64_encode($imageData);
}
/**
@ -458,7 +463,7 @@ class ImageService extends UploadService
// Get the standard public s3 url if s3 is set as storage type
// Uses the nice, short URL if bucket name has no periods in otherwise the longer
// region-based url will be used to prevent http issues.
if ($storageUrl == false && config('filesystems.default') === 's3') {
if ($storageUrl == false && config('filesystems.images') === 's3') {
$storageDetails = config('filesystems.disks.s3');
if (strpos($storageDetails['bucket'], '.') === false) {
$storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
@ -469,7 +474,7 @@ class ImageService extends UploadService
$this->storageUrl = $storageUrl;
}
$basePath = ($this->storageUrl == false) ? baseUrl('/') : $this->storageUrl;
$basePath = ($this->storageUrl == false) ? url('/') : $this->storageUrl;
return rtrim($basePath, '/') . $filePath;
}
}

View File

@ -1,8 +1,9 @@
<?php
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Entity;
use BookStack\Auth\User;
use BookStack\Ownable;
use BookStack\Settings\SettingService;
/**
* Get the path to a versioned file.
@ -11,7 +12,7 @@ use BookStack\Ownable;
* @return string
* @throws Exception
*/
function versioned_asset($file = '')
function versioned_asset($file = '') : string
{
static $version = null;
@ -26,17 +27,17 @@ function versioned_asset($file = '')
}
$path = $file . '?version=' . urlencode($version) . $additional;
return baseUrl($path);
return url($path);
}
/**
* Helper method to get the current User.
* Defaults to public 'Guest' user if not logged in.
* @return \BookStack\Auth\User
* @return User
*/
function user()
function user() : User
{
return auth()->user() ?: \BookStack\Auth\User::getDefault();
return auth()->user() ?: User::getDefault();
}
/**
@ -63,9 +64,9 @@ function hasAppAccess() : bool
* that particular item.
* @param string $permission
* @param Ownable $ownable
* @return mixed
* @return bool
*/
function userCan(string $permission, Ownable $ownable = null)
function userCan(string $permission, Ownable $ownable = null) : bool
{
if ($ownable === null) {
return user() && user()->can($permission);
@ -83,7 +84,7 @@ function userCan(string $permission, Ownable $ownable = null)
* @param string|null $entityClass
* @return bool
*/
function userCanOnAny(string $permission, string $entityClass = null)
function userCanOnAny(string $permission, string $entityClass = null) : bool
{
$permissionService = app(PermissionService::class);
return $permissionService->checkUserHasPermissionOnAnything($permission, $entityClass);
@ -93,83 +94,27 @@ function userCanOnAny(string $permission, string $entityClass = null)
* Helper to access system settings.
* @param $key
* @param bool $default
* @return bool|string|\BookStack\Settings\SettingService
* @return bool|string|SettingService
*/
function setting($key = null, $default = false)
{
$settingService = resolve(\BookStack\Settings\SettingService::class);
$settingService = resolve(SettingService::class);
if (is_null($key)) {
return $settingService;
}
return $settingService->get($key, $default);
}
/**
* Helper to create url's relative to the applications root path.
* @param string $path
* @param bool $forceAppDomain
* @return string
*/
function baseUrl($path, $forceAppDomain = false)
{
$isFullUrl = strpos($path, 'http') === 0;
if ($isFullUrl && !$forceAppDomain) {
return $path;
}
$path = trim($path, '/');
$base = rtrim(config('app.url'), '/');
// Remove non-specified domain if forced and we have a domain
if ($isFullUrl && $forceAppDomain) {
if (!empty($base) && strpos($path, $base) === 0) {
$path = substr($path, strlen($base));
} else {
$explodedPath = explode('/', $path);
$path = implode('/', array_splice($explodedPath, 3));
}
}
// Return normal url path if not specified in config
if (config('app.url') === '') {
return url($path);
}
return $base . '/' . ltrim($path, '/');
}
/**
* Get an instance of the redirector.
* Overrides the default laravel redirect helper.
* Ensures it redirects even when the app is in a subdirectory.
*
* @param string|null $to
* @param int $status
* @param array $headers
* @param bool $secure
* @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
*/
function redirect($to = null, $status = 302, $headers = [], $secure = null)
{
if (is_null($to)) {
return app('redirect');
}
$to = baseUrl($to);
return app('redirect')->to($to, $status, $headers, $secure);
}
/**
* Get a path to a theme resource.
* @param string $path
* @return string|boolean
* @return string
*/
function theme_path($path = '')
function theme_path($path = '') : string
{
$theme = config('view.theme');
if (!$theme) {
return false;
return '';
}
return base_path('themes/' . $theme .($path ? DIRECTORY_SEPARATOR.$path : $path));
@ -188,8 +133,9 @@ function theme_path($path = '')
function icon($name, $attrs = [])
{
$attrs = array_merge([
'class' => 'svg-icon',
'data-icon' => $name
'class' => 'svg-icon',
'data-icon' => $name,
'role' => 'presentation',
], $attrs);
$attrString = ' ';
foreach ($attrs as $attrName => $attr) {
@ -241,5 +187,5 @@ function sortUrl($path, $data, $overrideData = [])
return $path;
}
return baseUrl($path . '?' . implode('&', $queryStringSections));
return url($path . '?' . implode('&', $queryStringSections));
}

View File

@ -11,7 +11,7 @@
|
*/
$app = new Illuminate\Foundation\Application(
$app = new \BookStack\Application(
realpath(__DIR__.'/../')
);

View File

@ -0,0 +1,54 @@
<?php
use Carbon\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddTemplateSupport extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('pages', function (Blueprint $table) {
$table->boolean('template')->default(false);
$table->index('template');
});
// Create new templates-manage permission and assign to admin role
$adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
$permissionId = DB::table('role_permissions')->insertGetId([
'name' => 'templates-manage',
'display_name' => 'Manage Page Templates',
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString()
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
'permission_id' => $permissionId
]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('pages', function (Blueprint $table) {
$table->dropColumn('template');
});
// Remove templates-manage permission
$templatesManagePermission = DB::table('role_permissions')
->where('name', '=', 'templates_manage')->first();
DB::table('permission_role')->where('permission_id', '=', $templatesManagePermission->id)->delete();
DB::table('role_permissions')->where('name', '=', 'templates_manage')->delete();
}
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddUserInvitesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_invites', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->index();
$table->string('token')->index();
$table->nullableTimestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_invites');
}
}

16
dev/docker/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM php:7.3-apache
ENV APACHE_DOCUMENT_ROOT /app/public
WORKDIR /app
RUN apt-get update -y \
&& apt-get install -y libtidy-dev libpng-dev libldap2-dev libxml++2.6-dev wait-for-it \
&& docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu \
&& docker-php-ext-install pdo pdo_mysql tidy dom xml mbstring gd ldap \
&& 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 \
&& php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
&& php composer-setup.php \
&& mv composer.phar /usr/bin/composer \
&& php -r "unlink('composer-setup.php');"

14
dev/docker/entrypoint.app.sh Executable file
View File

@ -0,0 +1,14 @@
#!/bin/bash
set -e
env
if [[ -n "$1" ]]; then
exec "$@"
else
wait-for-it db:3306 -t 45
php artisan migrate --database=mysql
chown -R www-data:www-data storage
exec apache2-foreground
fi

8
dev/docker/entrypoint.node.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
set -e
npm install
npm rebuild node-sass
exec npm run watch

48
docker-compose.yml Normal file
View File

@ -0,0 +1,48 @@
# This is a Docker Compose configuration
# intended for development purposes only
version: '3'
volumes:
db: {}
services:
db:
image: mysql:8
environment:
MYSQL_DATABASE: bookstack-test
MYSQL_USER: bookstack-test
MYSQL_PASSWORD: bookstack-test
MYSQL_RANDOM_ROOT_PASSWORD: 'true'
command: --default-authentication-plugin=mysql_native_password
volumes:
- db:/var/lib/mysql
app:
build:
context: .
dockerfile: ./dev/docker/Dockerfile
environment:
DB_CONNECTION: mysql
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: bookstack-test
DB_USERNAME: bookstack-test
DB_PASSWORD: bookstack-test
MAIL_DRIVER: smtp
MAIL_HOST: mailhog
MAIL_PORT: 1025
ports:
- ${DEV_PORT:-8080}:80
volumes:
- ./:/app
entrypoint: /app/dev/docker/entrypoint.app.sh
node:
image: node:alpine
working_dir: /app
volumes:
- ./:/app
entrypoint: /app/dev/docker/entrypoint.node.sh
mailhog:
image: mailhog/mailhog
ports:
- ${DEV_MAIL_PORT:-8025}:8025

4320
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,34 +10,25 @@
"permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads"
},
"devDependencies": {
"@babel/core": "^7.1.6",
"@babel/polyfill": "^7.0.0",
"@babel/preset-env": "^7.1.6",
"autoprefixer": "^9.4.7",
"babel-loader": "^8.0.4",
"css-loader": "^2.1.0",
"livereload": "^0.7.0",
"mini-css-extract-plugin": "^0.5.0",
"node-sass": "^4.10.0",
"css-loader": "^2.1.1",
"livereload": "^0.8.0",
"mini-css-extract-plugin": "^0.7.0",
"node-sass": "^4.12.0",
"npm-run-all": "^4.1.5",
"postcss-loader": "^3.0.0",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"uglifyjs-webpack-plugin": "^2.1.1",
"webpack": "^4.26.1",
"webpack-cli": "^3.1.2"
"webpack": "^4.32.2",
"webpack-cli": "^3.3.2"
},
"dependencies": {
"axios": "^0.18.0",
"clipboard": "^2.0.4",
"codemirror": "^5.42.0",
"codemirror": "^5.47.0",
"dropzone": "^5.5.1",
"jquery": "^3.3.1",
"jquery-sortable": "^0.9.13",
"markdown-it": "^8.4.2",
"markdown-it-task-lists": "^2.1.1",
"vue": "^2.5.17",
"vuedraggable": "^2.16.0"
"sortablejs": "^1.9.0",
"vue": "^2.6.10",
"vuedraggable": "^2.21.0"
},
"browser": {
"vue": "vue/dist/vue.common.js"

View File

@ -34,6 +34,8 @@
<env name="AVATAR_URL" value=""/>
<env name="LDAP_VERSION" value="3"/>
<env name="STORAGE_TYPE" value="local"/>
<env name="STORAGE_ATTACHMENT_TYPE" value="local"/>
<env name="STORAGE_IMAGE_TYPE" value="local"/>
<env name="GITHUB_APP_ID" value="aaaaaaaaaaaaaa"/>
<env name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/>
<env name="GITHUB_AUTO_REGISTER" value=""/>

View File

@ -1,6 +1,6 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On

View File

@ -34,6 +34,7 @@ require __DIR__.'/../bootstrap/init.php';
*/
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->alias('request', \BookStack\Http\Request::class);
/*
|--------------------------------------------------------------------------
@ -50,7 +51,7 @@ $app = require_once __DIR__.'/../bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Illuminate\Http\Request::capture()
$request = \BookStack\Http\Request::capture()
);
$response->send();

File diff suppressed because one or more lines are too long

View File

@ -1,19 +0,0 @@
!function(d,B,m,f){function v(a,b){var c=Math.max(0,a[0]-b[0],b[0]-a[1]),e=Math.max(0,a[2]-b[1],b[1]-a[3]);return c+e}function w(a,b,c,e){var k=a.length;e=e?"offset":"position";for(c=c||0;k--;){var g=a[k].el?a[k].el:d(a[k]),l=g[e]();l.left+=parseInt(g.css("margin-left"),10);l.top+=parseInt(g.css("margin-top"),10);b[k]=[l.left-c,l.left+g.outerWidth()+c,l.top-c,l.top+g.outerHeight()+c]}}function p(a,b){var c=b.offset();return{left:a.left-c.left,top:a.top-c.top}}function x(a,b,c){b=[b.left,b.top];c=
c&&[c.left,c.top];for(var e,k=a.length,d=[];k--;)e=a[k],d[k]=[k,v(e,b),c&&v(e,c)];return d=d.sort(function(a,b){return b[1]-a[1]||b[2]-a[2]||b[0]-a[0]})}function q(a){this.options=d.extend({},n,a);this.containers=[];this.options.rootGroup||(this.scrollProxy=d.proxy(this.scroll,this),this.dragProxy=d.proxy(this.drag,this),this.dropProxy=d.proxy(this.drop,this),this.placeholder=d(this.options.placeholder),a.isValidTarget||(this.options.isValidTarget=f))}function t(a,b){this.el=a;this.options=d.extend({},
z,b);this.group=q.get(this.options);this.rootGroup=this.options.rootGroup||this.group;this.handle=this.rootGroup.options.handle||this.rootGroup.options.itemSelector;var c=this.rootGroup.options.itemPath;this.target=c?this.el.find(c):this.el;this.target.on(r.start,this.handle,d.proxy(this.dragInit,this));this.options.drop&&this.group.containers.push(this)}var r,z={drag:!0,drop:!0,exclude:"",nested:!0,vertical:!0},n={afterMove:function(a,b,c){},containerPath:"",containerSelector:"ol, ul",distance:0,
delay:0,handle:"",itemPath:"",itemSelector:"li",bodyClass:"dragging",draggedClass:"dragged",isValidTarget:function(a,b){return!0},onCancel:function(a,b,c,e){},onDrag:function(a,b,c,e){a.css(b)},onDragStart:function(a,b,c,e){a.css({height:a.outerHeight(),width:a.outerWidth()});a.addClass(b.group.options.draggedClass);d("body").addClass(b.group.options.bodyClass)},onDrop:function(a,b,c,e){a.removeClass(b.group.options.draggedClass).removeAttr("style");d("body").removeClass(b.group.options.bodyClass)},
onMousedown:function(a,b,c){if(!c.target.nodeName.match(/^(input|select|textarea)$/i))return c.preventDefault(),!0},placeholderClass:"placeholder",placeholder:'<li class="placeholder"></li>',pullPlaceholder:!0,serialize:function(a,b,c){a=d.extend({},a.data());if(c)return[b];b[0]&&(a.children=b);delete a.subContainers;delete a.sortable;return a},tolerance:0},s={},y=0,A={left:0,top:0,bottom:0,right:0};r={start:"touchstart.sortable mousedown.sortable",drop:"touchend.sortable touchcancel.sortable mouseup.sortable",
drag:"touchmove.sortable mousemove.sortable",scroll:"scroll.sortable"};q.get=function(a){s[a.group]||(a.group===f&&(a.group=y++),s[a.group]=new q(a));return s[a.group]};q.prototype={dragInit:function(a,b){this.$document=d(b.el[0].ownerDocument);var c=d(a.target).closest(this.options.itemSelector);c.length&&(this.item=c,this.itemContainer=b,!this.item.is(this.options.exclude)&&this.options.onMousedown(this.item,n.onMousedown,a)&&(this.setPointer(a),this.toggleListeners("on"),this.setupDelayTimer(),
this.dragInitDone=!0))},drag:function(a){if(!this.dragging){if(!this.distanceMet(a)||!this.delayMet)return;this.options.onDragStart(this.item,this.itemContainer,n.onDragStart,a);this.item.before(this.placeholder);this.dragging=!0}this.setPointer(a);this.options.onDrag(this.item,p(this.pointer,this.item.offsetParent()),n.onDrag,a);a=this.getPointer(a);var b=this.sameResultBox,c=this.options.tolerance;(!b||b.top-c>a.top||b.bottom+c<a.top||b.left-c>a.left||b.right+c<a.left)&&!this.searchValidTarget()&&
(this.placeholder.detach(),this.lastAppendedItem=f)},drop:function(a){this.toggleListeners("off");this.dragInitDone=!1;if(this.dragging){if(this.placeholder.closest("html")[0])this.placeholder.before(this.item).detach();else this.options.onCancel(this.item,this.itemContainer,n.onCancel,a);this.options.onDrop(this.item,this.getContainer(this.item),n.onDrop,a);this.clearDimensions();this.clearOffsetParent();this.lastAppendedItem=this.sameResultBox=f;this.dragging=!1}},searchValidTarget:function(a,b){a||
(a=this.relativePointer||this.pointer,b=this.lastRelativePointer||this.lastPointer);for(var c=x(this.getContainerDimensions(),a,b),e=c.length;e--;){var d=c[e][0];if(!c[e][1]||this.options.pullPlaceholder)if(d=this.containers[d],!d.disabled){if(!this.$getOffsetParent()){var g=d.getItemOffsetParent();a=p(a,g);b=p(b,g)}if(d.searchValidTarget(a,b))return!0}}this.sameResultBox&&(this.sameResultBox=f)},movePlaceholder:function(a,b,c,e){var d=this.lastAppendedItem;if(e||!d||d[0]!==b[0])b[c](this.placeholder),
this.lastAppendedItem=b,this.sameResultBox=e,this.options.afterMove(this.placeholder,a,b)},getContainerDimensions:function(){this.containerDimensions||w(this.containers,this.containerDimensions=[],this.options.tolerance,!this.$getOffsetParent());return this.containerDimensions},getContainer:function(a){return a.closest(this.options.containerSelector).data(m)},$getOffsetParent:function(){if(this.offsetParent===f){var a=this.containers.length-1,b=this.containers[a].getItemOffsetParent();if(!this.options.rootGroup)for(;a--;)if(b[0]!=
this.containers[a].getItemOffsetParent()[0]){b=!1;break}this.offsetParent=b}return this.offsetParent},setPointer:function(a){a=this.getPointer(a);if(this.$getOffsetParent()){var b=p(a,this.$getOffsetParent());this.lastRelativePointer=this.relativePointer;this.relativePointer=b}this.lastPointer=this.pointer;this.pointer=a},distanceMet:function(a){a=this.getPointer(a);return Math.max(Math.abs(this.pointer.left-a.left),Math.abs(this.pointer.top-a.top))>=this.options.distance},getPointer:function(a){var b=
a.originalEvent||a.originalEvent.touches&&a.originalEvent.touches[0];return{left:a.pageX||b.pageX,top:a.pageY||b.pageY}},setupDelayTimer:function(){var a=this;this.delayMet=!this.options.delay;this.delayMet||(clearTimeout(this._mouseDelayTimer),this._mouseDelayTimer=setTimeout(function(){a.delayMet=!0},this.options.delay))},scroll:function(a){this.clearDimensions();this.clearOffsetParent()},toggleListeners:function(a){var b=this;d.each(["drag","drop","scroll"],function(c,e){b.$document[a](r[e],b[e+
"Proxy"])})},clearOffsetParent:function(){this.offsetParent=f},clearDimensions:function(){this.traverse(function(a){a._clearDimensions()})},traverse:function(a){a(this);for(var b=this.containers.length;b--;)this.containers[b].traverse(a)},_clearDimensions:function(){this.containerDimensions=f},_destroy:function(){s[this.options.group]=f}};t.prototype={dragInit:function(a){var b=this.rootGroup;!this.disabled&&!b.dragInitDone&&this.options.drag&&this.isValidDrag(a)&&b.dragInit(a,this)},isValidDrag:function(a){return 1==
a.which||"touchstart"==a.type&&1==a.originalEvent.touches.length},searchValidTarget:function(a,b){var c=x(this.getItemDimensions(),a,b),e=c.length,d=this.rootGroup,g=!d.options.isValidTarget||d.options.isValidTarget(d.item,this);if(!e&&g)return d.movePlaceholder(this,this.target,"append"),!0;for(;e--;)if(d=c[e][0],!c[e][1]&&this.hasChildGroup(d)){if(this.getContainerGroup(d).searchValidTarget(a,b))return!0}else if(g)return this.movePlaceholder(d,a),!0},movePlaceholder:function(a,b){var c=d(this.items[a]),
e=this.itemDimensions[a],k="after",g=c.outerWidth(),f=c.outerHeight(),h=c.offset(),h={left:h.left,right:h.left+g,top:h.top,bottom:h.top+f};this.options.vertical?b.top<=(e[2]+e[3])/2?(k="before",h.bottom-=f/2):h.top+=f/2:b.left<=(e[0]+e[1])/2?(k="before",h.right-=g/2):h.left+=g/2;this.hasChildGroup(a)&&(h=A);this.rootGroup.movePlaceholder(this,c,k,h)},getItemDimensions:function(){this.itemDimensions||(this.items=this.$getChildren(this.el,"item").filter(":not(."+this.group.options.placeholderClass+
", ."+this.group.options.draggedClass+")").get(),w(this.items,this.itemDimensions=[],this.options.tolerance));return this.itemDimensions},getItemOffsetParent:function(){var a=this.el;return"relative"===a.css("position")||"absolute"===a.css("position")||"fixed"===a.css("position")?a:a.offsetParent()},hasChildGroup:function(a){return this.options.nested&&this.getContainerGroup(a)},getContainerGroup:function(a){var b=d.data(this.items[a],"subContainers");if(b===f){var c=this.$getChildren(this.items[a],
"container"),b=!1;c[0]&&(b=d.extend({},this.options,{rootGroup:this.rootGroup,group:y++}),b=c[m](b).data(m).group);d.data(this.items[a],"subContainers",b)}return b},$getChildren:function(a,b){var c=this.rootGroup.options,e=c[b+"Path"],c=c[b+"Selector"];a=d(a);e&&(a=a.find(e));return a.children(c)},_serialize:function(a,b){var c=this,e=this.$getChildren(a,b?"item":"container").not(this.options.exclude).map(function(){return c._serialize(d(this),!b)}).get();return this.rootGroup.options.serialize(a,
e,b)},traverse:function(a){d.each(this.items||[],function(b){(b=d.data(this,"subContainers"))&&b.traverse(a)});a(this)},_clearDimensions:function(){this.itemDimensions=f},_destroy:function(){var a=this;this.target.off(r.start,this.handle);this.el.removeData(m);this.options.drop&&(this.group.containers=d.grep(this.group.containers,function(b){return b!=a}));d.each(this.items||[],function(){d.removeData(this,"subContainers")})}};var u={enable:function(){this.traverse(function(a){a.disabled=!1})},disable:function(){this.traverse(function(a){a.disabled=
!0})},serialize:function(){return this._serialize(this.el,!0)},refresh:function(){this.traverse(function(a){a._clearDimensions()})},destroy:function(){this.traverse(function(a){a._destroy()})}};d.extend(t.prototype,u);d.fn[m]=function(a){var b=Array.prototype.slice.call(arguments,1);return this.map(function(){var c=d(this),e=c.data(m);if(e&&u[a])return u[a].apply(e,b)||this;e||a!==f&&"object"!==typeof a||c.data(m,new t(c,a));return this})}}(jQuery,window,"sortable");

View File

@ -1,2 +1,3 @@
*
!.gitignore
!.gitignore
!.htaccess

1
public/uploads/.htaccess Executable file
View File

@ -0,0 +1 @@
Options -Indexes

View File

@ -3,6 +3,7 @@
[![GitHub release](https://img.shields.io/github/release/BookStackApp/BookStack.svg)](https://github.com/BookStackApp/BookStack/releases/latest)
[![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/master/LICENSE)
[![Build Status](https://travis-ci.org/BookStackApp/BookStack.svg)](https://travis-ci.org/BookStackApp/BookStack)
[![Discord](https://img.shields.io/static/v1?label=Chat&message=Discord&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/.
@ -12,7 +13,7 @@ A platform for storing and organising information and documentation. General inf
* [Admin Login](https://demo.bookstackapp.com/login?email=admin@example.com&password=password)
* [BookStack Blog](https://www.bookstackapp.com/blog)
## Project Definition
## 📚 Project Definition
BookStack is an opinionated wiki system that provides a pleasant and simple out of the box experience. New users to an instance should find the experience intuitive and only basic word-processing skills should be required to get involved in creating content on BookStack. The platform should provide advanced power features to those that desire it but they should not interfere with the core simple user experience.
@ -20,13 +21,11 @@ BookStack is not designed as an extensible platform to be used for purposes that
In regards to development philosophy, BookStack has a relaxed, open & positive approach. At the end of the day this is free software developed and maintained by people donating their own free time.
## Road Map
## 🛣️ Road Map
Below is a high-level road map view for BookStack to provide a sense of direction of where the project is going. This can change at any point and does not reflect many features and improvements that will also be included as part of the journey along this road map. For more granular detail of what will be included in upcoming releases you can review the project milestones as defined in the "Release Process" section below.
- **Design Revamp** *[(In Progress)](https://github.com/BookStackApp/BookStack/pull/1153)*
- *A more organised modern design to clean things up, make BookStack more efficient to use and increase mobile usability.*
- **Platform REST API**
- **Platform REST API** *(In Design)*
- *A REST API covering, at minimum, control of core content models (Books, Chapters, Pages) for automation and platform extension.*
- **Editor Alignment & Review**
- *Review the page editors with goal of achieving increased interoperability & feature parity while also considering collaborative editing potential.*
@ -35,7 +34,7 @@ Below is a high-level road map view for BookStack to provide a sense of directio
- **Installation & Deployment Process Revamp**
- *Creation of a streamlined & secure process for users to deploy & update BookStack with reduced development requirements (No git or composer requirement).*
## Release Versioning & Process
## 🚀 Release Versioning & Process
BookStack releases are each assigned a version number, such as "v0.25.2", in the format `v<phase>.<feature>.<patch>`. A change only in the `patch` number indicates a fairly minor release that mainly contains fixes and therefore is very unlikely to cause breakages upon update. A change in the `feature` number indicates a release which will generally bring new features in addition to fixes and enhancements. These releases have a small chance of introducing breaking changes upon update so it's worth checking for any notes in the [update guide](https://www.bookstackapp.com/docs/admin/updates/). A change in the `phase` indicates a much large change in BookStack that will likely incur breakages requiring manual intervention.
@ -43,7 +42,7 @@ Each BookStack release will have a [milestone](https://github.com/BookStackApp/B
For feature releases, and some patch releases, the release will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blogs posts (once per week maximum) [at this link](http://eepurl.com/cmmq5j).
## Development & Testing
## 🛠️ Development & Testing
All development on BookStack is currently done on the master branch. When it's time for a release the master branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
@ -65,7 +64,7 @@ npm run production
npm run dev
```
BookStack has many integration tests that use Laravel's built-in testing capabilities which makes use of PHPUnit. To use you will need PHPUnit 6 installed and accessible via command line, Directly running the composer-installed version will not work. There is a `mysql_testing` database defined within the app config which is what is used by PHPUnit. This database is set with the following database name, user name and password defined as `bookstack-test`. You will have to create that database and credentials before testing.
BookStack has many integration tests that use Laravel's built-in testing capabilities which makes use of PHPUnit. There is a `mysql_testing` database defined within the app config which is what is used by PHPUnit. This database is set with the database name, user name and password all defined as `bookstack-test`. You will have to create that database and that set of credentials before testing.
The testing database will also need migrating and seeding beforehand. This can be done with the following commands:
@ -74,9 +73,39 @@ php artisan migrate --database=mysql_testing
php artisan db:seed --class=DummyContentSeeder --database=mysql_testing
```
Once done you can run `phpunit` in the application root directory to run all tests.
Once done you can run `php vendor/bin/phpunit` in the application root directory to run all tests.
## Translations
### 📜 Code Standards
PHP code within BookStack is generally to [PSR-2](http://www.php-fig.org/psr/psr-2/) standards. From the BookStack root folder you can run `./vendor/bin/phpcs` to check code is formatted correctly and `./vendor/bin/phpcbf` to auto-fix non-PSR-2 code.
### 🐋 Development using Docker
This repository ships with a Docker Compose configuration intended for development purposes. It'll build a PHP image with all needed extensions installed and start up a MySQL server and a Node image watching the UI assets.
To get started, make sure you meet the following requirements:
- Docker and Docker Compose are installed
- Your user is part of the `docker` group
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 `admin@admin.com` 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:
```shell script
docker-compose run app php artisan list
```
The docker-compose setup runs an instance of [MailHog](https://github.com/mailhog/MailHog) and sets environment variables to redirect any BookStack-sent emails to MailHog. You can view this mail via the MailHog web interface on `localhost:8025`. You can change the port MailHog is accessible on by setting a `DEV_MAIL_PORT` environment variable.
## 🌎 Translations
All text strings can be found in the `resources/lang` folder where each language option has its own folder. To add a new language you should copy the `en` folder to an new folder (eg. `fr` for french) then go through and translate all text strings in those files, leaving the keys and file-names intact. If a language string is missing then the `en` translation will be used. To show the language option in the user preferences language drop-down you will need to add your language to the options found at the bottom of the `resources/lang/en/settings.php` file. A system-wide language can also be set in the `.env` file like so: `APP_LANG=en`.
@ -95,29 +124,15 @@ php resources/lang/check.php pt_BR
Some strings have colon-prefixed variables in such as `:userName`. Leave these values as they are as they will be replaced at run-time.
## Contributing & Maintenance
## 🎁 Contributing, Issues & Pull Requests
Feel free to create issues to request new features or to report bugs and problems. Just please follow the template given when creating the issue.
Feel free to create issues to request new features or to report bugs & problems. Just please follow the template given when creating the issue.
Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit in to the project and plan out the merge. Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/assets`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/CODE_OF_CONDUCT.md).
### Code Standards
PHP code within BookStack is generally to [PSR-2](http://www.php-fig.org/psr/psr-2/) standards. From the BookStack root folder you can run `./vendor/bin/phpcs` to check code is formatted correctly and `./vendor/bin/phpcbf` to auto-fix non-PSR-2 code.
### Pull Requests
Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit in to the project and plan out the merge.
Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases.
If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/assets`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
## Website, Docs & Blog
The website which contains the project docs & Blog can be found in the [BookStackApp/website](https://github.com/BookStackApp/website) repo.
## Security
## 🔒 Security
Security information for administering a BookStack instance can be found on the [documentation site here](https://www.bookstackapp.com/docs/admin/security/).
@ -125,28 +140,32 @@ If you'd like to be notified of new potential security concerns you can [sign-up
If you would like to report a security concern in a more confidential manner than via a GitHub issue, You can directly email the lead maintainer [ssddanbrown](https://github.com/ssddanbrown). You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown). Alternatively you can send a DM via twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
## ♿ Accessibility
## License
We want BookStack to remain accessible to as many people as possible. We aim for at least WCAG 2.1 Level A standards where possible although we do not strictly test this upon each release. If you come across any accessibility issues please feel free to open an issue.
The BookStack source is provided under the MIT License.
## 🖥️ Website, Docs & Blog
## Attribution
The website which contains the project docs & Blog can be found in the [BookStackApp/website](https://github.com/BookStackApp/website) repo.
## ⚖️ License
The BookStack source is provided under the MIT License. The libraries used by, and included with, BookStack are provided under their own licenses.
## 👪 Attribution
The great people that have worked to build and improve BookStack can [be seen here](https://github.com/BookStackApp/BookStack/graphs/contributors).
These are the great open-source projects used to help build BookStack:
* [Laravel](http://laravel.com/)
* [jQuery](https://jquery.com/)
* [TinyMCE](https://www.tinymce.com/)
* [CodeMirror](https://codemirror.net)
* [Vue.js](http://vuejs.org/)
* [Axios](https://github.com/mzabriskie/axios)
* [jQuery Sortable](https://johnny.github.io/jquery-sortable/)
* [Sortable](https://github.com/SortableJS/Sortable) & [Vue.Draggable](https://github.com/SortableJS/Vue.Draggable)
* [Google Material Icons](https://material.io/icons/)
* [Dropzone.js](http://www.dropzonejs.com/)
* [clipboard.js](https://clipboardjs.com/)
* [TinyColorPicker](http://www.dematte.at/tinyColorPicker/index.html)
* [markdown-it](https://github.com/markdown-it/markdown-it) and [markdown-it-task-lists](https://github.com/revin/markdown-it-task-lists)
* [BarryVD](https://github.com/barryvdh)
* [Debugbar](https://github.com/barryvdh/laravel-debugbar)

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 8L12 12.58 16.59 8 18 9.41l-6 6-6-6z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 157 B

View File

@ -1,4 +1,3 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>

Before

Width:  |  Height:  |  Size: 253 B

After

Width:  |  Height:  |  Size: 211 B

View File

@ -1,4 +1,3 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
</svg>

Before

Width:  |  Height:  |  Size: 337 B

After

Width:  |  Height:  |  Size: 295 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 4h7V2H4c-1.1 0-2 .9-2 2v7h2zm16-2h-7v2h7v7h2V4c0-1.1-.9-2-2-2zm0 18h-7v2h7c1.1 0 2-.9 2-2v-7h-2zM4 13H2v7c0 1.1.9 2 2 2h7v-2H4zM16.475 15.356h-8.95v-2.237h8.95zm0-4.475h-8.95V8.644h8.95z"/></svg>

After

Width:  |  Height:  |  Size: 267 B

View File

@ -0,0 +1,204 @@
import Sortable from "sortablejs";
// Auto sort control
const sortOperations = {
name: function(a, b) {
const aName = a.getAttribute('data-name').trim().toLowerCase();
const bName = b.getAttribute('data-name').trim().toLowerCase();
return aName.localeCompare(bName);
},
created: function(a, b) {
const aTime = Number(a.getAttribute('data-created'));
const bTime = Number(b.getAttribute('data-created'));
return bTime - aTime;
},
updated: function(a, b) {
const aTime = Number(a.getAttribute('data-updated'));
const bTime = Number(b.getAttribute('data-updated'));
return bTime - aTime;
},
chaptersFirst: function(a, b) {
const aType = a.getAttribute('data-type');
const bType = b.getAttribute('data-type');
if (aType === bType) {
return 0;
}
return (aType === 'chapter' ? -1 : 1);
},
chaptersLast: function(a, b) {
const aType = a.getAttribute('data-type');
const bType = b.getAttribute('data-type');
if (aType === bType) {
return 0;
}
return (aType === 'chapter' ? 1 : -1);
},
};
class BookSort {
constructor(elem) {
this.elem = elem;
this.sortContainer = elem.querySelector('[book-sort-boxes]');
this.input = elem.querySelector('[book-sort-input]');
const initialSortBox = elem.querySelector('.sort-box');
this.setupBookSortable(initialSortBox);
this.setupSortPresets();
window.$events.listen('entity-select-confirm', this.bookSelect.bind(this));
}
/**
* Setup the handlers for the preset sort type buttons.
*/
setupSortPresets() {
let lastSort = '';
let reverse = false;
const reversibleTypes = ['name', 'created', 'updated'];
this.sortContainer.addEventListener('click', event => {
const sortButton = event.target.closest('.sort-box-options [data-sort]');
if (!sortButton) return;
event.preventDefault();
const sortLists = sortButton.closest('.sort-box').querySelectorAll('ul');
const sort = sortButton.getAttribute('data-sort');
reverse = (lastSort === sort) ? !reverse : false;
let sortFunction = sortOperations[sort];
if (reverse && reversibleTypes.includes(sort)) {
sortFunction = function(a, b) {
return 0 - sortOperations[sort](a, b)
};
}
for (let list of sortLists) {
const directItems = Array.from(list.children).filter(child => child.matches('li'));
directItems.sort(sortFunction).forEach(sortedItem => {
list.appendChild(sortedItem);
});
}
lastSort = sort;
this.updateMapInput();
});
}
/**
* Handle book selection from the entity selector.
* @param {Object} entityInfo
*/
bookSelect(entityInfo) {
const alreadyAdded = this.elem.querySelector(`[data-type="book"][data-id="${entityInfo.id}"]`) !== null;
if (alreadyAdded) return;
const entitySortItemUrl = entityInfo.link + '/sort-item';
window.$http.get(entitySortItemUrl).then(resp => {
const wrap = document.createElement('div');
wrap.innerHTML = resp.data;
const newBookContainer = wrap.children[0];
this.sortContainer.append(newBookContainer);
this.setupBookSortable(newBookContainer);
});
}
/**
* Setup the given book container element to have sortable items.
* @param {Element} bookContainer
*/
setupBookSortable(bookContainer) {
const sortElems = [bookContainer.querySelector('.sort-list')];
sortElems.push(...bookContainer.querySelectorAll('.entity-list-item + ul'));
const bookGroupConfig = {
name: 'book',
pull: ['book', 'chapter'],
put: ['book', 'chapter'],
};
const chapterGroupConfig = {
name: 'chapter',
pull: ['book', 'chapter'],
put: function(toList, fromList, draggedElem) {
return draggedElem.getAttribute('data-type') === 'page';
}
};
for (let sortElem of sortElems) {
new Sortable(sortElem, {
group: sortElem.classList.contains('sort-list') ? bookGroupConfig : chapterGroupConfig,
animation: 150,
fallbackOnBody: true,
swapThreshold: 0.65,
onSort: this.updateMapInput.bind(this),
dragClass: 'bg-white',
ghostClass: 'primary-background-light',
});
}
}
/**
* Update the input with our sort data.
*/
updateMapInput() {
const pageMap = this.buildEntityMap();
this.input.value = JSON.stringify(pageMap);
}
/**
* Build up a mapping of entities with their ordering and nesting.
* @returns {Array}
*/
buildEntityMap() {
const entityMap = [];
const lists = this.elem.querySelectorAll('.sort-list');
for (let list of lists) {
const bookId = list.closest('[data-type="book"]').getAttribute('data-id');
const directChildren = Array.from(list.children)
.filter(elem => elem.matches('[data-type="page"], [data-type="chapter"]'));
for (let i = 0; i < directChildren.length; i++) {
this.addBookChildToMap(directChildren[i], i, bookId, entityMap);
}
}
return entityMap;
}
/**
* Parse a sort item and add it to a data-map array.
* Parses sub0items if existing also.
* @param {Element} childElem
* @param {Number} index
* @param {Number} bookId
* @param {Array} entityMap
*/
addBookChildToMap(childElem, index, bookId, entityMap) {
const type = childElem.getAttribute('data-type');
const parentChapter = false;
const childId = childElem.getAttribute('data-id');
entityMap.push({
id: childId,
sort: index,
parentChapter: parentChapter,
type: type,
book: bookId
});
const subPages = childElem.querySelectorAll('[data-type="page"]');
for (let i = 0; i < subPages.length; i++) {
entityMap.push({
id: subPages[i].getAttribute('data-id'),
sort: i,
parentChapter: childId,
type: 'page',
book: bookId
});
}
}
}
export default BookSort;

View File

@ -7,14 +7,13 @@ class BreadcrumbListing {
this.searchInput = elem.querySelector('input');
this.loadingElem = elem.querySelector('.loading-container');
this.entityListElem = elem.querySelector('.breadcrumb-listing-entity-list');
this.toggleElem = elem.querySelector('[dropdown-toggle]');
// this.loadingElem.style.display = 'none';
const entityDescriptor = elem.getAttribute('breadcrumb-listing').split(':');
this.entityType = entityDescriptor[0];
this.entityId = Number(entityDescriptor[1]);
this.toggleElem.addEventListener('click', this.onShow.bind(this));
this.elem.addEventListener('show', this.onShow.bind(this));
this.searchInput.addEventListener('input', this.onSearch.bind(this));
}
@ -28,6 +27,7 @@ class BreadcrumbListing {
for (let listItem of listItems) {
const match = !input || listItem.textContent.toLowerCase().includes(input);
listItem.style.display = match ? 'flex' : 'none';
listItem.classList.toggle('hidden', !match);
}
}
@ -39,7 +39,7 @@ class BreadcrumbListing {
'entity_type': this.entityType,
};
window.$http.get('/search/entity/siblings', {params}).then(resp => {
window.$http.get('/search/entity/siblings', params).then(resp => {
this.entityListElem.innerHTML = resp.data;
}).catch(err => {
console.error(err);

View File

@ -1,3 +1,4 @@
import {slideUp, slideDown} from "../services/animations";
class ChapterToggle {
@ -9,56 +10,16 @@ class ChapterToggle {
open() {
const list = this.elem.parentNode.querySelector('.inset-list');
this.elem.classList.add('open');
list.style.display = 'block';
list.style.maxHeight = '';
const maxHeight = list.getBoundingClientRect().height + 10;
list.style.maxHeight = '0px';
list.style.overflow = 'hidden';
list.style.transition = 'max-height ease-in-out 240ms';
let transitionEndBound = onTransitionEnd.bind(this);
function onTransitionEnd() {
list.style.overflow = '';
list.style.maxHeight = '';
list.style.transition = '';
list.style.display = `block`;
list.removeEventListener('transitionend', transitionEndBound);
}
setTimeout(() => {
requestAnimationFrame(() => {
list.style.maxHeight = `${maxHeight}px`;
list.addEventListener('transitionend', transitionEndBound)
});
}, 1);
this.elem.setAttribute('aria-expanded', 'true');
slideDown(list, 240);
}
close() {
const list = this.elem.parentNode.querySelector('.inset-list');
list.style.display = 'block';
this.elem.classList.remove('open');
list.style.maxHeight = list.getBoundingClientRect().height + 'px';
list.style.overflow = 'hidden';
list.style.transition = 'max-height ease-in-out 240ms';
const transitionEndBound = onTransitionEnd.bind(this);
function onTransitionEnd() {
list.style.overflow = '';
list.style.maxHeight = '';
list.style.transition = '';
list.style.display = 'none';
list.removeEventListener('transitionend', transitionEndBound);
}
setTimeout(() => {
requestAnimationFrame(() => {
list.style.maxHeight = `0px`;
list.addEventListener('transitionend', transitionEndBound)
});
}, 1);
this.elem.setAttribute('aria-expanded', 'false');
slideUp(list, 240);
}
click(event) {

View File

@ -1,3 +1,5 @@
import {slideDown, slideUp} from "../services/animations";
/**
* Collapsible
* Provides some simple logic to allow collapsible sections.
@ -16,12 +18,14 @@ class Collapsible {
open() {
this.elem.classList.add('open');
$(this.content).slideDown(400);
this.trigger.setAttribute('aria-expanded', 'true');
slideDown(this.content, 300);
}
close() {
this.elem.classList.remove('open');
$(this.content).slideUp(400);
this.trigger.setAttribute('aria-expanded', 'false');
slideUp(this.content, 300);
}
toggle() {

View File

@ -0,0 +1,34 @@
class CustomCheckbox {
constructor(elem) {
this.elem = elem;
this.checkbox = elem.querySelector('input[type=checkbox]');
this.display = elem.querySelector('[role="checkbox"]');
this.checkbox.addEventListener('change', this.stateChange.bind(this));
this.elem.addEventListener('keydown', this.onKeyDown.bind(this));
}
onKeyDown(event) {
const isEnterOrPress = event.keyCode === 32 || event.keyCode === 13;
if (isEnterOrPress) {
event.preventDefault();
this.toggle();
}
}
toggle() {
this.checkbox.checked = !this.checkbox.checked;
this.checkbox.dispatchEvent(new Event('change'));
this.stateChange();
}
stateChange() {
const checked = this.checkbox.checked ? 'true' : 'false';
this.display.setAttribute('aria-checked', checked);
}
}
export default CustomCheckbox;

View File

@ -1,3 +1,5 @@
import {onSelect} from "../services/dom";
/**
* Dropdown
* Provides some simple logic to create simple dropdown menus.
@ -10,14 +12,16 @@ class DropDown {
this.moveMenu = elem.hasAttribute('dropdown-move-menu');
this.toggle = elem.querySelector('[dropdown-toggle]');
this.body = document.body;
this.showing = false;
this.setupListeners();
}
show(event) {
show(event = null) {
this.hideAll();
this.menu.style.display = 'block';
this.menu.classList.add('anim', 'menuIn');
this.toggle.setAttribute('aria-expanded', 'true');
if (this.moveMenu) {
// Move to body to prevent being trapped within scrollable sections
@ -38,10 +42,17 @@ class DropDown {
});
// Focus on first input if existing
let input = this.menu.querySelector('input');
const input = this.menu.querySelector('input');
if (input !== null) input.focus();
event.stopPropagation();
this.showing = true;
const showEvent = new Event('show');
this.container.dispatchEvent(showEvent);
if (event) {
event.stopPropagation();
}
}
hideAll() {
@ -53,6 +64,7 @@ class DropDown {
hide() {
this.menu.style.display = 'none';
this.menu.classList.remove('anim', 'menuIn');
this.toggle.setAttribute('aria-expanded', 'false');
if (this.moveMenu) {
this.menu.style.position = '';
this.menu.style.left = '';
@ -60,22 +72,78 @@ class DropDown {
this.menu.style.width = '';
this.container.appendChild(this.menu);
}
this.showing = false;
}
getFocusable() {
return Array.from(this.menu.querySelectorAll('[tabindex],[href],button,input:not([type=hidden])'));
}
focusNext() {
const focusable = this.getFocusable();
const currentIndex = focusable.indexOf(document.activeElement);
let newIndex = currentIndex + 1;
if (newIndex >= focusable.length) {
newIndex = 0;
}
focusable[newIndex].focus();
}
focusPrevious() {
const focusable = this.getFocusable();
const currentIndex = focusable.indexOf(document.activeElement);
let newIndex = currentIndex - 1;
if (newIndex < 0) {
newIndex = focusable.length - 1;
}
focusable[newIndex].focus();
}
setupListeners() {
// Hide menu on option click
this.container.addEventListener('click', event => {
let possibleChildren = Array.from(this.menu.querySelectorAll('a'));
if (possibleChildren.indexOf(event.target) !== -1) this.hide();
const possibleChildren = Array.from(this.menu.querySelectorAll('a'));
if (possibleChildren.includes(event.target)) {
this.hide();
}
});
// Show dropdown on toggle click
this.toggle.addEventListener('click', this.show.bind(this));
// Hide menu on enter press
this.container.addEventListener('keypress', event => {
if (event.keyCode !== 13) return true;
onSelect(this.toggle, event => {
event.stopPropagation();
this.show(event);
if (event instanceof KeyboardEvent) {
this.focusNext();
}
});
// Keyboard navigation
const keyboardNavigation = event => {
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
this.focusNext();
event.preventDefault();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
this.focusPrevious();
event.preventDefault();
} else if (event.key === 'Escape') {
this.hide();
return false;
this.toggle.focus();
event.stopPropagation();
}
};
this.container.addEventListener('keydown', keyboardNavigation);
if (this.moveMenu) {
this.menu.addEventListener('keydown', keyboardNavigation);
}
// Hide menu on enter press or escape
this.menu.addEventListener('keydown ', event => {
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
this.hide();
}
});
}

View File

@ -23,6 +23,8 @@ class EditorToolbox {
toggle() {
this.elem.classList.toggle('open');
const expanded = this.elem.classList.contains('open') ? 'true' : 'false';
this.toggleButton.setAttribute('aria-expanded', expanded);
}
setActiveTab(tabName, openToolbox = false) {

View File

@ -0,0 +1,20 @@
class EntityPermissionsEditor {
constructor(elem) {
this.permissionsTable = elem.querySelector('[permissions-table]');
// Handle toggle all event
this.restrictedCheckbox = elem.querySelector('[name=restricted]');
this.restrictedCheckbox.addEventListener('change', this.updateTableVisibility.bind(this));
}
updateTableVisibility() {
this.permissionsTable.style.display =
this.restrictedCheckbox.checked
? null
: 'none';
}
}
export default EntityPermissionsEditor;

View File

@ -1,3 +1,4 @@
import {slideUp, slideDown} from "../services/animations";
class ExpandToggle {
@ -14,46 +15,11 @@ class ExpandToggle {
}
open(elemToToggle) {
elemToToggle.style.display = 'block';
elemToToggle.style.height = '';
let height = elemToToggle.getBoundingClientRect().height;
elemToToggle.style.height = '0px';
elemToToggle.style.overflow = 'hidden';
elemToToggle.style.transition = 'height ease-in-out 240ms';
let transitionEndBound = onTransitionEnd.bind(this);
function onTransitionEnd() {
elemToToggle.style.overflow = '';
elemToToggle.style.height = '';
elemToToggle.style.transition = '';
elemToToggle.removeEventListener('transitionend', transitionEndBound);
}
setTimeout(() => {
elemToToggle.style.height = `${height}px`;
elemToToggle.addEventListener('transitionend', transitionEndBound)
}, 1);
slideDown(elemToToggle, 200);
}
close(elemToToggle) {
elemToToggle.style.display = 'block';
elemToToggle.style.height = elemToToggle.getBoundingClientRect().height + 'px';
elemToToggle.style.overflow = 'hidden';
elemToToggle.style.transition = 'all ease-in-out 240ms';
let transitionEndBound = onTransitionEnd.bind(this);
function onTransitionEnd() {
elemToToggle.style.overflow = '';
elemToToggle.style.height = '';
elemToToggle.style.transition = '';
elemToToggle.style.display = 'none';
elemToToggle.removeEventListener('transitionend', transitionEndBound);
}
setTimeout(() => {
elemToToggle.style.height = `0px`;
elemToToggle.addEventListener('transitionend', transitionEndBound)
}, 1);
slideUp(elemToToggle, 200);
}
click(event) {

View File

@ -23,6 +23,12 @@ import listSortControl from "./list-sort-control";
import triLayout from "./tri-layout";
import breadcrumbListing from "./breadcrumb-listing";
import permissionsTable from "./permissions-table";
import customCheckbox from "./custom-checkbox";
import bookSort from "./book-sort";
import settingAppColorPicker from "./setting-app-color-picker";
import entityPermissionsEditor from "./entity-permissions-editor";
import templateManager from "./template-manager";
import newUserPassword from "./new-user-password";
const componentMapping = {
'dropdown': dropdown,
@ -50,6 +56,12 @@ const componentMapping = {
'tri-layout': triLayout,
'breadcrumb-listing': breadcrumbListing,
'permissions-table': permissionsTable,
'custom-checkbox': customCheckbox,
'book-sort': bookSort,
'setting-app-color-picker': settingAppColorPicker,
'entity-permissions-editor': entityPermissionsEditor,
'template-manager': templateManager,
'new-user-password': newUserPassword,
};
window.components = {};

View File

@ -1,6 +1,7 @@
import MarkdownIt from "markdown-it";
import mdTasksLists from 'markdown-it-task-lists';
import code from '../services/code';
import {debounce} from "../services/util";
import DrawIO from "../services/drawio";
@ -17,19 +18,18 @@ class MarkdownEditor {
this.markdown.use(mdTasksLists, {label: true});
this.display = this.elem.querySelector('.markdown-display');
this.displayStylesLoaded = false;
this.input = this.elem.querySelector('textarea');
this.htmlInput = this.elem.querySelector('input[name=html]');
this.cm = code.markdownEditor(this.input);
this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
this.init();
// Scroll to text if needed.
const queryParams = (new URL(window.location)).searchParams;
const scrollText = queryParams.get('content-text');
if (scrollText) {
this.scrollToText(scrollText);
}
this.display.addEventListener('load', () => {
this.displayDoc = this.display.contentDocument;
this.init();
});
}
init() {
@ -37,7 +37,7 @@ class MarkdownEditor {
let lastClick = 0;
// Prevent markdown display link click redirect
this.display.addEventListener('click', event => {
this.displayDoc.addEventListener('click', event => {
let isDblClick = Date.now() - lastClick < 300;
let link = event.target.closest('a');
@ -90,28 +90,53 @@ class MarkdownEditor {
});
this.codeMirrorSetup();
this.listenForBookStackEditorEvents();
// Scroll to text if needed.
const queryParams = (new URL(window.location)).searchParams;
const scrollText = queryParams.get('content-text');
if (scrollText) {
this.scrollToText(scrollText);
}
}
// Update the input content and render the display.
updateAndRender() {
let content = this.cm.getValue();
const content = this.cm.getValue();
this.input.value = content;
let html = this.markdown.render(content);
const html = this.markdown.render(content);
window.$events.emit('editor-html-change', html);
window.$events.emit('editor-markdown-change', content);
this.display.innerHTML = html;
// Set body content
this.displayDoc.body.className = 'page-content';
this.displayDoc.body.innerHTML = html;
this.htmlInput.value = html;
// Copy styles from page head and set custom styles for editor
this.loadStylesIntoDisplay();
}
loadStylesIntoDisplay() {
if (this.displayStylesLoaded) return;
this.displayDoc.documentElement.className = 'markdown-editor-display';
this.displayDoc.head.innerHTML = '';
const styles = document.head.querySelectorAll('style,link[rel=stylesheet]');
for (let style of styles) {
const copy = style.cloneNode(true);
this.displayDoc.head.appendChild(copy);
}
this.displayStylesLoaded = true;
}
onMarkdownScroll(lineCount) {
let elems = this.display.children;
const elems = this.displayDoc.body.children;
if (elems.length <= lineCount) return;
let topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
// TODO - Replace jQuery
$(this.display).animate({
scrollTop: topElem.offsetTop
}, {queue: false, duration: 200, easing: 'linear'});
const topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
topElem.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'smooth'});
}
codeMirrorSetup() {
@ -160,8 +185,7 @@ class MarkdownEditor {
this.updateAndRender();
});
// Handle scroll to sync display view
cm.on('scroll', instance => {
const onScrollDebounced = debounce((instance) => {
// Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
let scroll = instance.getScrollInfo();
let atEnd = scroll.top + scroll.clientHeight === scroll.height;
@ -176,6 +200,11 @@ class MarkdownEditor {
let doc = parser.parseFromString(this.markdown.render(range), 'text/html');
let totalLines = doc.documentElement.querySelectorAll('body > *');
this.onMarkdownScroll(totalLines.length);
}, 100);
// Handle scroll to sync display view
cm.on('scroll', instance => {
onScrollDebounced(instance);
});
// Handle image paste
@ -197,16 +226,30 @@ class MarkdownEditor {
}
});
// Handle images on drag-drop
// Handle image & content drag n drop
cm.on('drop', (cm, event) => {
event.stopPropagation();
event.preventDefault();
let cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
cm.setCursor(cursorPos);
if (!event.dataTransfer || !event.dataTransfer.files) return;
for (let i = 0; i < event.dataTransfer.files.length; i++) {
uploadImage(event.dataTransfer.files[i]);
const templateId = event.dataTransfer.getData('bookstack/template');
if (templateId) {
const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
cm.setCursor(cursorPos);
event.preventDefault();
window.$http.get(`/templates/${templateId}`).then(resp => {
const content = resp.data.markdown || resp.data.html;
cm.replaceSelection(content);
});
}
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
cm.setCursor(cursorPos);
event.stopPropagation();
event.preventDefault();
for (let i = 0; i < event.dataTransfer.files.length; i++) {
uploadImage(event.dataTransfer.files[i]);
}
}
});
// Helper to replace editor content
@ -459,6 +502,37 @@ class MarkdownEditor {
})
}
listenForBookStackEditorEvents() {
function getContentToInsert({html, markdown}) {
return markdown || html;
}
// Replace editor content
window.$events.listen('editor::replace', (eventContent) => {
const markdown = getContentToInsert(eventContent);
this.cm.setValue(markdown);
});
// Append editor content
window.$events.listen('editor::append', (eventContent) => {
const cursorPos = this.cm.getCursor('from');
const markdown = getContentToInsert(eventContent);
const content = this.cm.getValue() + '\n' + markdown;
this.cm.setValue(content);
this.cm.setCursor(cursorPos.line, cursorPos.ch);
});
// Prepend editor content
window.$events.listen('editor::prepend', (eventContent) => {
const cursorPos = this.cm.getCursor('from');
const markdown = getContentToInsert(eventContent);
const content = markdown + '\n' + this.cm.getValue();
this.cm.setValue(content);
const prependLineCount = markdown.split('\n').length;
this.cm.setCursor(cursorPos.line + prependLineCount, cursorPos.ch);
});
}
}
export default MarkdownEditor ;

View File

@ -0,0 +1,28 @@
class NewUserPassword {
constructor(elem) {
this.elem = elem;
this.inviteOption = elem.querySelector('input[name=send_invite]');
if (this.inviteOption) {
this.inviteOption.addEventListener('change', this.inviteOptionChange.bind(this));
this.inviteOptionChange();
}
}
inviteOptionChange() {
const inviting = (this.inviteOption.value === 'true');
const passwordBoxes = this.elem.querySelectorAll('input[type=password]');
for (const input of passwordBoxes) {
input.disabled = inviting;
}
const container = this.elem.querySelector('#password-input-container');
if (container) {
container.style.display = inviting ? 'none' : 'block';
}
}
}
export default NewUserPassword;

View File

@ -6,12 +6,22 @@ class Overlay {
elem.addEventListener('click', event => {
if (event.target === elem) return this.hide();
});
window.addEventListener('keyup', event => {
if (event.key === 'Escape') {
this.hide();
}
});
let closeButtons = elem.querySelectorAll('.popup-header-close');
for (let i=0; i < closeButtons.length; i++) {
closeButtons[i].addEventListener('click', this.hide.bind(this));
}
}
hide() { this.toggle(false); }
show() { this.toggle(true); }
toggle(show = true) {
let start = Date.now();
let duration = 240;
@ -22,6 +32,9 @@ class Overlay {
this.container.style.opacity = targetOpacity;
if (elapsedTime > duration) {
this.container.style.display = show ? 'flex' : 'none';
if (show) {
this.focusOnBody();
}
this.container.style.opacity = '';
} else {
requestAnimationFrame(setOpacity.bind(this));
@ -31,8 +44,12 @@ class Overlay {
requestAnimationFrame(setOpacity.bind(this));
}
hide() { this.toggle(false); }
show() { this.toggle(true); }
focusOnBody() {
const body = this.container.querySelector('.popup-body');
if (body) {
body.focus();
}
}
}

View File

@ -1,4 +1,6 @@
import MarkdownIt from "markdown-it";
import {scrollAndHighlightElement} from "../services/util";
const md = new MarkdownIt({ html: false });
class PageComments {
@ -25,8 +27,8 @@ class PageComments {
handleAction(event) {
let actionElem = event.target.closest('[action]');
if (event.target.matches('a[href^="#"]')) {
let id = event.target.href.split('#')[1];
window.scrollAndHighlight(document.querySelector('#' + id));
const id = event.target.href.split('#')[1];
scrollAndHighlightElement(document.querySelector('#' + id));
}
if (actionElem === null) return;
event.preventDefault();
@ -132,7 +134,7 @@ class PageComments {
this.formContainer.parentNode.style.display = 'block';
this.elem.querySelector('[comment-add-button-container]').style.display = 'none';
this.formInput.focus();
window.scrollToElement(this.formInput);
this.formInput.scrollIntoView({behavior: "smooth"});
}
hideForm() {

View File

@ -1,5 +1,7 @@
import Clipboard from "clipboard/dist/clipboard.min";
import Code from "../services/code";
import * as DOM from "../services/dom";
import {scrollAndHighlightElement} from "../services/util";
class PageDisplay {
@ -9,7 +11,6 @@ class PageDisplay {
Code.highlight();
this.setupPointer();
this.setupStickySidebar();
this.setupNavHighlighting();
// Check the hash on load
@ -19,166 +20,135 @@ class PageDisplay {
}
// Sidebar page nav click event
$('.sidebar-page-nav').on('click', 'a', event => {
this.goToText(event.target.getAttribute('href').substr(1));
});
const sidebarPageNav = document.querySelector('.sidebar-page-nav');
if (sidebarPageNav) {
DOM.onChildEvent(sidebarPageNav, 'a', 'click', (event, child) => {
event.preventDefault();
window.components['tri-layout'][0].showContent();
const contentId = child.getAttribute('href').substr(1);
this.goToText(contentId);
window.history.pushState(null, null, '#' + contentId);
});
}
}
goToText(text) {
let idElem = document.getElementById(text);
$('.page-content [data-highlighted]').attr('data-highlighted', '').css('background-color', '');
const idElem = document.getElementById(text);
DOM.forEach('.page-content [data-highlighted]', elem => {
elem.removeAttribute('data-highlighted');
elem.style.backgroundColor = null;
});
if (idElem !== null) {
window.scrollAndHighlight(idElem);
scrollAndHighlightElement(idElem);
} else {
$('.page-content').find(':contains("' + text + '")').smoothScrollTo();
const textElem = DOM.findText('.page-content > div > *', text);
if (textElem) {
scrollAndHighlightElement(textElem);
}
}
}
setupPointer() {
if (document.getElementById('pointer') === null) return;
let pointer = document.getElementById('pointer');
if (!pointer) {
return;
}
// Set up pointer
let $pointer = $('#pointer').detach();
pointer = pointer.parentNode.removeChild(pointer);
const pointerInner = pointer.querySelector('div.pointer');
// Instance variables
let pointerShowing = false;
let $pointerInner = $pointer.children('div.pointer').first();
let isSelection = false;
let pointerModeLink = true;
let pointerSectionId = '';
// Select all contents on input click
$pointer.on('click', 'input', event => {
$(this).select();
DOM.onChildEvent(pointer, 'input', 'click', (event, input) => {
input.select();
event.stopPropagation();
});
$pointer.on('click focus', event => {
// Prevent closing pointer when clicked or focused
DOM.onEvents(pointer, ['click', 'focus'], event => {
event.stopPropagation();
});
// Pointer mode toggle
$pointer.on('click', 'span.icon', event => {
DOM.onChildEvent(pointer, 'span.icon', 'click', (event, icon) => {
event.stopPropagation();
let $icon = $(event.currentTarget);
pointerModeLink = !pointerModeLink;
$icon.find('[data-icon="include"]').toggle(!pointerModeLink);
$icon.find('[data-icon="link"]').toggle(pointerModeLink);
icon.querySelector('[data-icon="include"]').style.display = (!pointerModeLink) ? 'inline' : 'none';
icon.querySelector('[data-icon="link"]').style.display = (pointerModeLink) ? 'inline' : 'none';
updatePointerContent();
});
// Set up clipboard
let clipboard = new Clipboard($pointer[0].querySelector('button'));
new Clipboard(pointer.querySelector('button'));
// Hide pointer when clicking away
$(document.body).find('*').on('click focus', event => {
DOM.onEvents(document.body, ['click', 'focus'], event => {
if (!pointerShowing || isSelection) return;
$pointer.detach();
pointer = pointer.parentElement.removeChild(pointer);
pointerShowing = false;
});
let updatePointerContent = ($elem) => {
let updatePointerContent = (element) => {
let inputText = pointerModeLink ? window.baseUrl(`/link/${this.pageId}#${pointerSectionId}`) : `{{@${this.pageId}#${pointerSectionId}}}`;
if (pointerModeLink && inputText.indexOf('http') !== 0) inputText = window.location.protocol + "//" + window.location.host + inputText;
if (pointerModeLink && !inputText.startsWith('http')) {
inputText = window.location.protocol + "//" + window.location.host + inputText;
}
$pointer.find('input').val(inputText);
pointer.querySelector('input').value = inputText;
// update anchor if present
const $editAnchor = $pointer.find('#pointer-edit');
if ($editAnchor.length !== 0 && $elem) {
const editHref = $editAnchor.data('editHref');
const element = $elem[0];
// Update anchor if present
const editAnchor = pointer.querySelector('#pointer-edit');
if (editAnchor && element) {
const editHref = editAnchor.dataset.editHref;
const elementId = element.id;
// get the first 50 characters.
let queryContent = element.textContent && element.textContent.substring(0, 50);
$editAnchor[0].href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
const queryContent = element.textContent && element.textContent.substring(0, 50);
editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
}
};
// Show pointer when selecting a single block of tagged content
$('.page-content [id^="bkmrk"]').on('mouseup keyup', function (e) {
e.stopPropagation();
let selection = window.getSelection();
if (selection.toString().length === 0) return;
DOM.forEach('.page-content [id^="bkmrk"]', bookMarkElem => {
DOM.onEvents(bookMarkElem, ['mouseup', 'keyup'], event => {
event.stopPropagation();
let selection = window.getSelection();
if (selection.toString().length === 0) return;
// Show pointer and set link
let $elem = $(this);
pointerSectionId = $elem.attr('id');
updatePointerContent($elem);
// Show pointer and set link
pointerSectionId = bookMarkElem.id;
updatePointerContent(bookMarkElem);
$elem.before($pointer);
$pointer.show();
pointerShowing = true;
bookMarkElem.parentNode.insertBefore(pointer, bookMarkElem);
pointer.style.display = 'block';
pointerShowing = true;
isSelection = true;
// Set pointer to sit near mouse-up position
let pointerLeftOffset = (e.pageX - $elem.offset().left - ($pointerInner.width() / 2));
if (pointerLeftOffset < 0) pointerLeftOffset = 0;
let pointerLeftOffsetPercent = (pointerLeftOffset / $elem.width()) * 100;
$pointerInner.css('left', pointerLeftOffsetPercent + '%');
// Set pointer to sit near mouse-up position
requestAnimationFrame(() => {
const bookMarkBounds = bookMarkElem.getBoundingClientRect();
let pointerLeftOffset = (event.pageX - bookMarkBounds.left - 164);
if (pointerLeftOffset < 0) {
pointerLeftOffset = 0
}
const pointerLeftOffsetPercent = (pointerLeftOffset / bookMarkBounds.width) * 100;
isSelection = true;
setTimeout(() => {
isSelection = false;
}, 100);
});
}
pointerInner.style.left = pointerLeftOffsetPercent + '%';
setupStickySidebar() {
// Make the sidebar stick in view on scroll
const $window = $(window);
const $sidebar = $("#sidebar .scroll-body");
const $sidebarContainer = $sidebar.parent();
const sidebarHeight = $sidebar.height() + 32;
setTimeout(() => {
isSelection = false;
}, 100);
});
// Check the page is scrollable and the content is taller than the tree
const pageScrollable = ($(document).height() > ($window.height() + 40)) && (sidebarHeight < $('.page-content').height());
// Get current tree's width and header height
const headerHeight = $("#header").height() + $(".toolbar").height();
let isFixed = $window.scrollTop() > headerHeight;
// Fix the tree as a sidebar
function stickTree() {
$sidebar.width($sidebarContainer.width() + 15);
$sidebar.addClass("fixed");
isFixed = true;
}
// Un-fix the tree back into position
function unstickTree() {
$sidebar.css('width', 'auto');
$sidebar.removeClass("fixed");
isFixed = false;
}
// Checks if the tree stickiness state should change
function checkTreeStickiness(skipCheck) {
let shouldBeFixed = $window.scrollTop() > headerHeight;
if (shouldBeFixed && (!isFixed || skipCheck)) {
stickTree();
} else if (!shouldBeFixed && (isFixed || skipCheck)) {
unstickTree();
}
}
// The event ran when the window scrolls
function windowScrollEvent() {
checkTreeStickiness(false);
}
// If the page is scrollable and the window is wide enough listen to scroll events
// and evaluate tree stickiness.
if (pageScrollable && $window.width() > 1000) {
$window.on('scroll', windowScrollEvent);
checkTreeStickiness(true);
}
// Handle window resizing and switch between desktop/mobile views
$window.on('resize', event => {
if (pageScrollable && $window.width() > 1000) {
$window.on('scroll', windowScrollEvent);
checkTreeStickiness(true);
} else {
$window.off('scroll', windowScrollEvent);
unstickTree();
}
});
});
}
@ -221,10 +191,9 @@ class PageDisplay {
}
function toggleAnchorHighlighting(elementId, shouldHighlight) {
const anchorsToHighlight = pageNav.querySelectorAll('a[href="#' + elementId + '"]');
for (let anchor of anchorsToHighlight) {
DOM.forEach('a[href="#' + elementId + '"]', anchor => {
anchor.closest('li').classList.toggle('current-heading', shouldHighlight);
}
});
}
}
}

View File

@ -0,0 +1,56 @@
class SettingAppColorPicker {
constructor(elem) {
this.elem = elem;
this.colorInput = elem.querySelector('input[type=color]');
this.lightColorInput = elem.querySelector('input[name="setting-app-color-light"]');
this.resetButton = elem.querySelector('[setting-app-color-picker-reset]');
this.colorInput.addEventListener('change', this.updateColor.bind(this));
this.colorInput.addEventListener('input', this.updateColor.bind(this));
this.resetButton.addEventListener('click', event => {
this.colorInput.value = '#206ea7';
this.updateColor();
});
}
/**
* Update the app colors as a preview, and create a light version of the color.
*/
updateColor() {
const hexVal = this.colorInput.value;
const rgb = this.hexToRgb(hexVal);
const rgbLightVal = 'rgba('+ [rgb.r, rgb.g, rgb.b, '0.15'].join(',') +')';
this.lightColorInput.value = rgbLightVal;
const customStyles = document.getElementById('custom-styles');
const oldColor = customStyles.getAttribute('data-color');
const oldColorLight = customStyles.getAttribute('data-color-light');
customStyles.innerHTML = customStyles.innerHTML.split(oldColor).join(hexVal);
customStyles.innerHTML = customStyles.innerHTML.split(oldColorLight).join(rgbLightVal);
customStyles.setAttribute('data-color', hexVal);
customStyles.setAttribute('data-color-light', rgbLightVal);
}
/**
* Covert a hex color code to rgb components.
* @attribution https://stackoverflow.com/a/5624139
* @param hex
* @returns {*}
*/
hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return {
r: result ? parseInt(result[1], 16) : 0,
g: result ? parseInt(result[2], 16) : 0,
b: result ? parseInt(result[3], 16) : 0
};
}
}
export default SettingAppColorPicker;

View File

@ -1,25 +1,26 @@
import "jquery-sortable";
import Sortable from "sortablejs";
class ShelfSort {
constructor(elem) {
this.elem = elem;
this.sortGroup = this.initSortable();
this.input = document.getElementById('books-input');
this.shelfBooksList = elem.querySelector('[shelf-sort-assigned-books]');
this.initSortable();
this.setupListeners();
}
initSortable() {
const placeHolderContent = this.getPlaceholderHTML();
// TODO - Load sortable at this point
return $('.scroll-box').sortable({
group: 'shelf-books',
exclude: '.instruction,.scroll-box-placeholder',
containerSelector: 'div.scroll-box',
itemSelector: '.scroll-box-item',
placeholder: placeHolderContent,
onDrop: this.onDrop.bind(this)
});
const scrollBoxes = this.elem.querySelectorAll('.scroll-box');
for (let scrollBox of scrollBoxes) {
new Sortable(scrollBox, {
group: 'shelf-books',
ghostClass: 'primary-background-light',
animation: 150,
onSort: this.onChange.bind(this),
});
}
}
setupListeners() {
@ -45,27 +46,11 @@ class ShelfSort {
this.onChange();
}
onDrop($item, container, _super) {
this.onChange();
_super($item, container);
}
onChange() {
const data = this.sortGroup.sortable('serialize').get();
this.input.value = data[0].map(item => item.id).join(',');
const instruction = this.elem.querySelector('.scroll-box-item.instruction');
instruction.parentNode.insertBefore(instruction, instruction.parentNode.children[0]);
const shelfBookElems = Array.from(this.shelfBooksList.querySelectorAll('[data-id]'));
this.input.value = shelfBookElems.map(elem => elem.getAttribute('data-id')).join(',');
}
getPlaceholderHTML() {
const placeHolder = document.querySelector('.scroll-box-placeholder');
placeHolder.style.display = 'block';
const placeHolderContent = placeHolder.outerHTML;
placeHolder.style.display = 'none';
return placeHolderContent;
}
}
export default ShelfSort;

View File

@ -0,0 +1,94 @@
import * as DOM from "../services/dom";
class TemplateManager {
constructor(elem) {
this.elem = elem;
this.list = elem.querySelector('[template-manager-list]');
this.searching = false;
// Template insert action buttons
DOM.onChildEvent(this.elem, '[template-action]', 'click', this.handleTemplateActionClick.bind(this));
// Template list pagination click
DOM.onChildEvent(this.elem, '.pagination a', 'click', this.handlePaginationClick.bind(this));
// Template list item content click
DOM.onChildEvent(this.elem, '.template-item-content', 'click', this.handleTemplateItemClick.bind(this));
// Template list item drag start
DOM.onChildEvent(this.elem, '.template-item', 'dragstart', this.handleTemplateItemDragStart.bind(this));
this.setupSearchBox();
}
handleTemplateItemClick(event, templateItem) {
const templateId = templateItem.closest('[template-id]').getAttribute('template-id');
this.insertTemplate(templateId, 'replace');
}
handleTemplateItemDragStart(event, templateItem) {
const templateId = templateItem.closest('[template-id]').getAttribute('template-id');
event.dataTransfer.setData('bookstack/template', templateId);
event.dataTransfer.setData('text/plain', templateId);
}
handleTemplateActionClick(event, actionButton) {
event.stopPropagation();
const action = actionButton.getAttribute('template-action');
const templateId = actionButton.closest('[template-id]').getAttribute('template-id');
this.insertTemplate(templateId, action);
}
async insertTemplate(templateId, action = 'replace') {
const resp = await window.$http.get(`/templates/${templateId}`);
const eventName = 'editor::' + action;
window.$events.emit(eventName, resp.data);
}
async handlePaginationClick(event, paginationLink) {
event.preventDefault();
const paginationUrl = paginationLink.getAttribute('href');
const resp = await window.$http.get(paginationUrl);
this.list.innerHTML = resp.data;
}
setupSearchBox() {
const searchBox = this.elem.querySelector('.search-box');
const input = searchBox.querySelector('input');
const submitButton = searchBox.querySelector('button');
const cancelButton = searchBox.querySelector('button.search-box-cancel');
async function performSearch() {
const searchTerm = input.value;
const resp = await window.$http.get(`/templates`, {
search: searchTerm
});
cancelButton.style.display = searchTerm ? 'block' : 'none';
this.list.innerHTML = resp.data;
}
performSearch = performSearch.bind(this);
// Searchbox enter press
searchBox.addEventListener('keypress', event => {
if (event.key === 'Enter') {
event.preventDefault();
performSearch();
}
});
// Submit button press
submitButton.addEventListener('click', event => {
performSearch();
});
// Cancel button press
cancelButton.addEventListener('click', event => {
input.value = '';
performSearch();
});
}
}
export default TemplateManager;

View File

@ -6,12 +6,16 @@ class ToggleSwitch {
this.input = elem.querySelector('input[type=hidden]');
this.checkbox = elem.querySelector('input[type=checkbox]');
this.checkbox.addEventListener('change', this.onClick.bind(this));
this.checkbox.addEventListener('change', this.stateChange.bind(this));
}
onClick(event) {
let checked = this.checkbox.checked;
this.input.value = checked ? 'true' : 'false';
stateChange() {
this.input.value = (this.checkbox.checked ? 'true' : 'false');
// Dispatch change event from hidden input so they can be listened to
// like a normal checkbox.
const changeEvent = new Event('change');
this.input.dispatchEvent(changeEvent);
}
}

View File

@ -66,28 +66,46 @@ class TriLayout {
*/
mobileTabClick(event) {
const tab = event.target.getAttribute('tri-layout-mobile-tab');
this.showTab(tab);
}
/**
* Show the content tab.
* Used by the page-display component.
*/
showContent() {
this.showTab('content', false);
}
/**
* Show the given tab
* @param tabName
*/
showTab(tabName, scroll = true) {
this.scrollCache[this.lastTabShown] = document.documentElement.scrollTop;
// Set tab status
const activeTabs = document.querySelectorAll('.tri-layout-mobile-tab.active');
for (let tab of activeTabs) {
tab.classList.remove('active');
const tabs = document.querySelectorAll('.tri-layout-mobile-tab');
for (let tab of tabs) {
const isActive = (tab.getAttribute('tri-layout-mobile-tab') === tabName);
tab.classList.toggle('active', isActive);
}
event.target.classList.add('active');
// Toggle section
const showInfo = (tab === 'info');
const showInfo = (tabName === 'info');
this.elem.classList.toggle('show-info', showInfo);
// Set the scroll position from cache
const pageHeader = document.querySelector('header');
const defaultScrollTop = pageHeader.getBoundingClientRect().bottom;
document.documentElement.scrollTop = this.scrollCache[tab] || defaultScrollTop;
setTimeout(() => {
document.documentElement.scrollTop = this.scrollCache[tab] || defaultScrollTop;
}, 50);
if (scroll) {
const pageHeader = document.querySelector('header');
const defaultScrollTop = pageHeader.getBoundingClientRect().bottom;
document.documentElement.scrollTop = this.scrollCache[tabName] || defaultScrollTop;
setTimeout(() => {
document.documentElement.scrollTop = this.scrollCache[tabName] || defaultScrollTop;
}, 50);
}
this.lastTabShown = tab;
this.lastTabShown = tabName;
}
}

View File

@ -168,23 +168,24 @@ function codePlugin() {
});
}
function codeMirrorContainerToPre($codeMirrorContainer) {
let textArea = $codeMirrorContainer[0].querySelector('textarea');
let code = textArea.textContent;
let lang = $codeMirrorContainer[0].getAttribute('data-lang');
function codeMirrorContainerToPre(codeMirrorContainer) {
const textArea = codeMirrorContainer.querySelector('textarea');
const code = textArea.textContent;
const lang = codeMirrorContainer.getAttribute('data-lang');
$codeMirrorContainer.removeAttr('contentEditable');
let $pre = $('<pre></pre>');
$pre.append($('<code></code>').each((index, elem) => {
// Needs to be textContent since innerText produces BR:s
elem.textContent = code;
}).attr('class', `language-${lang}`));
$codeMirrorContainer.replaceWith($pre);
codeMirrorContainer.removeAttribute('contentEditable');
const pre = document.createElement('pre');
const codeElem = document.createElement('code');
codeElem.classList.add(`language-${lang}`);
codeElem.textContent = code;
pre.appendChild(codeElem);
codeMirrorContainer.parentElement.replaceChild(pre, codeMirrorContainer);
}
window.tinymce.PluginManager.add('codeeditor', function(editor, url) {
let $ = editor.$;
const $ = editor.$;
editor.addButton('codeeditor', {
text: 'Code block',
@ -198,10 +199,8 @@ function codePlugin() {
// Convert
editor.on('PreProcess', function (e) {
$('div.CodeMirrorContainer', e.node).
each((index, elem) => {
let $elem = $(elem);
codeMirrorContainerToPre($elem);
$('div.CodeMirrorContainer', e.node).each((index, elem) => {
codeMirrorContainerToPre(elem);
});
});
@ -217,10 +216,10 @@ function codePlugin() {
$('.CodeMirrorContainer').filter((index ,elem) => {
return typeof elem.querySelector('.CodeMirror').CodeMirror === 'undefined';
}).each((index, elem) => {
codeMirrorContainerToPre($(elem));
codeMirrorContainerToPre(elem);
});
let codeSamples = $('body > pre').filter((index, elem) => {
const codeSamples = $('body > pre').filter((index, elem) => {
return elem.contentEditable !== "false";
});
@ -341,7 +340,7 @@ function drawIoPlugin() {
});
editor.on('SetContent', function () {
let drawings = editor.$('body > div[drawio-diagram]');
const drawings = editor.$('body > div[drawio-diagram]');
if (!drawings.length) return;
editor.undoManager.transact(function () {
@ -379,6 +378,27 @@ function customHrPlugin() {
}
function listenForBookStackEditorEvents(editor) {
// Replace editor content
window.$events.listen('editor::replace', ({html}) => {
editor.setContent(html);
});
// Append editor content
window.$events.listen('editor::append', ({html}) => {
const content = editor.getContent() + html;
editor.setContent(content);
});
// Prepend editor content
window.$events.listen('editor::prepend', ({html}) => {
const content = html + editor.getContent();
editor.setContent(content);
});
}
class WysiwygEditor {
constructor(elem) {
@ -472,9 +492,10 @@ class WysiwygEditor {
if (type === 'file') {
window.EntitySelectorPopup.show(function(entity) {
let originalField = win.document.getElementById(field_name);
const originalField = win.document.getElementById(field_name);
originalField.value = entity.link;
$(originalField).closest('.mce-form').find('input').eq(2).val(entity.name);
const mceForm = originalField.closest('.mce-form');
mceForm.querySelectorAll('input')[2].value = entity.name;
});
}
@ -553,6 +574,10 @@ class WysiwygEditor {
editor.focus();
}
listenForBookStackEditorEvents(editor);
// TODO - Update to standardise across both editors
// Use events within listenForBookStackEditorEvents instead (Different event signature)
window.$events.listen('editor-html-update', html => {
editor.setContent(html);
editor.selection.select(editor.getBody(), true);
@ -583,6 +608,18 @@ class WysiwygEditor {
let dom = editor.dom,
rng = tinymce.dom.RangeUtils.getCaretRangeFromPoint(event.clientX, event.clientY, editor.getDoc());
// Template insertion
const templateId = event.dataTransfer.getData('bookstack/template');
if (templateId) {
event.preventDefault();
window.$http.get(`/templates/${templateId}`).then(resp => {
editor.selection.setRng(rng);
editor.undoManager.transact(function () {
editor.execCommand('mceInsertContent', false, resp.data.html);
});
});
}
// Don't allow anything to be dropped in a captioned image.
if (dom.getParent(rng.startContainer, '.mceTemp')) {
event.preventDefault();

View File

@ -1,6 +1,3 @@
// Global Polyfills
import "./services/dom-polyfills"
// Url retrieval function
window.baseUrl = function(path) {
let basePath = document.querySelector('meta[name="base-url"]').getAttribute('content');
@ -11,27 +8,24 @@ window.baseUrl = function(path) {
// Set events and http services on window
import Events from "./services/events"
import Http from "./services/http"
let httpInstance = Http();
import httpInstance from "./services/http"
const eventManager = new Events();
window.$http = httpInstance;
window.$events = new Events();
window.$events = eventManager;
// Translation setup
// Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
import Translations from "./services/translations"
let translator = new Translations(window.translations);
const translator = new Translations();
window.trans = translator.get.bind(translator);
window.trans_choice = translator.getPlural.bind(translator);
// Load in global UI helpers and libraries including jQuery
import "./services/global-ui"
// Set services on Vue
// Make services available to Vue instances
import Vue from "vue"
Vue.prototype.$http = httpInstance;
Vue.prototype.$events = window.$events;
Vue.prototype.$events = eventManager;
// Load vues and components
// Load Vues and components
import vues from "./vues/vues"
import components from "./components"
vues();

View File

@ -0,0 +1,106 @@
/**
* Fade out the given element.
* @param {Element} element
* @param {Number} animTime
* @param {Function|null} onComplete
*/
export function fadeOut(element, animTime = 400, onComplete = null) {
animateStyles(element, {
opacity: ['1', '0']
}, animTime, () => {
element.style.display = 'none';
if (onComplete) onComplete();
});
}
/**
* Hide the element by sliding the contents upwards.
* @param {Element} element
* @param {Number} animTime
*/
export function slideUp(element, animTime = 400) {
const currentHeight = element.getBoundingClientRect().height;
const computedStyles = getComputedStyle(element);
const currentPaddingTop = computedStyles.getPropertyValue('padding-top');
const currentPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
const animStyles = {
height: [`${currentHeight}px`, '0px'],
overflow: ['hidden', 'hidden'],
paddingTop: [currentPaddingTop, '0px'],
paddingBottom: [currentPaddingBottom, '0px'],
};
animateStyles(element, animStyles, animTime, () => {
element.style.display = 'none';
});
}
/**
* Show the given element by expanding the contents.
* @param {Element} element - Element to animate
* @param {Number} animTime - Animation time in ms
*/
export function slideDown(element, animTime = 400) {
element.style.display = 'block';
const targetHeight = element.getBoundingClientRect().height;
const computedStyles = getComputedStyle(element);
const targetPaddingTop = computedStyles.getPropertyValue('padding-top');
const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
const animStyles = {
height: ['0px', `${targetHeight}px`],
overflow: ['hidden', 'hidden'],
paddingTop: ['0px', targetPaddingTop],
paddingBottom: ['0px', targetPaddingBottom],
};
animateStyles(element, animStyles, animTime);
}
/**
* Used in the function below to store references of clean-up functions.
* Used to ensure only one transitionend function exists at any time.
* @type {WeakMap<object, any>}
*/
const animateStylesCleanupMap = new WeakMap();
/**
* Animate the css styles of an element using FLIP animation techniques.
* Styles must be an object where the keys are style properties, camelcase, and the values
* are an array of two items in the format [initialValue, finalValue]
* @param {Element} element
* @param {Object} styles
* @param {Number} animTime
* @param {Function} onComplete
*/
function animateStyles(element, styles, animTime = 400, onComplete = null) {
const styleNames = Object.keys(styles);
for (let style of styleNames) {
element.style[style] = styles[style][0];
}
const cleanup = () => {
for (let style of styleNames) {
element.style[style] = null;
}
element.style.transition = null;
element.removeEventListener('transitionend', cleanup);
if (onComplete) onComplete();
};
setTimeout(() => {
requestAnimationFrame(() => {
element.style.transition = `all ease-in-out ${animTime}ms`;
for (let style of styleNames) {
element.style[style] = styles[style][1];
}
if (animateStylesCleanupMap.has(element)) {
const oldCleanup = animateStylesCleanupMap.get(element);
element.removeEventListener('transitionend', oldCleanup);
}
element.addEventListener('transitionend', cleanup);
animateStylesCleanupMap.set(element, cleanup);
});
}, 10);
}

View File

@ -1,22 +0,0 @@
/**
* Polyfills for DOM API's
*/
// https://developer.mozilla.org/en-US/docs/Web/API/Element/matches
if (!Element.prototype.matches) {
Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
}
// https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Browser_compatibility
if (!Element.prototype.closest) {
Element.prototype.closest = function (s) {
var el = this;
var ancestor = this;
if (!document.documentElement.contains(el)) return null;
do {
if (ancestor.matches(s)) return ancestor;
ancestor = ancestor.parentElement;
} while (ancestor !== null);
return null;
};
}

View File

@ -0,0 +1,75 @@
/**
* Run the given callback against each element that matches the given selector.
* @param {String} selector
* @param {Function<Element>} callback
*/
export function forEach(selector, callback) {
const elements = document.querySelectorAll(selector);
for (let element of elements) {
callback(element);
}
}
/**
* Helper to listen to multiple DOM events
* @param {Element} listenerElement
* @param {Array<String>} events
* @param {Function<Event>} callback
*/
export function onEvents(listenerElement, events, callback) {
for (let eventName of events) {
listenerElement.addEventListener(eventName, callback);
}
}
/**
* Helper to run an action when an element is selected.
* A "select" is made to be accessible, So can be a click, space-press or enter-press.
* @param listenerElement
* @param callback
*/
export function onSelect(listenerElement, callback) {
listenerElement.addEventListener('click', callback);
listenerElement.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
callback(event);
}
});
}
/**
* Set a listener on an element for an event emitted by a child
* matching the given childSelector param.
* Used in a similar fashion to jQuery's $('listener').on('eventName', 'childSelector', callback)
* @param {Element} listenerElement
* @param {String} childSelector
* @param {String} eventName
* @param {Function} callback
*/
export function onChildEvent(listenerElement, childSelector, eventName, callback) {
listenerElement.addEventListener(eventName, function(event) {
const matchingChild = event.target.closest(childSelector);
if (matchingChild) {
callback.call(matchingChild, event, matchingChild);
}
});
}
/**
* Look for elements that match the given selector and contain the given text.
* Is case insensitive and returns the first result or null if nothing is found.
* @param {String} selector
* @param {String} text
* @returns {Element}
*/
export function findText(selector, text) {
const elements = document.querySelectorAll(selector);
text = text.toLowerCase();
for (let element of elements) {
if (element.textContent.toLowerCase().includes(text)) {
return element;
}
}
return null;
}

View File

@ -1,58 +0,0 @@
// Global jQuery Config & Extensions
import jQuery from "jquery"
window.jQuery = window.$ = jQuery;
/**
* Scroll the view to a specific element.
* @param {HTMLElement} element
*/
window.scrollToElement = function(element) {
if (!element) return;
let offset = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;
let top = element.getBoundingClientRect().top + offset;
$('html, body').animate({
scrollTop: top - 60 // Adjust to change final scroll position top margin
}, 300);
};
/**
* Scroll and highlight an element.
* @param {HTMLElement} element
*/
window.scrollAndHighlight = function(element) {
if (!element) return;
window.scrollToElement(element);
let color = document.getElementById('custom-styles').getAttribute('data-color-light');
let initColor = window.getComputedStyle(element).getPropertyValue('background-color');
element.style.backgroundColor = color;
setTimeout(() => {
element.classList.add('selectFade');
element.style.backgroundColor = initColor;
}, 10);
setTimeout(() => {
element.classList.remove('selectFade');
element.style.backgroundColor = '';
}, 3000);
};
// Smooth scrolling
jQuery.fn.smoothScrollTo = function () {
if (this.length === 0) return;
window.scrollToElement(this[0]);
return this;
};
// Making contains text expression not worry about casing
jQuery.expr[":"].contains = $.expr.createPseudo(function (arg) {
return function (elem) {
return $(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0;
};
});
// Detect IE for css
if(navigator.userAgent.indexOf('MSIE')!==-1
|| navigator.appVersion.indexOf('Trident/') > 0
|| navigator.userAgent.indexOf('Safari') !== -1){
document.body.classList.add('flexbox-support');
}

View File

@ -1,21 +1,146 @@
import axios from "axios"
function instance() {
let axiosInstance = axios.create({
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=token]').getAttribute('content'),
'baseURL': window.baseUrl('')
}
/**
* Perform a HTTP GET request.
* Can easily pass query parameters as the second parameter.
* @param {String} url
* @param {Object} params
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function get(url, params = {}) {
return request(url, {
method: 'GET',
params,
});
axiosInstance.interceptors.request.use(resp => {
return resp;
}, err => {
if (typeof err.response === "undefined" || typeof err.response.data === "undefined") return Promise.reject(err);
if (typeof err.response.data.error !== "undefined") window.$events.emit('error', err.response.data.error);
if (typeof err.response.data.message !== "undefined") window.$events.emit('error', err.response.data.message);
});
return axiosInstance;
}
/**
* Perform a HTTP POST request.
* @param {String} url
* @param {Object} data
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function post(url, data = null) {
return dataRequest('POST', url, data);
}
export default instance;
/**
* Perform a HTTP PUT request.
* @param {String} url
* @param {Object} data
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function put(url, data = null) {
return dataRequest('PUT', url, data);
}
/**
* Perform a HTTP PATCH request.
* @param {String} url
* @param {Object} data
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function patch(url, data = null) {
return dataRequest('PATCH', url, data);
}
/**
* Perform a HTTP DELETE request.
* @param {String} url
* @param {Object} data
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function performDelete(url, data = null) {
return dataRequest('DELETE', url, data);
}
/**
* Perform a HTTP request to the back-end that includes data in the body.
* Parses the body to JSON if an object, setting the correct headers.
* @param {String} method
* @param {String} url
* @param {Object} data
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function dataRequest(method, url, data = null) {
const options = {
method: method,
body: data,
};
if (typeof data === 'object') {
options.headers = {'Content-Type': 'application/json'};
options.body = JSON.stringify(data);
}
return request(url, options)
}
/**
* Create a new HTTP request, setting the required CSRF information
* to communicate with the back-end. Parses & formats the response.
* @param {String} url
* @param {Object} options
* @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>}
*/
async function request(url, options = {}) {
if (!url.startsWith('http')) {
url = window.baseUrl(url);
}
if (options.params) {
const urlObj = new URL(url);
for (let paramName of Object.keys(options.params)) {
const value = options.params[paramName];
if (typeof value !== 'undefined' && value !== null) {
urlObj.searchParams.set(paramName, value);
}
}
url = urlObj.toString();
}
const csrfToken = document.querySelector('meta[name=token]').getAttribute('content');
options = Object.assign({}, options, {
'credentials': 'same-origin',
});
options.headers = Object.assign({}, options.headers || {}, {
'baseURL': window.baseUrl(''),
'X-CSRF-TOKEN': csrfToken,
});
const response = await fetch(url, options);
const content = await getResponseContent(response);
return {
data: content,
headers: response.headers,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
url: response.url,
original: response,
}
}
/**
* Get the content from a fetch response.
* Checks the content-type header to determine the format.
* @param response
* @returns {Promise<Object|String>}
*/
async function getResponseContent(response) {
const responseContentType = response.headers.get('Content-Type');
const subType = responseContentType.split('/').pop();
if (subType === 'javascript' || subType === 'json') {
return await response.json();
}
return await response.text();
}
export default {
get: get,
post: post,
put: put,
patch: patch,
delete: performDelete,
};

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