Compare commits

...

96 Commits

Author SHA1 Message Date
Dan Brown
cdfda508d8 Updated assets for release v0.14 2017-01-22 12:36:10 +00:00
Dan Brown
da941e584f Merge branch 'master' into release ready for v0.14 2017-01-22 12:31:27 +00:00
Dan Brown
380f0f2042 Prevented a missing avatar from crashing the application 2017-01-22 12:19:50 +00:00
Dan Brown
33d4844f17 Fixed role 'manage own permissions' permission 2017-01-22 12:16:02 +00:00
Dan Brown
989de47f22 Fixed hidden book children for admins on upgrade 2017-01-22 12:02:30 +00:00
Dan Brown
8f19231ed5 Added options to use database cache & sessions 2017-01-21 16:39:50 +00:00
Dan Brown
5c60f27a7d Merge branch 'page_transclusion'
Closes #123
2017-01-21 16:17:33 +00:00
Dan Brown
2d4034f3b7 Added transclusion tests and fixed other tests 2017-01-21 16:16:27 +00:00
Dan Brown
56d58ad8e5 Updated pointer to be able to show includes
Also fixed pointer copying on flash-blocked browsers
2017-01-21 14:58:03 +00:00
Dan Brown
a4f6bc63f0 Updated page include logic to use blade-style tags
It will also snippets of a page if and id is provided in a tag
2017-01-21 13:53:00 +00:00
Dan Brown
26da81a3b0 Added pt_BR to language dropdown and renamed folder 2017-01-18 20:04:29 +00:00
Dan Brown
ec9410b510 Merge pull request #279 from NakaharaL/master
Brazilian Portuguese Localization
2017-01-18 19:48:53 +00:00
NakaharaL
a0035e4de2 Brazilian Portuguese Localization
Translated files for messages from English to Brazilian Portuguese
2017-01-18 08:29:18 -03:00
Dan Brown
e4e3b25c22 Started page transclusion system 2017-01-16 21:24:48 +00:00
Dan Brown
d8c5f72258 Updated issue template and added TinyMCE autolinking
Closes #267
2017-01-16 19:33:29 +00:00
Dan Brown
d67ad47b2c Merge pull request #274 from GeneralMediaCH/feature/translations
French translations
2017-01-16 19:23:30 +00:00
Dan Brown
b08e49b59d Updated CI to not use github token 2017-01-16 19:14:58 +00:00
sirgix
d3dc73ca06 remove trailing spaces 2017-01-16 15:34:34 +01:00
sirgix
d5ea15e6dd fix typo 2017-01-16 14:51:45 +01:00
sirgix
5d45286646 french translation 2017-01-16 14:51:33 +01:00
Dan Brown
dabf149411 Added user setting system and added user-lang option
Supports #115
2017-01-15 16:27:24 +00:00
Dan Brown
ee5ded6e1e Updated permission bookChildrenQuery to use QueryBuilder 2017-01-15 15:00:29 +00:00
Dan Brown
598b07b53d Updated LDAP to allow protocol to be specified
As per details by fredericmohr in #236
2017-01-14 17:55:09 +00:00
Dan Brown
e211f31370 Merge branch 'patch-2' of git://github.com/fredericmohr/BookStack into fredericmohr-patch-2 2017-01-14 17:30:49 +00:00
Dan Brown
0bcf608e0b Fixed page navigation with special chars in id
Also made so it smooth-scrolls and uses app theme color.
Fixes #254
2017-01-14 16:36:12 +00:00
Dan Brown
969ad8911c Updated page nav to hide when empty 2017-01-14 15:34:52 +00:00
Dan Brown
581c382f65 Fixed image delete permission issue
Also fixed missing translations and wrote tests to cover issue.
Fixes #258
2017-01-08 19:19:30 +00:00
Dan Brown
f7f86ff821 Merge branch 'master' of github.com:BookStackApp/BookStack 2017-01-08 18:43:33 +00:00
Dan Brown
212cd710aa Fixed default empty app settings effecting blank app color
Fixes #265
2017-01-08 18:42:46 +00:00
Dan Brown
33c44d3c0f Merge pull request #263 from Abijeet/typo-fix
Typo fix
2017-01-08 13:55:44 +00:00
Dan Brown
0faa130cfd Fixed offset code blocks when editing in markdown.
Fixes #264
2017-01-08 13:31:53 +00:00
Abijeet Patro
af76580b98 Fixes typo causing the message not to be displayed 2017-01-03 22:18:13 +05:30
Abijeet Patro
b526d172d6 Merge pull request #5 from BookStackApp/master
Getting the latest from Bookstack to push the typo-fix
2017-01-03 22:16:39 +05:30
Dan Brown
f2917fc462 Added tests to cover social login actions
Closes #244
2017-01-02 14:56:58 +00:00
Dan Brown
7c8c4c2a05 Normalised page nav header inset when only small headers are used 2017-01-02 12:13:03 +00:00
Dan Brown
3595ac2551 Merge pull request #262 from BookStackApp/entity_repo_refactor
Entity repo refactor
2017-01-02 11:12:44 +00:00
Dan Brown
8453191dfb Finished refactor of entity repos
Removed entity-specific repos and standardised
the majority of repo calls to be applicable to
all entity types
2017-01-02 11:07:27 +00:00
Dan Brown
65796cfc7b Rewrote book children query 2017-01-01 21:21:11 +00:00
Dan Brown
bab27462ab Fixed issue where default user was over-fetched 2017-01-01 17:33:06 +00:00
Dan Brown
241278226f Refactored search and slug repo components 2017-01-01 16:57:47 +00:00
Dan Brown
7f9de2c8ab Started refactor to merge entity repos 2017-01-01 16:05:44 +00:00
Dan Brown
f91f33c236 Updated readme attribution and npm scripts 2017-01-01 12:51:23 +00:00
Dan Brown
3f0ef57d31 Added wkhtmltopdf support and done some style tweaks
Closes #248
2017-01-01 12:20:30 +00:00
Dan Brown
0eb90cb3b6 Fixed carbon locale setting 2016-12-31 14:38:04 +00:00
Dan Brown
9fe158b78a Merge pull request #255 from olexus/master
Add Carbon localization support
2016-12-31 14:36:01 +00:00
Dan Brown
b14222dabd Merge pull request #234 from BookStackApp/translations
Setup for translations
2016-12-31 14:33:25 +00:00
Dan Brown
a24f3d7d47 Merge branch 'master' into translations 2016-12-31 14:32:52 +00:00
Dan Brown
c9700e38e2 Created solution for JS translations
Also tidied up existing components and JS
2016-12-31 14:27:40 +00:00
Dan Brown
05316c90ba converted image picker to blade-based component
Also updated some other JS translations
2016-12-24 15:21:19 +00:00
Dan Brown
242dc21876 Converted toggle switch into a blade/jquery template 2016-12-22 19:41:32 +00:00
Dan Brown
08c4b9ac7c Standardised JS vars and imports/exports 2016-12-19 19:16:31 +00:00
olexus
f30f4579e9 Add Carbon localization support 2016-12-19 11:05:06 +03:00
Dan Brown
573357a08c Extracted text from logic files 2016-12-04 16:51:39 +00:00
Dan Brown
0775cd09a1 Extracted text for remaining views 2016-12-04 14:08:04 +00:00
Dan Brown
96075dee7b Extracted text from page views & standardised breadcrumbs 2016-12-03 18:35:40 +00:00
Dan Brown
066adf3cea Moved text for errors and form views.
Updated 404 page with additional links
2016-12-03 13:31:54 +00:00
Dan Brown
65874d7b96 Updated assets for release v0.13.1 2016-11-27 19:42:33 +00:00
Dan Brown
ac9b8f405c Merge fixes from master for release v0.13.1 2016-11-27 19:41:12 +00:00
Dan Brown
286f9b0c7d Moved page tags to sidebar
Fixed #238
2016-11-27 19:37:57 +00:00
Dan Brown
c403d05755 Fixed social login routes
Fixes #239
2016-11-27 19:11:15 +00:00
Dan Brown
57dc53ceff Extracted text from book & chapter views 2016-11-17 13:33:07 +00:00
Frederic Mohr
340d3f833b Update Ldap.php
This is a very crude workaround, a better solution is explained in the comments I added.
2016-11-15 11:10:12 +01:00
Dan Brown
694a9459c1 Moved text from auth views into lang files 2016-11-13 16:34:28 +00:00
Dan Brown
8d1419a12e Update assets and version for release v0.13 2016-11-13 12:29:52 +00:00
Dan Brown
04f7a7d301 Merge branch 'master' into release 2016-11-13 12:26:56 +00:00
Dan Brown
0fb1fc87c8 Enabled utf8 slugs
Prevents slug change when using only non-ascii chars
Allows use of more localised urls.

Closes #233
2016-11-12 17:16:52 +00:00
Dan Brown
d3c7aada89 Fixed attachments on draft pages 2016-11-12 14:21:54 +00:00
Dan Brown
e639600ba5 Renamed files to attachments 2016-11-12 14:12:26 +00:00
Dan Brown
d439fb459b Made language configurable via .env file 2016-11-12 13:30:00 +00:00
Dan Brown
9d8a176182 Merge branch 'localization-de' of git://github.com/robertlandes/BookStack into robertlandes-localization-de 2016-11-12 13:27:37 +00:00
Dan Brown
600055bc73 Fixed tag searches and added tag search regression test
Fixes #222
2016-11-12 13:21:16 +00:00
Robert Landes
e691b401c8 Added initial translation into German (formal)
This translation is based on the current english language files. It is a
transaltion into German (formal) language.  A language selector on the
Settings page would be a nice addition.
2016-11-12 14:16:07 +01:00
Dan Brown
672b15d36c Fixed attachment base-url usage and non-existant images
Images now self-delete if the original file does not exist.
Prevents simply getting non-fixable errors.

Also cleaned some JS.
2016-11-12 12:41:34 +00:00
Dan Brown
ac80723058 Merge fixes from branch 'v0.12' 2016-11-12 11:40:54 +00:00
Dan Brown
ab468bac3c Updated build and versioning system
Added versioning file instead of using git tags
(Step towards removing git as a dependancy in the future)

Updated gulpfile to fit with verisioning system and cleaned
up node dependancies.

Fixes #108
2016-10-30 17:44:00 +00:00
Dan Brown
a6c6c6e300 Merge pull request #205 from ssddanbrown/attachments
Implementation of File Attachments
2016-10-23 18:01:20 +01:00
Dan Brown
30458405ce Page Attachments - Improved UI, Now initially complete
Closes #62
2016-10-23 17:55:48 +01:00
Dan Brown
91220239e5 Added in attachment tests 2016-10-23 15:25:04 +01:00
Dan Brown
7ee695d74a File upload deletion complete & added extension handling
Also fixed issue with file editing on JS side
2016-10-23 13:36:45 +01:00
Dan Brown
867fc8be64 Added basic attachment editing functionality 2016-10-11 20:39:11 +01:00
Dan Brown
89509b487a Added attachment creation from link/name 2016-10-10 21:13:18 +01:00
Dan Brown
ac0b29fb6d Added view, deletion and permissions for files 2016-10-10 20:30:27 +01:00
Dan Brown
673c74ddfc Started work on attachments
Created base models and started user-facing controls.
2016-10-09 18:58:22 +01:00
Dan Brown
3b7d223b0c Updated and added tests for new default user system
Closes #138
2016-09-29 17:07:58 +01:00
Dan Brown
b662670efc Prevented guest users creating draft pages. 2016-09-29 15:56:57 +01:00
Dan Brown
771626b6ec Started work on making the public role/user configurable
Create a new 'public' guest user and made the public
role visible on role setting screens.
2016-09-29 12:43:46 +01:00
Dan Brown
f15cc5bdfa Separated revision preview and diff & fixed chosen diff html
Closes #8
2016-09-29 10:10:46 +01:00
Dan Brown
fff5bbcee4 Merge branch 'diff' of git://github.com/younes0/BookStack into younes0-diff 2016-09-29 09:32:40 +01:00
Dan Brown
42d8e9e5bd Improved numeric term search capabilities
Prevented a quoted term also being added to fuzzy searches
and also added check to see if the term is numeric to check if
an exact match is required.

Closes #200
2016-09-29 09:13:15 +01:00
Dan Brown
da10c50945 Added app name header display setting + extracted setting text
Closes #194
2016-09-22 18:53:22 +01:00
Dan Brown
24523cf31d Added page autosave request failure notification
Closes #192
2016-09-18 15:10:27 +01:00
Dan Brown
1d681e53e4 Added page navigation and tweaked header styles
Changed header selection in editor to be more descriptive and
to provide a wider range of styles.

Closes #68
2016-09-18 14:49:36 +01:00
Dan Brown
e0235fda8b Made registration gravatar/email requests fail gracefully
* Extracted any email confirmation text into langs.
* Added new notification on confirmation email send fail.

Closes #187
2016-09-17 21:33:55 +01:00
Dan Brown
9dc9724e15 Laravel 5.3 upgrade (#189)
* Started move to laravel 5.3

* Started updating login & registration flows for laravel 5.3 update

* Updated app emails to notification system

* Fixed registations bugs and removed email confirmation model

* Fixed large portion of laravel post-upgrade issues

* Fixed and tested LDAP process
2016-09-17 18:22:04 +01:00
Younès EL BIACHE
c279c6e2af replace GPL diff lib with MIT lib
replace gpl lib with mit lib
2016-07-10 12:01:05 +02:00
Younès EL BIACHE
9537e2ae95 html diff in revision view 2016-07-07 19:54:40 +02:00
249 changed files with 9447 additions and 4384 deletions

View File

@@ -1,11 +1,13 @@
### For Feature Requests
Desired Feature:
### For Bug Reports
PHP Version:
MySQL Version:
* BookStack Version:
* PHP Version:
* MySQL Version:
Expected Behavior:
##### Expected Behavior
Actual Behavior:
##### Actual Behavior

5
.gitignore vendored
View File

@@ -10,4 +10,7 @@ Homestead.yaml
/public/bower
/storage/images
_ide_helper.php
/storage/debugbar
/storage/debugbar
.phpstorm.meta.php
yarn.lock
/bin

View File

@@ -17,9 +17,7 @@ addons:
before_script:
- mysql -u root -e 'create database `bookstack-test`;'
- composer config -g github-oauth.github.com $GITHUB_ACCESS_TOKEN
- phpenv config-rm xdebug.ini
- composer self-update
- composer dump-autoload --no-interaction
- composer install --prefer-dist --no-interaction
- php artisan clear-compiled -n

36
app/Attachment.php Normal file
View File

@@ -0,0 +1,36 @@
<?php namespace BookStack;
class Attachment extends Ownable
{
protected $fillable = ['name', 'order'];
/**
* Get the downloadable file name for this upload.
* @return mixed|string
*/
public function getFileName()
{
if (str_contains($this->name, '.')) return $this->name;
return $this->name . '.' . $this->extension;
}
/**
* Get the page this file was uploaded to.
* @return Page
*/
public function page()
{
return $this->belongsTo(Page::class, 'uploaded_to');
}
/**
* Get the url of this file.
* @return string
*/
public function getUrl()
{
return baseUrl('/attachments/' . $this->id);
}
}

View File

@@ -13,9 +13,9 @@ class Book extends Entity
public function getUrl($path = false)
{
if ($path !== false) {
return baseUrl('/books/' . $this->slug . '/' . trim($path, '/'));
return baseUrl('/books/' . urlencode($this->slug) . '/' . trim($path, '/'));
}
return baseUrl('/books/' . $this->slug);
return baseUrl('/books/' . urlencode($this->slug));
}
/*

View File

@@ -5,6 +5,8 @@ class Chapter extends Entity
{
protected $fillable = ['name', 'description', 'priority', 'book_id'];
protected $with = ['book'];
/**
* Get the book this chapter is within.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
@@ -16,11 +18,12 @@ class Chapter extends Entity
/**
* Get the pages that this chapter contains.
* @param string $dir
* @return mixed
*/
public function pages()
public function pages($dir = 'ASC')
{
return $this->hasMany(Page::class)->orderBy('priority', 'ASC');
return $this->hasMany(Page::class)->orderBy('priority', $dir);
}
/**
@@ -32,9 +35,9 @@ class Chapter extends Entity
{
$bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
if ($path !== false) {
return baseUrl('/books/' . $bookSlug. '/chapter/' . $this->slug . '/' . trim($path, '/'));
return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug) . '/' . trim($path, '/'));
}
return baseUrl('/books/' . $bookSlug. '/chapter/' . $this->slug);
return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug));
}
/**

View File

@@ -1,16 +0,0 @@
<?php namespace BookStack;
class EmailConfirmation extends Model
{
protected $fillable = ['user_id', 'token'];
/**
* Get the user that this confirmation is attached to.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@@ -4,6 +4,8 @@
class Entity extends Ownable
{
protected $fieldsToSearch = ['name', 'description'];
/**
* Compares this entity to another given entity.
* Matches by comparing class and id.
@@ -157,42 +159,46 @@ class Entity extends Ownable
* @param string[] array $wheres
* @return mixed
*/
public function fullTextSearchQuery($fieldsToSearch, $terms, $wheres = [])
public function fullTextSearchQuery($terms, $wheres = [])
{
$exactTerms = [];
$fuzzyTerms = [];
$search = static::newQuery();
foreach ($terms as $key => $term) {
$safeTerm = htmlentities($term, ENT_QUOTES);
$safeTerm = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $safeTerm);
if (preg_match('/&quot;.*?&quot;/', $safeTerm) || is_numeric($safeTerm)) {
$safeTerm = preg_replace('/^"(.*?)"$/', '$1', $term);
$exactTerms[] = '%' . $safeTerm . '%';
$term = htmlentities($term, ENT_QUOTES);
$term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
if (preg_match('/&quot;.*?&quot;/', $term) || is_numeric($term)) {
$term = str_replace('&quot;', '', $term);
$exactTerms[] = '%' . $term . '%';
} else {
$safeTerm = '' . $safeTerm . '*';
if (trim($safeTerm) !== '*') $fuzzyTerms[] = $safeTerm;
$term = '' . $term . '*';
if ($term !== '*') $fuzzyTerms[] = $term;
}
}
$isFuzzy = count($exactTerms) === 0 || count($fuzzyTerms) > 0;
$isFuzzy = count($exactTerms) === 0 && count($fuzzyTerms) > 0;
// Perform fulltext search if relevant terms exist.
if ($isFuzzy) {
$termString = implode(' ', $fuzzyTerms);
$fields = implode(',', $fieldsToSearch);
$fields = implode(',', $this->fieldsToSearch);
$search = $search->selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]);
$search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]);
}
// Ensure at least one exact term matches if in search
if (count($exactTerms) > 0) {
$search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) {
$search = $search->where(function ($query) use ($exactTerms) {
foreach ($exactTerms as $exactTerm) {
foreach ($fieldsToSearch as $field) {
foreach ($this->fieldsToSearch as $field) {
$query->orWhere($field, 'like', $exactTerm);
}
}
});
}
$orderBy = $isFuzzy ? 'title_relevance' : 'updated_at';
// Add additional where terms

View File

@@ -1,8 +0,0 @@
<?php
namespace BookStack\Events;
abstract class Event
{
//
}

View File

@@ -0,0 +1,4 @@
<?php namespace BookStack\Exceptions;
class FileUploadException extends PrettyException {}

View File

@@ -87,4 +87,20 @@ class Handler extends ExceptionHandler
} while ($e = $e->getPrevious());
return $message;
}
/**
* Convert an authentication exception into an unauthenticated response.
*
* @param \Illuminate\Http\Request $request
* @param \Illuminate\Auth\AuthenticationException $exception
* @return \Illuminate\Http\Response
*/
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->expectsJson()) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}
return redirect()->guest('login');
}
}

View File

@@ -0,0 +1,215 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Exceptions\FileUploadException;
use BookStack\Attachment;
use BookStack\Repos\EntityRepo;
use BookStack\Services\AttachmentService;
use Illuminate\Http\Request;
class AttachmentController extends Controller
{
protected $attachmentService;
protected $attachment;
protected $entityRepo;
/**
* AttachmentController constructor.
* @param AttachmentService $attachmentService
* @param Attachment $attachment
* @param EntityRepo $entityRepo
*/
public function __construct(AttachmentService $attachmentService, Attachment $attachment, EntityRepo $entityRepo)
{
$this->attachmentService = $attachmentService;
$this->attachment = $attachment;
$this->entityRepo = $entityRepo;
parent::__construct();
}
/**
* Endpoint at which attachments are uploaded to.
* @param Request $request
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
*/
public function upload(Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'file' => 'required|file'
]);
$pageId = $request->get('uploaded_to');
$page = $this->entityRepo->getById('page', $pageId, true);
$this->checkPermission('attachment-create-all');
$this->checkOwnablePermission('page-update', $page);
$uploadedFile = $request->file('file');
try {
$attachment = $this->attachmentService->saveNewUpload($uploadedFile, $pageId);
} catch (FileUploadException $e) {
return response($e->getMessage(), 500);
}
return response()->json($attachment);
}
/**
* Update an uploaded attachment.
* @param int $attachmentId
* @param Request $request
* @return mixed
*/
public function uploadUpdate($attachmentId, Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'file' => 'required|file'
]);
$pageId = $request->get('uploaded_to');
$page = $this->entityRepo->getById('page', $pageId, true);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('attachment-create', $attachment);
if (intval($pageId) !== intval($attachment->uploaded_to)) {
return $this->jsonError(trans('errors.attachment_page_mismatch'));
}
$uploadedFile = $request->file('file');
try {
$attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $attachment);
} catch (FileUploadException $e) {
return response($e->getMessage(), 500);
}
return response()->json($attachment);
}
/**
* Update the details of an existing file.
* @param $attachmentId
* @param Request $request
* @return Attachment|mixed
*/
public function update($attachmentId, Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'name' => 'required|string|min:1|max:255',
'link' => 'url|min:1|max:255'
]);
$pageId = $request->get('uploaded_to');
$page = $this->entityRepo->getById('page', $pageId, true);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('attachment-create', $attachment);
if (intval($pageId) !== intval($attachment->uploaded_to)) {
return $this->jsonError(trans('errors.attachment_page_mismatch'));
}
$attachment = $this->attachmentService->updateFile($attachment, $request->all());
return response()->json($attachment);
}
/**
* Attach a link to a page.
* @param Request $request
* @return mixed
*/
public function attachLink(Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'name' => 'required|string|min:1|max:255',
'link' => 'required|url|min:1|max:255'
]);
$pageId = $request->get('uploaded_to');
$page = $this->entityRepo->getById('page', $pageId, true);
$this->checkPermission('attachment-create-all');
$this->checkOwnablePermission('page-update', $page);
$attachmentName = $request->get('name');
$link = $request->get('link');
$attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
return response()->json($attachment);
}
/**
* Get the attachments for a specific page.
* @param $pageId
* @return mixed
*/
public function listForPage($pageId)
{
$page = $this->entityRepo->getById('page', $pageId, true);
$this->checkOwnablePermission('page-view', $page);
return response()->json($page->attachments);
}
/**
* Update the attachment sorting.
* @param $pageId
* @param Request $request
* @return mixed
*/
public function sortForPage($pageId, Request $request)
{
$this->validate($request, [
'files' => 'required|array',
'files.*.id' => 'required|integer',
]);
$page = $this->entityRepo->getById('page', $pageId);
$this->checkOwnablePermission('page-update', $page);
$attachments = $request->get('files');
$this->attachmentService->updateFileOrderWithinPage($attachments, $pageId);
return response()->json(['message' => trans('entities.attachments_order_updated')]);
}
/**
* Get an attachment from storage.
* @param $attachmentId
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Symfony\Component\HttpFoundation\Response
*/
public function get($attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
$page = $this->entityRepo->getById('page', $attachment->uploaded_to);
$this->checkOwnablePermission('page-view', $page);
if ($attachment->external) {
return redirect($attachment->path);
}
$attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
return response($attachmentContents, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'. $attachment->getFileName() .'"'
]);
}
/**
* Delete a specific attachment in the system.
* @param $attachmentId
* @return mixed
*/
public function delete($attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('attachment-delete', $attachment);
$this->attachmentService->deleteFile($attachment);
return response()->json(['message' => trans('entities.attachments_deleted')]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace BookStack\Http\Controllers\Auth;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Http\Request;
use Password;
class ForgotPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset emails and
| includes a trait which assists in sending these notifications from
| your application to your users. Feel free to explore this trait.
|
*/
use SendsPasswordResetEmails;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
parent::__construct();
}
/**
* Send a reset link to the given user.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse
*/
public function sendResetLinkEmail(Request $request)
{
$this->validate($request, ['email' => 'required|email']);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$response = $this->broker()->sendResetLink(
$request->only('email')
);
if ($response === Password::RESET_LINK_SENT) {
$message = trans('auth.reset_password_sent_success', ['email' => $request->get('email')]);
session()->flash('success', $message);
return back()->with('status', trans($response));
}
// If an error was returned by the password broker, we will get this message
// translated so we can notify a user of the problem. We'll redirect back
// to where the users came from so they can attempt this process again.
return back()->withErrors(
['email' => trans($response)]
);
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace BookStack\Http\Controllers\Auth;
use BookStack\Exceptions\AuthException;
use BookStack\Http\Controllers\Controller;
use BookStack\Repos\UserRepo;
use BookStack\Services\SocialAuthService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
class LoginController extends Controller
{
/*
|--------------------------------------------------------------------------
| Login Controller
|--------------------------------------------------------------------------
|
| This controller handles authenticating users for the application and
| redirecting them to your home screen. The controller uses a trait
| to conveniently provide its functionality to your applications.
|
*/
use AuthenticatesUsers;
/**
* Where to redirect users after login.
*
* @var string
*/
protected $redirectTo = '/';
protected $redirectPath = '/';
protected $redirectAfterLogout = '/login';
protected $socialAuthService;
protected $userRepo;
/**
* Create a new controller instance.
*
* @param SocialAuthService $socialAuthService
* @param UserRepo $userRepo
*/
public function __construct(SocialAuthService $socialAuthService, UserRepo $userRepo)
{
$this->middleware('guest', ['only' => ['getLogin', 'postLogin']]);
$this->socialAuthService = $socialAuthService;
$this->userRepo = $userRepo;
$this->redirectPath = baseUrl('/');
$this->redirectAfterLogout = baseUrl('/login');
parent::__construct();
}
public function username()
{
return config('auth.method') === 'standard' ? 'email' : 'username';
}
/**
* Overrides the action when a user is authenticated.
* If the user authenticated but does not exist in the user table we create them.
* @param Request $request
* @param Authenticatable $user
* @return \Illuminate\Http\RedirectResponse
* @throws AuthException
*/
protected function authenticated(Request $request, Authenticatable $user)
{
// Explicitly log them out for now if they do no exist.
if (!$user->exists) auth()->logout($user);
if (!$user->exists && $user->email === null && !$request->has('email')) {
$request->flash();
session()->flash('request-email', true);
return redirect('/login');
}
if (!$user->exists && $user->email === null && $request->has('email')) {
$user->email = $request->get('email');
}
if (!$user->exists) {
// Check for users with same email already
$alreadyUser = $user->newQuery()->where('email', '=', $user->email)->count() > 0;
if ($alreadyUser) {
throw new AuthException(trans('errors.error_user_exists_different_creds', ['email' => $user->email]));
}
$user->save();
$this->userRepo->attachDefaultRole($user);
auth()->login($user);
}
$path = session()->pull('url.intended', '/');
$path = baseUrl($path, true);
return redirect($path);
}
/**
* Show the application login form.
* @return \Illuminate\Http\Response
*/
public function getLogin()
{
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$authMethod = config('auth.method');
return view('auth/login', ['socialDrivers' => $socialDrivers, 'authMethod' => $authMethod]);
}
/**
* Redirect to the relevant social site.
* @param $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
*/
public function getSocialLogin($socialDriver)
{
session()->put('social-callback', 'login');
return $this->socialAuthService->startLogIn($socialDriver);
}
}

View File

@@ -1,76 +0,0 @@
<?php
namespace BookStack\Http\Controllers\Auth;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Http\Request;
use Password;
class PasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset requests
| and uses a simple trait to include this behavior. You're free to
| explore this trait and override any methods you wish to tweak.
|
*/
use ResetsPasswords;
protected $redirectTo = '/';
/**
* Create a new password controller instance.
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* Send a reset link to the given user.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function sendResetLinkEmail(Request $request)
{
$this->validate($request, ['email' => 'required|email']);
$broker = $this->getBroker();
$response = Password::broker($broker)->sendResetLink(
$request->only('email'), $this->resetEmailBuilder()
);
switch ($response) {
case Password::RESET_LINK_SENT:
$message = 'A password reset link has been sent to ' . $request->get('email') . '.';
session()->flash('success', $message);
return $this->getSendResetLinkEmailSuccessResponse($response);
case Password::INVALID_USER:
default:
return $this->getSendResetLinkEmailFailureResponse($response);
}
}
/**
* Get the response for after a successful password reset.
*
* @param string $response
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function getResetSuccessResponse($response)
{
$message = 'Your password has been successfully reset.';
session()->flash('success', $message);
return redirect($this->redirectPath())->with('status', trans($response));
}
}

View File

@@ -1,62 +1,69 @@
<?php namespace BookStack\Http\Controllers\Auth;
<?php
use BookStack\Exceptions\AuthException;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
namespace BookStack\Http\Controllers\Auth;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Exceptions\SocialSignInException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Repos\UserRepo;
use BookStack\Services\EmailConfirmationService;
use BookStack\Services\SocialAuthService;
use BookStack\SocialAccount;
use BookStack\User;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Validator;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ThrottlesLogins;
use Illuminate\Foundation\Auth\AuthenticatesAndRegistersUsers;
use Illuminate\Foundation\Auth\RegistersUsers;
class AuthController extends Controller
class RegisterController extends Controller
{
/*
|--------------------------------------------------------------------------
| Registration & Login Controller
| Register Controller
|--------------------------------------------------------------------------
|
| This controller handles the registration of new users, as well as the
| authentication of existing users. By default, this controller uses
| a simple trait to add these behaviors. Why don't you explore it?
| This controller handles the registration of new users as well as their
| validation and creation. By default this controller uses a trait to
| provide this functionality without requiring any additional code.
|
*/
use AuthenticatesAndRegistersUsers, ThrottlesLogins;
protected $redirectPath = '/';
protected $redirectAfterLogout = '/login';
protected $username = 'email';
use RegistersUsers;
protected $socialAuthService;
protected $emailConfirmationService;
protected $userRepo;
/**
* Create a new authentication controller instance.
* Where to redirect users after login / registration.
*
* @var string
*/
protected $redirectTo = '/';
protected $redirectPath = '/';
/**
* Create a new controller instance.
*
* @param SocialAuthService $socialAuthService
* @param EmailConfirmationService $emailConfirmationService
* @param UserRepo $userRepo
*/
public function __construct(SocialAuthService $socialAuthService, EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
{
$this->middleware('guest', ['only' => ['getLogin', 'postLogin', 'getRegister', 'postRegister']]);
$this->middleware('guest')->except(['socialCallback', 'detachSocialAccount']);
$this->socialAuthService = $socialAuthService;
$this->emailConfirmationService = $emailConfirmationService;
$this->userRepo = $userRepo;
$this->redirectTo = baseUrl('/');
$this->redirectPath = baseUrl('/');
$this->redirectAfterLogout = baseUrl('/login');
$this->username = config('auth.method') === 'standard' ? 'email' : 'username';
parent::__construct();
}
/**
* Get a validator for an incoming registration request.
*
* @param array $data
* @return \Illuminate\Contracts\Validation\Validator
*/
@@ -69,16 +76,20 @@ class AuthController extends Controller
]);
}
/**
* Check whether or not registrations are allowed in the app settings.
* @throws UserRegistrationException
*/
protected function checkRegistrationAllowed()
{
if (!setting('registration-enabled')) {
throw new UserRegistrationException('Registrations are currently disabled.', '/login');
throw new UserRegistrationException(trans('auth.registrations_disabled'), '/login');
}
}
/**
* Show the application registration form.
* @return \Illuminate\Http\Response
* @return Response
*/
public function getRegister()
{
@@ -89,9 +100,10 @@ class AuthController extends Controller
/**
* Handle a registration request for the application.
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
* @param Request|\Illuminate\Http\Request $request
* @return Response
* @throws UserRegistrationException
* @throws \Illuminate\Foundation\Validation\ValidationException
*/
public function postRegister(Request $request)
{
@@ -108,66 +120,18 @@ class AuthController extends Controller
return $this->registerUser($userData);
}
/**
* Overrides the action when a user is authenticated.
* If the user authenticated but does not exist in the user table we create them.
* @param Request $request
* @param Authenticatable $user
* @return \Illuminate\Http\RedirectResponse
* @throws AuthException
* Create a new user instance after a valid registration.
* @param array $data
* @return User
*/
protected function authenticated(Request $request, Authenticatable $user)
protected function create(array $data)
{
// Explicitly log them out for now if they do no exist.
if (!$user->exists) auth()->logout($user);
if (!$user->exists && $user->email === null && !$request->has('email')) {
$request->flash();
session()->flash('request-email', true);
return redirect('/login');
}
if (!$user->exists && $user->email === null && $request->has('email')) {
$user->email = $request->get('email');
}
if (!$user->exists) {
// Check for users with same email already
$alreadyUser = $user->newQuery()->where('email', '=', $user->email)->count() > 0;
if ($alreadyUser) {
throw new AuthException('A user with the email ' . $user->email . ' already exists but with different credentials.');
}
$user->save();
$this->userRepo->attachDefaultRole($user);
auth()->login($user);
}
$path = session()->pull('url.intended', '/');
$path = baseUrl($path, true);
return redirect($path);
}
/**
* Register a new user after a registration callback.
* @param $socialDriver
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws UserRegistrationException
*/
protected function socialRegisterCallback($socialDriver)
{
$socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver);
$socialAccount = $this->socialAuthService->fillSocialAccount($socialDriver, $socialUser);
// Create an array of the user data to create a new user instance
$userData = [
'name' => $socialUser->getName(),
'email' => $socialUser->getEmail(),
'password' => str_random(30)
];
return $this->registerUser($userData, $socialAccount);
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
]);
}
/**
@@ -176,7 +140,7 @@ class AuthController extends Controller
* @param bool|false|SocialAccount $socialAccount
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws UserRegistrationException
* @throws \BookStack\Exceptions\ConfirmationEmailException
* @throws ConfirmationEmailException
*/
protected function registerUser(array $userData, $socialAccount = false)
{
@@ -184,7 +148,7 @@ class AuthController extends Controller
$restrictedEmailDomains = explode(',', str_replace(' ', '', setting('registration-restrict')));
$userEmailDomain = $domain = substr(strrchr($userData['email'], "@"), 1);
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
throw new UserRegistrationException('That email domain does not have access to this application', '/register');
throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), '/register');
}
}
@@ -195,12 +159,18 @@ class AuthController extends Controller
if (setting('registration-confirmation') || setting('registration-restrict')) {
$newUser->save();
$this->emailConfirmationService->sendConfirmation($newUser);
try {
$this->emailConfirmationService->sendConfirmation($newUser);
} catch (Exception $e) {
session()->flash('error', trans('auth.email_confirm_send_error'));
}
return redirect('/register/confirm');
}
auth()->login($newUser);
session()->flash('success', 'Thanks for signing up! You are now registered and signed in.');
session()->flash('success', trans('auth.register_success'));
return redirect($this->redirectPath());
}
@@ -213,18 +183,6 @@ class AuthController extends Controller
return view('auth/register-confirm');
}
/**
* View the confirmation email as a standard web page.
* @param $token
* @return \Illuminate\View\View
* @throws UserRegistrationException
*/
public function viewConfirmEmail($token)
{
$confirmation = $this->emailConfirmationService->getEmailConfirmationFromToken($token);
return view('emails/email-confirmation', ['token' => $confirmation->token]);
}
/**
* Confirms an email via a token and logs the user into the system.
* @param $token
@@ -237,8 +195,8 @@ class AuthController extends Controller
$user = $confirmation->user;
$user->email_confirmed = true;
$user->save();
auth()->login($confirmation->user);
session()->flash('success', 'Your email has been confirmed!');
auth()->login($user);
session()->flash('success', trans('auth.email_confirm_success'));
$this->emailConfirmationService->deleteConfirmationsByUser($user);
return redirect($this->redirectPath);
}
@@ -264,33 +222,19 @@ class AuthController extends Controller
'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');
}
$this->emailConfirmationService->sendConfirmation($user);
session()->flash('success', 'Confirmation email resent, Please check your inbox.');
session()->flash('success', trans('auth.email_confirm_resent'));
return redirect('/register/confirm');
}
/**
* Show the application login form.
* @return \Illuminate\Http\Response
*/
public function getLogin()
{
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$authMethod = config('auth.method');
return view('auth/login', ['socialDrivers' => $socialDrivers, 'authMethod' => $authMethod]);
}
/**
* Redirect to the relevant social site.
* @param $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
*/
public function getSocialLogin($socialDriver)
{
session()->put('social-callback', 'login');
return $this->socialAuthService->startLogIn($socialDriver);
}
/**
* Redirect to the social site for authentication intended to register.
* @param $socialDriver
@@ -319,7 +263,7 @@ class AuthController extends Controller
return $this->socialRegisterCallback($socialDriver);
}
} else {
throw new SocialSignInException('No action defined', '/login');
throw new SocialSignInException(trans('errors.social_no_action_defined'), '/login');
}
return redirect()->back();
}
@@ -334,4 +278,24 @@ class AuthController extends Controller
return $this->socialAuthService->detachSocialAccount($socialDriver);
}
}
/**
* Register a new user after a registration callback.
* @param $socialDriver
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws UserRegistrationException
*/
protected function socialRegisterCallback($socialDriver)
{
$socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver);
$socialAccount = $this->socialAuthService->fillSocialAccount($socialDriver, $socialUser);
// Create an array of the user data to create a new user instance
$userData = [
'name' => $socialUser->getName(),
'email' => $socialUser->getEmail(),
'password' => str_random(30)
];
return $this->registerUser($userData, $socialAccount);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace BookStack\Http\Controllers\Auth;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
class ResetPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset requests
| and uses a simple trait to include this behavior. You're free to
| explore this trait and override any methods you wish to tweak.
|
*/
use ResetsPasswords;
protected $redirectTo = '/';
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
parent::__construct();
}
/**
* Get the response for a successful password reset.
*
* @param string $response
* @return \Illuminate\Http\Response
*/
protected function sendResetResponse($response)
{
$message = trans('auth.reset_password_success');
session()->flash('success', $message);
return redirect($this->redirectPath())
->with('status', trans($response));
}
}

View File

@@ -1,34 +1,26 @@
<?php namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Repos\EntityRepo;
use BookStack\Repos\UserRepo;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
use BookStack\Repos\BookRepo;
use BookStack\Repos\ChapterRepo;
use BookStack\Repos\PageRepo;
use Illuminate\Http\Response;
use Views;
class BookController extends Controller
{
protected $bookRepo;
protected $pageRepo;
protected $chapterRepo;
protected $entityRepo;
protected $userRepo;
/**
* BookController constructor.
* @param BookRepo $bookRepo
* @param PageRepo $pageRepo
* @param ChapterRepo $chapterRepo
* @param EntityRepo $entityRepo
* @param UserRepo $userRepo
*/
public function __construct(BookRepo $bookRepo, PageRepo $pageRepo, ChapterRepo $chapterRepo, UserRepo $userRepo)
public function __construct(EntityRepo $entityRepo, UserRepo $userRepo)
{
$this->bookRepo = $bookRepo;
$this->pageRepo = $pageRepo;
$this->chapterRepo = $chapterRepo;
$this->entityRepo = $entityRepo;
$this->userRepo = $userRepo;
parent::__construct();
}
@@ -39,9 +31,9 @@ class BookController extends Controller
*/
public function index()
{
$books = $this->bookRepo->getAllPaginated(10);
$recents = $this->signedIn ? $this->bookRepo->getRecentlyViewed(4, 0) : false;
$popular = $this->bookRepo->getPopular(4, 0);
$books = $this->entityRepo->getAllPaginated('book', 10);
$recents = $this->signedIn ? $this->entityRepo->getRecentlyViewed('book', 4, 0) : false;
$popular = $this->entityRepo->getPopular('book', 4, 0);
$this->setPageTitle('Books');
return view('books/index', ['books' => $books, 'recents' => $recents, 'popular' => $popular]);
}
@@ -53,7 +45,7 @@ class BookController extends Controller
public function create()
{
$this->checkPermission('book-create-all');
$this->setPageTitle('Create New Book');
$this->setPageTitle(trans('entities.books_create'));
return view('books/create');
}
@@ -70,7 +62,7 @@ class BookController extends Controller
'name' => 'required|string|max:255',
'description' => 'string|max:1000'
]);
$book = $this->bookRepo->createFromInput($request->all());
$book = $this->entityRepo->createFromInput('book', $request->all());
Activity::add($book, 'book_create', $book->id);
return redirect($book->getUrl());
}
@@ -82,9 +74,9 @@ class BookController extends Controller
*/
public function show($slug)
{
$book = $this->bookRepo->getBySlug($slug);
$book = $this->entityRepo->getBySlug('book', $slug);
$this->checkOwnablePermission('book-view', $book);
$bookChildren = $this->bookRepo->getChildren($book);
$bookChildren = $this->entityRepo->getBookChildren($book);
Views::add($book);
$this->setPageTitle($book->getShortName());
return view('books/show', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
@@ -97,9 +89,9 @@ class BookController extends Controller
*/
public function edit($slug)
{
$book = $this->bookRepo->getBySlug($slug);
$book = $this->entityRepo->getBySlug('book', $slug);
$this->checkOwnablePermission('book-update', $book);
$this->setPageTitle('Edit Book ' . $book->getShortName());
$this->setPageTitle(trans('entities.books_edit_named',['bookName'=>$book->getShortName()]));
return view('books/edit', ['book' => $book, 'current' => $book]);
}
@@ -111,13 +103,13 @@ class BookController extends Controller
*/
public function update(Request $request, $slug)
{
$book = $this->bookRepo->getBySlug($slug);
$book = $this->entityRepo->getBySlug('book', $slug);
$this->checkOwnablePermission('book-update', $book);
$this->validate($request, [
'name' => 'required|string|max:255',
'description' => 'string|max:1000'
]);
$book = $this->bookRepo->updateFromInput($book, $request->all());
$book = $this->entityRepo->updateFromInput('book', $book, $request->all());
Activity::add($book, 'book_update', $book->id);
return redirect($book->getUrl());
}
@@ -129,9 +121,9 @@ class BookController extends Controller
*/
public function showDelete($bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$this->checkOwnablePermission('book-delete', $book);
$this->setPageTitle('Delete Book ' . $book->getShortName());
$this->setPageTitle(trans('entities.books_delete_named', ['bookName'=>$book->getShortName()]));
return view('books/delete', ['book' => $book, 'current' => $book]);
}
@@ -142,11 +134,11 @@ class BookController extends Controller
*/
public function sort($bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$this->checkOwnablePermission('book-update', $book);
$bookChildren = $this->bookRepo->getChildren($book, true);
$books = $this->bookRepo->getAll(false);
$this->setPageTitle('Sort Book ' . $book->getShortName());
$bookChildren = $this->entityRepo->getBookChildren($book, true);
$books = $this->entityRepo->getAll('book', false);
$this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()]));
return view('books/sort', ['book' => $book, 'current' => $book, 'books' => $books, 'bookChildren' => $bookChildren]);
}
@@ -158,8 +150,8 @@ class BookController extends Controller
*/
public function getSortItem($bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$bookChildren = $this->bookRepo->getChildren($book);
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$bookChildren = $this->entityRepo->getBookChildren($book);
return view('books/sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
}
@@ -171,7 +163,7 @@ class BookController extends Controller
*/
public function saveSort($bookSlug, Request $request)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$this->checkOwnablePermission('book-update', $book);
// Return if no map sent
@@ -190,13 +182,13 @@ class BookController extends Controller
$priority = $bookChild->sort;
$id = intval($bookChild->id);
$isPage = $bookChild->type == 'page';
$bookId = $this->bookRepo->exists($bookChild->book) ? intval($bookChild->book) : $defaultBookId;
$bookId = $this->entityRepo->exists('book', $bookChild->book) ? intval($bookChild->book) : $defaultBookId;
$chapterId = ($isPage && $bookChild->parentChapter === false) ? 0 : intval($bookChild->parentChapter);
$model = $isPage ? $this->pageRepo->getById($id) : $this->chapterRepo->getById($id);
$model = $this->entityRepo->getById($isPage?'page':'chapter', $id);
// Update models only if there's a change in parent chain or ordering.
if ($model->priority !== $priority || $model->book_id !== $bookId || ($isPage && $model->chapter_id !== $chapterId)) {
$isPage ? $this->pageRepo->changeBook($bookId, $model) : $this->chapterRepo->changeBook($bookId, $model);
$this->entityRepo->changeBook($isPage?'page':'chapter', $bookId, $model);
$model->priority = $priority;
if ($isPage) $model->chapter_id = $chapterId;
$model->save();
@@ -211,12 +203,12 @@ class BookController extends Controller
// Add activity for books
foreach ($sortedBooks as $bookId) {
$updatedBook = $this->bookRepo->getById($bookId);
$updatedBook = $this->entityRepo->getById('book', $bookId);
Activity::add($updatedBook, 'book_sort', $updatedBook->id);
}
// Update permissions on changed models
$this->bookRepo->buildJointPermissions($updatedModels);
$this->entityRepo->buildJointPermissions($updatedModels);
return redirect($book->getUrl());
}
@@ -228,11 +220,10 @@ class BookController extends Controller
*/
public function destroy($bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$this->checkOwnablePermission('book-delete', $book);
Activity::addMessage('book_delete', 0, $book->name);
Activity::removeEntity($book);
$this->bookRepo->destroy($book);
$this->entityRepo->destroyBook($book);
return redirect('/books');
}
@@ -243,7 +234,7 @@ class BookController extends Controller
*/
public function showRestrict($bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$this->checkOwnablePermission('restrictions-manage', $book);
$roles = $this->userRepo->getRestrictableRoles();
return view('books/restrictions', [
@@ -261,10 +252,10 @@ class BookController extends Controller
*/
public function restrict($bookSlug, Request $request)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$this->checkOwnablePermission('restrictions-manage', $book);
$this->bookRepo->updateEntityPermissionsFromRequest($request, $book);
session()->flash('success', 'Book Restrictions Updated');
$this->entityRepo->updateEntityPermissionsFromRequest($request, $book);
session()->flash('success', trans('entities.books_permissions_updated'));
return redirect($book->getUrl());
}
}

View File

@@ -1,30 +1,26 @@
<?php namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Repos\EntityRepo;
use BookStack\Repos\UserRepo;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
use BookStack\Repos\BookRepo;
use BookStack\Repos\ChapterRepo;
use Illuminate\Http\Response;
use Views;
class ChapterController extends Controller
{
protected $bookRepo;
protected $chapterRepo;
protected $userRepo;
protected $entityRepo;
/**
* ChapterController constructor.
* @param BookRepo $bookRepo
* @param ChapterRepo $chapterRepo
* @param EntityRepo $entityRepo
* @param UserRepo $userRepo
*/
public function __construct(BookRepo $bookRepo, ChapterRepo $chapterRepo, UserRepo $userRepo)
public function __construct(EntityRepo $entityRepo, UserRepo $userRepo)
{
$this->bookRepo = $bookRepo;
$this->chapterRepo = $chapterRepo;
$this->entityRepo = $entityRepo;
$this->userRepo = $userRepo;
parent::__construct();
}
@@ -36,9 +32,9 @@ class ChapterController extends Controller
*/
public function create($bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$this->checkOwnablePermission('chapter-create', $book);
$this->setPageTitle('Create New Chapter');
$this->setPageTitle(trans('entities.chapters_create'));
return view('chapters/create', ['book' => $book, 'current' => $book]);
}
@@ -54,12 +50,12 @@ class ChapterController extends Controller
'name' => 'required|string|max:255'
]);
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$this->checkOwnablePermission('chapter-create', $book);
$input = $request->all();
$input['priority'] = $this->bookRepo->getNewPriority($book);
$chapter = $this->chapterRepo->createFromInput($input, $book);
$input['priority'] = $this->entityRepo->getNewBookPriority($book);
$chapter = $this->entityRepo->createFromInput('chapter', $input, $book);
Activity::add($chapter, 'chapter_create', $book->id);
return redirect($chapter->getUrl());
}
@@ -72,15 +68,14 @@ class ChapterController extends Controller
*/
public function show($bookSlug, $chapterSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$sidebarTree = $this->bookRepo->getChildren($book);
$sidebarTree = $this->entityRepo->getBookChildren($chapter->book);
Views::add($chapter);
$this->setPageTitle($chapter->getShortName());
$pages = $this->chapterRepo->getChildren($chapter);
$pages = $this->entityRepo->getChapterChildren($chapter);
return view('chapters/show', [
'book' => $book,
'book' => $chapter->book,
'chapter' => $chapter,
'current' => $chapter,
'sidebarTree' => $sidebarTree,
@@ -96,11 +91,10 @@ class ChapterController extends Controller
*/
public function edit($bookSlug, $chapterSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->setPageTitle('Edit Chapter' . $chapter->getShortName());
return view('chapters/edit', ['book' => $book, 'chapter' => $chapter, 'current' => $chapter]);
$this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()]));
return view('chapters/edit', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]);
}
/**
@@ -112,14 +106,15 @@ class ChapterController extends Controller
*/
public function update(Request $request, $bookSlug, $chapterSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
if ($chapter->name !== $request->get('name')) {
$chapter->slug = $this->entityRepo->findSuitableSlug('chapter', $request->get('name'), $chapter->id, $chapter->book->id);
}
$chapter->fill($request->all());
$chapter->slug = $this->chapterRepo->findSuitableSlug($chapter->name, $book->id, $chapter->id);
$chapter->updated_by = auth()->user()->id;
$chapter->updated_by = user()->id;
$chapter->save();
Activity::add($chapter, 'chapter_update', $book->id);
Activity::add($chapter, 'chapter_update', $chapter->book->id);
return redirect($chapter->getUrl());
}
@@ -131,11 +126,10 @@ class ChapterController extends Controller
*/
public function showDelete($bookSlug, $chapterSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->setPageTitle('Delete Chapter' . $chapter->getShortName());
return view('chapters/delete', ['book' => $book, 'chapter' => $chapter, 'current' => $chapter]);
$this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()]));
return view('chapters/delete', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]);
}
/**
@@ -146,11 +140,11 @@ class ChapterController extends Controller
*/
public function destroy($bookSlug, $chapterSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
$book = $chapter->book;
$this->checkOwnablePermission('chapter-delete', $chapter);
Activity::addMessage('chapter_delete', $book->id, $chapter->name);
$this->chapterRepo->destroy($chapter);
$this->entityRepo->destroyChapter($chapter);
return redirect($book->getUrl());
}
@@ -162,12 +156,12 @@ class ChapterController extends Controller
* @throws \BookStack\Exceptions\NotFoundException
*/
public function showMove($bookSlug, $chapterSlug) {
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
$this->setPageTitle(trans('entities.chapters_move_named', ['chapterName' => $chapter->getShortName()]));
$this->checkOwnablePermission('chapter-update', $chapter);
return view('chapters/move', [
'chapter' => $chapter,
'book' => $book
'book' => $chapter->book
]);
}
@@ -180,8 +174,7 @@ class ChapterController extends Controller
* @throws \BookStack\Exceptions\NotFoundException
*/
public function move($bookSlug, $chapterSlug, Request $request) {
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$entitySelection = $request->get('entity_selection', null);
@@ -196,17 +189,17 @@ class ChapterController extends Controller
$parent = false;
if ($entityType == 'book') {
$parent = $this->bookRepo->getById($entityId);
$parent = $this->entityRepo->getById('book', $entityId);
}
if ($parent === false || $parent === null) {
session()->flash('The selected Book was not found');
session()->flash('error', trans('errors.selected_book_not_found'));
return redirect()->back();
}
$this->chapterRepo->changeBook($parent->id, $chapter, true);
$this->entityRepo->changeBook('chapter', $parent->id, $chapter, true);
Activity::add($chapter, 'chapter_move', $chapter->book->id);
session()->flash('success', sprintf('Chapter moved to "%s"', $parent->name));
session()->flash('success', trans('entities.chapter_move_success', ['bookName' => $parent->name]));
return redirect($chapter->getUrl());
}
@@ -219,8 +212,7 @@ class ChapterController extends Controller
*/
public function showRestrict($bookSlug, $chapterSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
$this->checkOwnablePermission('restrictions-manage', $chapter);
$roles = $this->userRepo->getRestrictableRoles();
return view('chapters/restrictions', [
@@ -238,11 +230,10 @@ class ChapterController extends Controller
*/
public function restrict($bookSlug, $chapterSlug, Request $request)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
$this->checkOwnablePermission('restrictions-manage', $chapter);
$this->chapterRepo->updateEntityPermissionsFromRequest($request, $chapter);
session()->flash('success', 'Chapter Restrictions Updated');
$this->entityRepo->updateEntityPermissionsFromRequest($request, $chapter);
session()->flash('success', trans('entities.chapters_permissions_success'));
return redirect($chapter->getUrl());
}
}

View File

@@ -3,13 +3,11 @@
namespace BookStack\Http\Controllers;
use BookStack\Ownable;
use HttpRequestException;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Http\Exception\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use BookStack\User;
abstract class Controller extends BaseController
@@ -30,17 +28,21 @@ abstract class Controller extends BaseController
*/
public function __construct()
{
// Get a user instance for the current user
$user = auth()->user();
if (!$user) $user = User::getDefault();
$this->middleware(function ($request, $next) {
// Share variables with views
view()->share('signedIn', auth()->check());
view()->share('currentUser', $user);
// Get a user instance for the current user
$user = user();
// Share variables with controllers
$this->currentUser = $user;
$this->signedIn = auth()->check();
// Share variables with controllers
$this->currentUser = $user;
$this->signedIn = auth()->check();
// Share variables with views
view()->share('signedIn', $this->signedIn);
view()->share('currentUser', $user);
return $next($request);
});
}
/**
@@ -67,8 +69,13 @@ abstract class Controller extends BaseController
*/
protected function showPermissionError()
{
Session::flash('error', trans('errors.permission'));
$response = request()->wantsJson() ? response()->json(['error' => trans('errors.permissionJson')], 403) : redirect('/');
if (request()->wantsJson()) {
$response = response()->json(['error' => trans('errors.permissionJson')], 403);
} else {
$response = redirect('/');
session()->flash('error', trans('errors.permission'));
}
throw new HttpResponseException($response);
}
@@ -79,7 +86,7 @@ abstract class Controller extends BaseController
*/
protected function checkPermission($permissionName)
{
if (!$this->currentUser || !$this->currentUser->can($permissionName)) {
if (!user() || !user()->can($permissionName)) {
$this->showPermissionError();
}
return true;
@@ -121,4 +128,22 @@ abstract class Controller extends BaseController
return response()->json(['message' => $messageText], $statusCode);
}
/**
* Create the response for when a request fails validation.
*
* @param \Illuminate\Http\Request $request
* @param array $errors
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function buildFailedValidationResponse(Request $request, array $errors)
{
if ($request->expectsJson()) {
return response()->json(['validation' => $errors], 422);
}
return redirect()->to($this->getRedirectUrl())
->withInput($request->input())
->withErrors($errors, $this->errorBag());
}
}

View File

@@ -5,6 +5,7 @@ namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Repos\EntityRepo;
use BookStack\Http\Requests;
use Illuminate\Http\Response;
use Views;
class HomeController extends Controller
@@ -31,9 +32,9 @@ class HomeController extends Controller
$activity = Activity::latest(10);
$draftPages = $this->signedIn ? $this->entityRepo->getUserDraftPages(6) : [];
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
$recents = $this->signedIn ? Views::getUserRecentlyViewed(12*$recentFactor, 0) : $this->entityRepo->getRecentlyCreatedBooks(10*$recentFactor);
$recentlyCreatedPages = $this->entityRepo->getRecentlyCreatedPages(5);
$recentlyUpdatedPages = $this->entityRepo->getRecentlyUpdatedPages(5);
$recents = $this->signedIn ? Views::getUserRecentlyViewed(12*$recentFactor, 0) : $this->entityRepo->getRecentlyCreated('book', 10*$recentFactor);
$recentlyCreatedPages = $this->entityRepo->getRecentlyCreated('page', 5);
$recentlyUpdatedPages = $this->entityRepo->getRecentlyUpdated('page', 5);
return view('home', [
'activity' => $activity,
'recents' => $recents,
@@ -43,4 +44,39 @@ class HomeController extends Controller
]);
}
/**
* Get a js representation of the current translations
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Symfony\Component\HttpFoundation\Response
*/
public function getTranslations() {
$locale = trans()->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')
];
if ($locale !== 'en') {
$enTrans = [
'common' => trans('common', [], null, 'en'),
'components' => trans('components', [], null, 'en'),
'entities' => trans('entities', [], null, 'en'),
'errors' => trans('errors', [], null, 'en')
];
$translations = array_replace_recursive($enTrans, $translations);
}
$resp = 'window.translations = ' . json_encode($translations);
cache()->put($cacheKey, $resp, 120);
}
return response($resp, 200, [
'Content-Type' => 'application/javascript'
]);
}
}

View File

@@ -1,6 +1,7 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Repos\EntityRepo;
use BookStack\Repos\ImageRepo;
use Illuminate\Filesystem\Filesystem as File;
use Illuminate\Http\Request;
@@ -73,6 +74,7 @@ class ImageController extends Controller
* @param $filter
* @param int $page
* @param Request $request
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
*/
public function getGalleryFiltered($filter, $page = 0, Request $request)
{
@@ -149,12 +151,12 @@ class ImageController extends Controller
/**
* Deletes an image and all thumbnail/image files
* @param PageRepo $pageRepo
* @param EntityRepo $entityRepo
* @param Request $request
* @param int $id
* @return \Illuminate\Http\JsonResponse
*/
public function destroy(PageRepo $pageRepo, Request $request, $id)
public function destroy(EntityRepo $entityRepo, Request $request, $id)
{
$image = $this->imageRepo->getById($id);
$this->checkOwnablePermission('image-delete', $image);
@@ -162,14 +164,14 @@ class ImageController extends Controller
// Check if this image is used on any pages
$isForced = ($request->has('force') && ($request->get('force') === 'true') || $request->get('force') === true);
if (!$isForced) {
$pageSearch = $pageRepo->searchForImage($image->url);
$pageSearch = $entityRepo->searchForImage($image->url);
if ($pageSearch !== false) {
return response()->json($pageSearch, 400);
}
}
$this->imageRepo->destroyImage($image);
return response()->json('Image Deleted');
return response()->json(trans('components.images_deleted'));
}

View File

@@ -2,39 +2,31 @@
use Activity;
use BookStack\Exceptions\NotFoundException;
use BookStack\Repos\EntityRepo;
use BookStack\Repos\UserRepo;
use BookStack\Services\ExportService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
use BookStack\Repos\BookRepo;
use BookStack\Repos\ChapterRepo;
use BookStack\Repos\PageRepo;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Illuminate\Http\Response;
use Views;
use GatherContent\Htmldiff\Htmldiff;
class PageController extends Controller
{
protected $pageRepo;
protected $bookRepo;
protected $chapterRepo;
protected $entityRepo;
protected $exportService;
protected $userRepo;
/**
* PageController constructor.
* @param PageRepo $pageRepo
* @param BookRepo $bookRepo
* @param ChapterRepo $chapterRepo
* @param EntityRepo $entityRepo
* @param ExportService $exportService
* @param UserRepo $userRepo
*/
public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo, ExportService $exportService, UserRepo $userRepo)
public function __construct(EntityRepo $entityRepo, ExportService $exportService, UserRepo $userRepo)
{
$this->pageRepo = $pageRepo;
$this->bookRepo = $bookRepo;
$this->chapterRepo = $chapterRepo;
$this->entityRepo = $entityRepo;
$this->exportService = $exportService;
$this->userRepo = $userRepo;
parent::__construct();
@@ -42,43 +34,82 @@ class PageController extends Controller
/**
* Show the form for creating a new page.
* @param $bookSlug
* @param bool $chapterSlug
* @param string $bookSlug
* @param string $chapterSlug
* @return Response
* @internal param bool $pageSlug
*/
public function create($bookSlug, $chapterSlug = false)
public function create($bookSlug, $chapterSlug = null)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $chapterSlug ? $this->chapterRepo->getBySlug($chapterSlug, $book->id) : null;
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$chapter = $chapterSlug ? $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug) : null;
$parent = $chapter ? $chapter : $book;
$this->checkOwnablePermission('page-create', $parent);
$this->setPageTitle('Create New Page');
$draft = $this->pageRepo->getDraftPage($book, $chapter);
return redirect($draft->getUrl());
// Redirect to draft edit screen if signed in
if ($this->signedIn) {
$draft = $this->entityRepo->getDraftPage($book, $chapter);
return redirect($draft->getUrl());
}
// Otherwise show edit view
$this->setPageTitle(trans('entities.pages_new'));
return view('pages/guest-create', ['parent' => $parent]);
}
/**
* Create a new page as a guest user.
* @param Request $request
* @param string $bookSlug
* @param string|null $chapterSlug
* @return mixed
* @throws NotFoundException
*/
public function createAsGuest(Request $request, $bookSlug, $chapterSlug = null)
{
$this->validate($request, [
'name' => 'required|string|max:255'
]);
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$chapter = $chapterSlug ? $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug) : null;
$parent = $chapter ? $chapter : $book;
$this->checkOwnablePermission('page-create', $parent);
$page = $this->entityRepo->getDraftPage($book, $chapter);
$this->entityRepo->publishPageDraft($page, [
'name' => $request->get('name'),
'html' => ''
]);
return redirect($page->getUrl('/edit'));
}
/**
* Show form to continue editing a draft page.
* @param $bookSlug
* @param $pageId
* @param string $bookSlug
* @param int $pageId
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function editDraft($bookSlug, $pageId)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$draft = $this->pageRepo->getById($pageId, true);
$this->checkOwnablePermission('page-create', $book);
$this->setPageTitle('Edit Page Draft');
$draft = $this->entityRepo->getById('page', $pageId, true);
$this->checkOwnablePermission('page-create', $draft->book);
$this->setPageTitle(trans('entities.pages_edit_draft'));
return view('pages/edit', ['page' => $draft, 'book' => $book, 'isDraft' => true]);
$draftsEnabled = $this->signedIn;
return view('pages/edit', [
'page' => $draft,
'book' => $draft->book,
'isDraft' => true,
'draftsEnabled' => $draftsEnabled
]);
}
/**
* Store a new page by changing a draft into a page.
* @param Request $request
* @param string $bookSlug
* @param int $pageId
* @return Response
*/
public function store(Request $request, $bookSlug, $pageId)
@@ -88,21 +119,21 @@ class PageController extends Controller
]);
$input = $request->all();
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$draftPage = $this->pageRepo->getById($pageId, true);
$draftPage = $this->entityRepo->getById('page', $pageId, true);
$chapterId = intval($draftPage->chapter_id);
$parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book;
$parent = $chapterId !== 0 ? $this->entityRepo->getById('chapter', $chapterId) : $book;
$this->checkOwnablePermission('page-create', $parent);
if ($parent->isA('chapter')) {
$input['priority'] = $this->chapterRepo->getNewPriority($parent);
$input['priority'] = $this->entityRepo->getNewChapterPriority($parent);
} else {
$input['priority'] = $this->bookRepo->getNewPriority($parent);
$input['priority'] = $this->entityRepo->getNewBookPriority($parent);
}
$page = $this->pageRepo->publishDraft($draftPage, $input);
$page = $this->entityRepo->publishPageDraft($draftPage, $input);
Activity::add($page, 'page_create', $book->id);
return redirect($page->getUrl());
@@ -110,83 +141,91 @@ class PageController extends Controller
/**
* Display the specified page.
* If the page is not found via the slug the
* revisions are searched for a match.
* @param $bookSlug
* @param $pageSlug
* If the page is not found via the slug the revisions are searched for a match.
* @param string $bookSlug
* @param string $pageSlug
* @return Response
*/
public function show($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
try {
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
} catch (NotFoundException $e) {
$page = $this->pageRepo->findPageUsingOldSlug($pageSlug, $bookSlug);
$page = $this->entityRepo->getPageByOldSlug($pageSlug, $bookSlug);
if ($page === null) abort(404);
return redirect($page->getUrl());
}
$this->checkOwnablePermission('page-view', $page);
$sidebarTree = $this->bookRepo->getChildren($book);
$pageContent = $this->entityRepo->renderPage($page);
$sidebarTree = $this->entityRepo->getBookChildren($page->book);
$pageNav = $this->entityRepo->getPageNav($pageContent);
Views::add($page);
$this->setPageTitle($page->getShortName());
return view('pages/show', ['page' => $page, 'book' => $book, 'current' => $page, 'sidebarTree' => $sidebarTree]);
return view('pages/show', [
'page' => $page,'book' => $page->book,
'current' => $page, 'sidebarTree' => $sidebarTree,
'pageNav' => $pageNav, 'pageContent' => $pageContent]);
}
/**
* Get page from an ajax request.
* @param $pageId
* @param int $pageId
* @return \Illuminate\Http\JsonResponse
*/
public function getPageAjax($pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->entityRepo->getById('page', $pageId);
return response()->json($page);
}
/**
* Show the form for editing the specified page.
* @param $bookSlug
* @param $pageSlug
* @param string $bookSlug
* @param string $pageSlug
* @return Response
*/
public function edit($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$this->checkOwnablePermission('page-update', $page);
$this->setPageTitle('Editing Page ' . $page->getShortName());
$this->setPageTitle(trans('entities.pages_editing_named', ['pageName'=>$page->getShortName()]));
$page->isDraft = false;
// Check for active editing
$warnings = [];
if ($this->pageRepo->isPageEditingActive($page, 60)) {
$warnings[] = $this->pageRepo->getPageEditingActiveMessage($page, 60);
if ($this->entityRepo->isPageEditingActive($page, 60)) {
$warnings[] = $this->entityRepo->getPageEditingActiveMessage($page, 60);
}
// Check for a current draft version for this user
if ($this->pageRepo->hasUserGotPageDraft($page, $this->currentUser->id)) {
$draft = $this->pageRepo->getUserPageDraft($page, $this->currentUser->id);
if ($this->entityRepo->hasUserGotPageDraft($page, $this->currentUser->id)) {
$draft = $this->entityRepo->getUserPageDraft($page, $this->currentUser->id);
$page->name = $draft->name;
$page->html = $draft->html;
$page->markdown = $draft->markdown;
$page->isDraft = true;
$warnings [] = $this->pageRepo->getUserPageDraftMessage($draft);
$warnings [] = $this->entityRepo->getUserPageDraftMessage($draft);
}
if (count($warnings) > 0) session()->flash('warning', implode("\n", $warnings));
return view('pages/edit', ['page' => $page, 'book' => $book, 'current' => $page]);
$draftsEnabled = $this->signedIn;
return view('pages/edit', [
'page' => $page,
'book' => $page->book,
'current' => $page,
'draftsEnabled' => $draftsEnabled
]);
}
/**
* Update the specified page in storage.
* @param Request $request
* @param $bookSlug
* @param $pageSlug
* @param string $bookSlug
* @param string $pageSlug
* @return Response
*/
public function update(Request $request, $bookSlug, $pageSlug)
@@ -194,35 +233,38 @@ class PageController extends Controller
$this->validate($request, [
'name' => 'required|string|max:255'
]);
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$this->checkOwnablePermission('page-update', $page);
$this->pageRepo->updatePage($page, $book->id, $request->all());
Activity::add($page, 'page_update', $book->id);
$this->entityRepo->updatePage($page, $page->book->id, $request->all());
Activity::add($page, 'page_update', $page->book->id);
return redirect($page->getUrl());
}
/**
* Save a draft update as a revision.
* @param Request $request
* @param $pageId
* @param int $pageId
* @return \Illuminate\Http\JsonResponse
*/
public function saveDraft(Request $request, $pageId)
{
$page = $this->pageRepo->getById($pageId, true);
$page = $this->entityRepo->getById('page', $pageId, true);
$this->checkOwnablePermission('page-update', $page);
if ($page->draft) {
$draft = $this->pageRepo->updateDraftPage($page, $request->only(['name', 'html', 'markdown']));
} else {
$draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html', 'markdown']));
if (!$this->signedIn) {
return response()->json([
'status' => 'error',
'message' => trans('errors.guests_cannot_save_drafts'),
], 500);
}
$draft = $this->entityRepo->updatePageDraft($page, $request->only(['name', 'html', 'markdown']));
$updateTime = $draft->updated_at->timestamp;
$utcUpdateTimestamp = $updateTime + Carbon::createFromTimestamp(0)->offset;
return response()->json([
'status' => 'success',
'message' => 'Draft saved at ',
'message' => trans('entities.pages_edit_draft_save_at'),
'timestamp' => $utcUpdateTimestamp
]);
}
@@ -230,142 +272,168 @@ class PageController extends Controller
/**
* Redirect from a special link url which
* uses the page id rather than the name.
* @param $pageId
* @param int $pageId
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function redirectFromLink($pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->entityRepo->getById('page', $pageId);
return redirect($page->getUrl());
}
/**
* Show the deletion page for the specified page.
* @param $bookSlug
* @param $pageSlug
* @param string $bookSlug
* @param string $pageSlug
* @return \Illuminate\View\View
*/
public function showDelete($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->setPageTitle('Delete Page ' . $page->getShortName());
return view('pages/delete', ['book' => $book, 'page' => $page, 'current' => $page]);
$this->setPageTitle(trans('entities.pages_delete_named', ['pageName'=>$page->getShortName()]));
return view('pages/delete', ['book' => $page->book, 'page' => $page, 'current' => $page]);
}
/**
* Show the deletion page for the specified page.
* @param $bookSlug
* @param $pageId
* @param string $bookSlug
* @param int $pageId
* @return \Illuminate\View\View
* @throws NotFoundException
*/
public function showDeleteDraft($bookSlug, $pageId)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getById($pageId, true);
$page = $this->entityRepo->getById('page', $pageId, true);
$this->checkOwnablePermission('page-update', $page);
$this->setPageTitle('Delete Draft Page ' . $page->getShortName());
return view('pages/delete', ['book' => $book, 'page' => $page, 'current' => $page]);
$this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName'=>$page->getShortName()]));
return view('pages/delete', ['book' => $page->book, 'page' => $page, 'current' => $page]);
}
/**
* Remove the specified page from storage.
* @param $bookSlug
* @param $pageSlug
* @param string $bookSlug
* @param string $pageSlug
* @return Response
* @internal param int $id
*/
public function destroy($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$book = $page->book;
$this->checkOwnablePermission('page-delete', $page);
Activity::addMessage('page_delete', $book->id, $page->name);
session()->flash('success', 'Page deleted');
$this->pageRepo->destroy($page);
session()->flash('success', trans('entities.pages_delete_success'));
$this->entityRepo->destroyPage($page);
return redirect($book->getUrl());
}
/**
* Remove the specified draft page from storage.
* @param $bookSlug
* @param $pageId
* @param string $bookSlug
* @param int $pageId
* @return Response
* @throws NotFoundException
*/
public function destroyDraft($bookSlug, $pageId)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getById($pageId, true);
$page = $this->entityRepo->getById('page', $pageId, true);
$book = $page->book;
$this->checkOwnablePermission('page-update', $page);
session()->flash('success', 'Draft deleted');
$this->pageRepo->destroy($page);
session()->flash('success', trans('entities.pages_delete_draft_success'));
$this->entityRepo->destroyPage($page);
return redirect($book->getUrl());
}
/**
* Shows the last revisions for this page.
* @param $bookSlug
* @param $pageSlug
* @param string $bookSlug
* @param string $pageSlug
* @return \Illuminate\View\View
*/
public function showRevisions($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$this->setPageTitle('Revisions For ' . $page->getShortName());
return view('pages/revisions', ['page' => $page, 'book' => $book, 'current' => $page]);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName'=>$page->getShortName()]));
return view('pages/revisions', ['page' => $page, 'book' => $page->book, 'current' => $page]);
}
/**
* Shows a preview of a single revision
* @param $bookSlug
* @param $pageSlug
* @param $revisionId
* @param string $bookSlug
* @param string $pageSlug
* @param int $revisionId
* @return \Illuminate\View\View
*/
public function showRevision($bookSlug, $pageSlug, $revisionId)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$revision = $this->pageRepo->getRevisionById($revisionId);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$revision = $this->entityRepo->getById('page_revision', $revisionId, false);
$page->fill($revision->toArray());
$this->setPageTitle('Page Revision For ' . $page->getShortName());
return view('pages/revision', ['page' => $page, 'book' => $book]);
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
return view('pages/revision', [
'page' => $page,
'book' => $page->book,
]);
}
/**
* Shows the changes of a single revision
* @param string $bookSlug
* @param string $pageSlug
* @param int $revisionId
* @return \Illuminate\View\View
*/
public function showRevisionChanges($bookSlug, $pageSlug, $revisionId)
{
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$revision = $this->entityRepo->getById('page_revision', $revisionId);
$prev = $revision->getPrevious();
$prevContent = ($prev === null) ? '' : $prev->html;
$diff = (new Htmldiff)->diff($prevContent, $revision->html);
$page->fill($revision->toArray());
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
return view('pages/revision', [
'page' => $page,
'book' => $page->book,
'diff' => $diff,
]);
}
/**
* Restores a page using the content of the specified revision.
* @param $bookSlug
* @param $pageSlug
* @param $revisionId
* @param string $bookSlug
* @param string $pageSlug
* @param int $revisionId
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function restoreRevision($bookSlug, $pageSlug, $revisionId)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$this->checkOwnablePermission('page-update', $page);
$page = $this->pageRepo->restoreRevision($page, $book, $revisionId);
Activity::add($page, 'page_restore', $book->id);
$page = $this->entityRepo->restorePageRevision($page, $page->book, $revisionId);
Activity::add($page, 'page_restore', $page->book->id);
return redirect($page->getUrl());
}
/**
* Exports a page to pdf format using barryvdh/laravel-dompdf wrapper.
* https://github.com/barryvdh/laravel-dompdf
* @param $bookSlug
* @param $pageSlug
* @param string $bookSlug
* @param string $pageSlug
* @return \Illuminate\Http\Response
*/
public function exportPdf($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$pdfContent = $this->exportService->pageToPdf($page);
// return $pdfContent;
return response()->make($pdfContent, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . $pageSlug . '.pdf'
@@ -374,14 +442,13 @@ class PageController extends Controller
/**
* Export a page to a self-contained HTML file.
* @param $bookSlug
* @param $pageSlug
* @param string $bookSlug
* @param string $pageSlug
* @return \Illuminate\Http\Response
*/
public function exportHtml($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$containedHtml = $this->exportService->pageToContainedHtml($page);
return response()->make($containedHtml, 200, [
'Content-Type' => 'application/octet-stream',
@@ -391,14 +458,13 @@ class PageController extends Controller
/**
* Export a page to a simple plaintext .txt file.
* @param $bookSlug
* @param $pageSlug
* @param string $bookSlug
* @param string $pageSlug
* @return \Illuminate\Http\Response
*/
public function exportPlainText($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$containedHtml = $this->exportService->pageToPlainText($page);
return response()->make($containedHtml, 200, [
'Content-Type' => 'application/octet-stream',
@@ -412,9 +478,9 @@ class PageController extends Controller
*/
public function showRecentlyCreated()
{
$pages = $this->pageRepo->getRecentlyCreatedPaginated(20)->setPath(baseUrl('/pages/recently-created'));
$pages = $this->entityRepo->getRecentlyCreatedPaginated('page', 20)->setPath(baseUrl('/pages/recently-created'));
return view('pages/detailed-listing', [
'title' => 'Recently Created Pages',
'title' => trans('entities.recently_created_pages'),
'pages' => $pages
]);
}
@@ -425,23 +491,22 @@ class PageController extends Controller
*/
public function showRecentlyUpdated()
{
$pages = $this->pageRepo->getRecentlyUpdatedPaginated(20)->setPath(baseUrl('/pages/recently-updated'));
$pages = $this->entityRepo->getRecentlyUpdatedPaginated('page', 20)->setPath(baseUrl('/pages/recently-updated'));
return view('pages/detailed-listing', [
'title' => 'Recently Updated Pages',
'title' => trans('entities.recently_updated_pages'),
'pages' => $pages
]);
}
/**
* Show the Restrictions view.
* @param $bookSlug
* @param $pageSlug
* @param string $bookSlug
* @param string $pageSlug
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function showRestrict($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$this->checkOwnablePermission('restrictions-manage', $page);
$roles = $this->userRepo->getRestrictableRoles();
return view('pages/restrictions', [
@@ -452,34 +517,32 @@ class PageController extends Controller
/**
* Show the view to choose a new parent to move a page into.
* @param $bookSlug
* @param $pageSlug
* @param string $bookSlug
* @param string $pageSlug
* @return mixed
* @throws NotFoundException
*/
public function showMove($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$this->checkOwnablePermission('page-update', $page);
return view('pages/move', [
'book' => $book,
'book' => $page->book,
'page' => $page
]);
}
/**
* Does the action of moving the location of a page
* @param $bookSlug
* @param $pageSlug
* @param string $bookSlug
* @param string $pageSlug
* @param Request $request
* @return mixed
* @throws NotFoundException
*/
public function move($bookSlug, $pageSlug, Request $request)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$this->checkOwnablePermission('page-update', $page);
$entitySelection = $request->get('entity_selection', null);
@@ -491,40 +554,34 @@ class PageController extends Controller
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
$parent = false;
if ($entityType == 'chapter') {
$parent = $this->chapterRepo->getById($entityId);
} else if ($entityType == 'book') {
$parent = $this->bookRepo->getById($entityId);
}
if ($parent === false || $parent === null) {
session()->flash('The selected Book or Chapter was not found');
try {
$parent = $this->entityRepo->getById($entityType, $entityId);
} catch (\Exception $e) {
session()->flash(trans('entities.selected_book_chapter_not_found'));
return redirect()->back();
}
$this->pageRepo->changePageParent($page, $parent);
$this->entityRepo->changePageParent($page, $parent);
Activity::add($page, 'page_move', $page->book->id);
session()->flash('success', sprintf('Page moved to "%s"', $parent->name));
session()->flash('success', trans('entities.pages_move_success', ['parentName' => $parent->name]));
return redirect($page->getUrl());
}
/**
* Set the permissions for this page.
* @param $bookSlug
* @param $pageSlug
* @param string $bookSlug
* @param string $pageSlug
* @param Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function restrict($bookSlug, $pageSlug, Request $request)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
$this->checkOwnablePermission('restrictions-manage', $page);
$this->pageRepo->updateEntityPermissionsFromRequest($request, $page);
session()->flash('success', 'Page Permissions Updated');
$this->entityRepo->updateEntityPermissionsFromRequest($request, $page);
session()->flash('success', trans('entities.pages_permissions_success'));
return redirect($page->getUrl());
}

View File

@@ -2,9 +2,7 @@
use BookStack\Exceptions\PermissionsException;
use BookStack\Repos\PermissionsRepo;
use BookStack\Services\PermissionService;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
class PermissionController extends Controller
{
@@ -55,7 +53,7 @@ class PermissionController extends Controller
]);
$this->permissionsRepo->saveNewRole($request->all());
session()->flash('success', 'Role successfully created');
session()->flash('success', trans('settings.role_create_success'));
return redirect('/settings/roles');
}
@@ -69,7 +67,7 @@ class PermissionController extends Controller
{
$this->checkPermission('user-roles-manage');
$role = $this->permissionsRepo->getRoleById($id);
if ($role->hidden) throw new PermissionsException('This role cannot be edited');
if ($role->hidden) throw new PermissionsException(trans('errors.role_cannot_be_edited'));
return view('settings/roles/edit', ['role' => $role]);
}
@@ -88,7 +86,7 @@ class PermissionController extends Controller
]);
$this->permissionsRepo->updateRole($id, $request->all());
session()->flash('success', 'Role successfully updated');
session()->flash('success', trans('settings.role_update_success'));
return redirect('/settings/roles');
}
@@ -103,7 +101,7 @@ class PermissionController extends Controller
$this->checkPermission('user-roles-manage');
$role = $this->permissionsRepo->getRoleById($id);
$roles = $this->permissionsRepo->getAllRolesExcept($role);
$blankRole = $role->newInstance(['display_name' => 'Don\'t migrate users']);
$blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]);
$roles->prepend($blankRole);
return view('settings/roles/delete', ['role' => $role, 'roles' => $roles]);
}
@@ -126,7 +124,7 @@ class PermissionController extends Controller
return redirect()->back();
}
session()->flash('success', 'Role successfully deleted');
session()->flash('success', trans('settings.role_delete_success'));
return redirect('/settings/roles');
}
}

View File

@@ -1,34 +1,22 @@
<?php
namespace BookStack\Http\Controllers;
<?php namespace BookStack\Http\Controllers;
use BookStack\Repos\EntityRepo;
use BookStack\Services\ViewService;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
use BookStack\Repos\BookRepo;
use BookStack\Repos\ChapterRepo;
use BookStack\Repos\PageRepo;
class SearchController extends Controller
{
protected $pageRepo;
protected $bookRepo;
protected $chapterRepo;
protected $entityRepo;
protected $viewService;
/**
* SearchController constructor.
* @param PageRepo $pageRepo
* @param BookRepo $bookRepo
* @param ChapterRepo $chapterRepo
* @param EntityRepo $entityRepo
* @param ViewService $viewService
*/
public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo, ViewService $viewService)
public function __construct(EntityRepo $entityRepo, ViewService $viewService)
{
$this->pageRepo = $pageRepo;
$this->bookRepo = $bookRepo;
$this->chapterRepo = $chapterRepo;
$this->entityRepo = $entityRepo;
$this->viewService = $viewService;
parent::__construct();
}
@@ -46,10 +34,10 @@ class SearchController extends Controller
}
$searchTerm = $request->get('term');
$paginationAppends = $request->only('term');
$pages = $this->pageRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
$books = $this->bookRepo->getBySearch($searchTerm, 10, $paginationAppends);
$chapters = $this->chapterRepo->getBySearch($searchTerm, [], 10, $paginationAppends);
$this->setPageTitle('Search For ' . $searchTerm);
$pages = $this->entityRepo->getBySearch('page', $searchTerm, [], 20, $paginationAppends);
$books = $this->entityRepo->getBySearch('book', $searchTerm, [], 10, $paginationAppends);
$chapters = $this->entityRepo->getBySearch('chapter', $searchTerm, [], 10, $paginationAppends);
$this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm]));
return view('search/all', [
'pages' => $pages,
'books' => $books,
@@ -69,11 +57,11 @@ class SearchController extends Controller
$searchTerm = $request->get('term');
$paginationAppends = $request->only('term');
$pages = $this->pageRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
$this->setPageTitle('Page Search For ' . $searchTerm);
$pages = $this->entityRepo->getBySearch('page', $searchTerm, [], 20, $paginationAppends);
$this->setPageTitle(trans('entities.search_page_for_term', ['term' => $searchTerm]));
return view('search/entity-search-list', [
'entities' => $pages,
'title' => 'Page Search Results',
'title' => trans('entities.search_results_page'),
'searchTerm' => $searchTerm
]);
}
@@ -89,11 +77,11 @@ class SearchController extends Controller
$searchTerm = $request->get('term');
$paginationAppends = $request->only('term');
$chapters = $this->chapterRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
$this->setPageTitle('Chapter Search For ' . $searchTerm);
$chapters = $this->entityRepo->getBySearch('chapter', $searchTerm, [], 20, $paginationAppends);
$this->setPageTitle(trans('entities.search_chapter_for_term', ['term' => $searchTerm]));
return view('search/entity-search-list', [
'entities' => $chapters,
'title' => 'Chapter Search Results',
'title' => trans('entities.search_results_chapter'),
'searchTerm' => $searchTerm
]);
}
@@ -109,11 +97,11 @@ class SearchController extends Controller
$searchTerm = $request->get('term');
$paginationAppends = $request->only('term');
$books = $this->bookRepo->getBySearch($searchTerm, 20, $paginationAppends);
$this->setPageTitle('Book Search For ' . $searchTerm);
$books = $this->entityRepo->getBySearch('book', $searchTerm, [], 20, $paginationAppends);
$this->setPageTitle(trans('entities.search_book_for_term', ['term' => $searchTerm]));
return view('search/entity-search-list', [
'entities' => $books,
'title' => 'Book Search Results',
'title' => trans('entities.search_results_book'),
'searchTerm' => $searchTerm
]);
}
@@ -132,8 +120,8 @@ class SearchController extends Controller
}
$searchTerm = $request->get('term');
$searchWhereTerms = [['book_id', '=', $bookId]];
$pages = $this->pageRepo->getBySearch($searchTerm, $searchWhereTerms);
$chapters = $this->chapterRepo->getBySearch($searchTerm, $searchWhereTerms);
$pages = $this->entityRepo->getBySearch('page', $searchTerm, $searchWhereTerms);
$chapters = $this->entityRepo->getBySearch('chapter', $searchTerm, $searchWhereTerms);
return view('search/book', ['pages' => $pages, 'chapters' => $chapters, 'searchTerm' => $searchTerm]);
}
@@ -152,9 +140,11 @@ class SearchController extends Controller
// Search for entities otherwise show most popular
if ($searchTerm !== false) {
if ($entityTypes->contains('page')) $entities = $entities->merge($this->pageRepo->getBySearch($searchTerm)->items());
if ($entityTypes->contains('chapter')) $entities = $entities->merge($this->chapterRepo->getBySearch($searchTerm)->items());
if ($entityTypes->contains('book')) $entities = $entities->merge($this->bookRepo->getBySearch($searchTerm)->items());
foreach (['page', 'chapter', 'book'] as $entityType) {
if ($entityTypes->contains($entityType)) {
$entities = $entities->merge($this->entityRepo->getBySearch($entityType, $searchTerm)->items());
}
}
$entities = $entities->sortByDesc('title_relevance');
} else {
$entityNames = $entityTypes->map(function ($type) {

View File

@@ -1,8 +1,7 @@
<?php namespace BookStack\Http\Controllers;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
use Illuminate\Http\Response;
use Setting;
class SettingController extends Controller
@@ -17,10 +16,7 @@ class SettingController extends Controller
$this->setPageTitle('Settings');
// Get application version
$version = false;
if (function_exists('exec')) {
$version = exec('git describe --always --tags ');
}
$version = trim(file_get_contents(base_path('version')));
return view('settings/index', ['version' => $version]);
}
@@ -42,7 +38,7 @@ class SettingController extends Controller
Setting::put($key, $value);
}
session()->flash('success', 'Settings Saved');
session()->flash('success', trans('settings.settings_save_success'));
return redirect('/settings');
}

View File

@@ -2,7 +2,6 @@
use BookStack\Repos\TagRepo;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
class TagController extends Controller
{
@@ -16,12 +15,14 @@ class TagController extends Controller
public function __construct(TagRepo $tagRepo)
{
$this->tagRepo = $tagRepo;
parent::__construct();
}
/**
* Get all the Tags for a particular entity
* @param $entityType
* @param $entityId
* @return \Illuminate\Http\JsonResponse
*/
public function getForEntity($entityType, $entityId)
{
@@ -29,29 +30,10 @@ class TagController extends Controller
return response()->json($tags);
}
/**
* Update the tags for a particular entity.
* @param $entityType
* @param $entityId
* @param Request $request
* @return mixed
*/
public function updateForEntity($entityType, $entityId, Request $request)
{
$entity = $this->tagRepo->getEntity($entityType, $entityId, 'update');
if ($entity === null) return $this->jsonError("Entity not found", 404);
$inputTags = $request->input('tags');
$tags = $this->tagRepo->saveTagsToEntity($entity, $inputTags);
return response()->json([
'tags' => $tags,
'message' => 'Tags successfully updated'
]);
}
/**
* Get tag name suggestions from a given search term.
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getNameSuggestions(Request $request)
{
@@ -63,6 +45,7 @@ class TagController extends Controller
/**
* Get tag value suggestions from a given search term.
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getValueSuggestions(Request $request)
{

View File

@@ -1,12 +1,8 @@
<?php
<?php namespace BookStack\Http\Controllers;
namespace BookStack\Http\Controllers;
use BookStack\Activity;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use BookStack\Http\Requests;
use BookStack\Repos\UserRepo;
use BookStack\Services\SocialAuthService;
use BookStack\User;
@@ -43,7 +39,7 @@ class UserController extends Controller
'sort' => $request->has('sort') ? $request->get('sort') : 'name',
];
$users = $this->userRepo->getAllUsersPaginatedAndSorted(20, $listDetails);
$this->setPageTitle('Users');
$this->setPageTitle(trans('settings.users'));
$users->appends($listDetails);
return view('users/index', ['users' => $users, 'listDetails' => $listDetails]);
}
@@ -56,7 +52,7 @@ class UserController extends Controller
{
$this->checkPermission('users-manage');
$authMethod = config('auth.method');
$roles = $this->userRepo->getAssignableRoles();
$roles = $this->userRepo->getAllRoles();
return view('users/create', ['authMethod' => $authMethod, 'roles' => $roles]);
}
@@ -82,7 +78,6 @@ class UserController extends Controller
}
$this->validate($request, $validationRules);
$user = $this->user->fill($request->all());
if ($authMethod === 'standard') {
@@ -100,9 +95,14 @@ class UserController extends Controller
// Get avatar from gravatar and save
if (!config('services.disable_services')) {
$avatar = \Images::saveUserGravatar($user);
$user->avatar()->associate($avatar);
$user->save();
try {
$avatar = \Images::saveUserGravatar($user);
$user->avatar()->associate($avatar);
$user->save();
} catch (Exception $e) {
\Log::error('Failed to save user gravatar image');
}
}
return redirect('/settings/users');
@@ -120,12 +120,13 @@ class UserController extends Controller
return $this->currentUser->id == $id;
});
$authMethod = config('auth.method');
$user = $this->user->findOrFail($id);
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
$this->setPageTitle('User Profile');
$roles = $this->userRepo->getAssignableRoles();
$this->setPageTitle(trans('settings.user_profile'));
$roles = $this->userRepo->getAllRoles();
return view('users/edit', ['user' => $user, 'activeSocialDrivers' => $activeSocialDrivers, 'authMethod' => $authMethod, 'roles' => $roles]);
}
@@ -146,9 +147,8 @@ class UserController extends Controller
'name' => 'min:2',
'email' => 'min:2|email|unique:users,email,' . $id,
'password' => 'min:5|required_with:password_confirm',
'password-confirm' => 'same:password|required_with:password'
], [
'password-confirm.required_with' => 'Password confirmation required'
'password-confirm' => 'same:password|required_with:password',
'setting' => 'array'
]);
$user = $this->user->findOrFail($id);
@@ -171,8 +171,15 @@ class UserController extends Controller
$user->external_auth_id = $request->get('external_auth_id');
}
// Save an user-specific settings
if ($request->has('setting')) {
foreach ($request->get('setting') as $key => $value) {
setting()->putUser($user, $key, $value);
}
}
$user->save();
session()->flash('success', 'User successfully updated');
session()->flash('success', trans('settings.users_edit_success'));
$redirectUrl = userCan('users-manage') ? '/settings/users' : '/settings/users/' . $user->id;
return redirect($redirectUrl);
@@ -180,7 +187,7 @@ class UserController extends Controller
/**
* Show the user delete page.
* @param $id
* @param int $id
* @return \Illuminate\View\View
*/
public function delete($id)
@@ -190,7 +197,7 @@ class UserController extends Controller
});
$user = $this->user->findOrFail($id);
$this->setPageTitle('Delete User ' . $user->name);
$this->setPageTitle(trans('settings.users_delete_named', ['userName' => $user->name]));
return view('users/delete', ['user' => $user]);
}
@@ -209,12 +216,17 @@ class UserController extends Controller
$user = $this->userRepo->getById($id);
if ($this->userRepo->isOnlyAdmin($user)) {
session()->flash('error', 'You cannot delete the only admin');
session()->flash('error', trans('errors.users_cannot_delete_only_admin'));
return redirect($user->getEditUrl());
}
if ($user->system_name === 'public') {
session()->flash('error', trans('errors.users_cannot_delete_guest'));
return redirect($user->getEditUrl());
}
$this->userRepo->destroy($user);
session()->flash('success', 'User successfully removed');
session()->flash('success', trans('settings.users_delete_success'));
return redirect('/settings/users');
}

View File

@@ -1,6 +1,4 @@
<?php
namespace BookStack\Http;
<?php namespace BookStack\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
@@ -9,15 +7,33 @@ class Kernel extends HttpKernel
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
\Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode::class,
\BookStack\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\BookStack\Http\Middleware\VerifyCsrfToken::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\BookStack\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\BookStack\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\BookStack\Http\Middleware\Localization::class
],
'api' => [
'throttle:60,1',
'bindings',
],
];
/**
@@ -26,6 +42,7 @@ class Kernel extends HttpKernel
* @var array
*/
protected $routeMiddleware = [
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'auth' => \BookStack\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'guest' => \BookStack\Http\Middleware\RedirectIfAuthenticated::class,

View File

@@ -4,8 +4,6 @@ namespace BookStack\Http\Middleware;
use Closure;
use Illuminate\Contracts\Auth\Guard;
use BookStack\Exceptions\UserRegistrationException;
use Setting;
class Authenticate
{
@@ -33,7 +31,7 @@ class Authenticate
public function handle($request, Closure $next)
{
if ($this->auth->check() && setting('registration-confirmation') && !$this->auth->user()->email_confirmed) {
return redirect()->guest(baseUrl('/register/confirm/awaiting'));
return redirect(baseUrl('/register/confirm/awaiting'));
}
if ($this->auth->guest() && !setting('app-public')) {

View File

@@ -0,0 +1,23 @@
<?php namespace BookStack\Http\Middleware;
use Carbon\Carbon;
use Closure;
class Localization
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
$defaultLang = config('app.locale');
$locale = setting()->getUser(user(), 'language', $defaultLang);
app()->setLocale($locale);
Carbon::setLocale($locale);
return $next($request);
}
}

View File

@@ -1,6 +1,4 @@
<?php
namespace BookStack\Http\Middleware;
<?php namespace BookStack\Http\Middleware;
use Closure;
use Illuminate\Contracts\Auth\Guard;
@@ -34,7 +32,8 @@ class RedirectIfAuthenticated
*/
public function handle($request, Closure $next)
{
if ($this->auth->check()) {
$requireConfirmation = setting('registration-confirmation');
if ($this->auth->check() && (!$requireConfirmation || ($requireConfirmation && $this->auth->user()->email_confirmed))) {
return redirect('/');
}

View File

@@ -1,21 +0,0 @@
<?php
namespace BookStack\Jobs;
use Illuminate\Bus\Queueable;
abstract class Job
{
/*
|--------------------------------------------------------------------------
| Queueable Jobs
|--------------------------------------------------------------------------
|
| This job base class provides a central location to place any logic that
| is shared across all of your jobs. The trait included with the class
| provides access to the "queueOn" and "delay" queue helper methods.
|
*/
use Queueable;
}

View File

View File

@@ -0,0 +1,49 @@
<?php
namespace BookStack\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class ConfirmEmail extends Notification
{
public $token;
/**
* Create a new notification instance.
* @param string $token
*/
public function __construct($token)
{
$this->token = $token;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* 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 (new MailMessage)
->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));
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace BookStack\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class ResetPassword extends Notification
{
/**
* The password reset token.
*
* @var string
*/
public $token;
/**
* Create a notification instance.
*
* @param string $token
*/
public function __construct($token)
{
$this->token = $token;
}
/**
* Get the notification's channels.
*
* @param mixed $notifiable
* @return array|string
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Build the mail representation of the notification.
*
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail()
{
return (new MailMessage)
->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))
->line(trans('auth.email_reset_not_requested'));
}
}

View File

@@ -7,6 +7,10 @@ class Page extends Entity
protected $simpleAttributes = ['name', 'id', 'slug'];
protected $with = ['book'];
protected $fieldsToSearch = ['name', 'text'];
/**
* Converts this page into a simplified array.
* @return mixed
@@ -54,6 +58,15 @@ class Page extends Entity
return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc');
}
/**
* Get the attachments assigned to this page.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function attachments()
{
return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
}
/**
* Get the url for this page.
* @param string|bool $path
@@ -63,13 +76,13 @@ class Page extends Entity
{
$bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
$midText = $this->draft ? '/draft/' : '/page/';
$idComponent = $this->draft ? $this->id : $this->slug;
$idComponent = $this->draft ? $this->id : urlencode($this->slug);
if ($path !== false) {
return baseUrl('/books/' . $bookSlug . $midText . $idComponent . '/' . trim($path, '/'));
return baseUrl('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/'));
}
return baseUrl('/books/' . $bookSlug . $midText . $idComponent);
return baseUrl('/books/' . urlencode($bookSlug) . $midText . $idComponent);
}
/**

View File

@@ -25,11 +25,26 @@ class PageRevision extends Model
/**
* Get the url for this revision.
* @param null|string $path
* @return string
*/
public function getUrl()
public function getUrl($path = null)
{
return $this->page->getUrl() . '/revisions/' . $this->id;
$url = $this->page->getUrl() . '/revisions/' . $this->id;
if ($path) return $url . '/' . trim($path, '/');
return $url;
}
/**
* Get the previous revision for the same page if existing
* @return \BookStack\PageRevision|null
*/
public function getPrevious()
{
if ($id = static::where('page_id', '=', $this->page_id)->where('id', '<', $this->id)->max('id')) {
return static::find($id);
}
return null;
}
}

View File

@@ -1,6 +1,7 @@
<?php namespace BookStack\Providers;
use Illuminate\Support\ServiceProvider;
use Validator;
class AppServiceProvider extends ServiceProvider
{
@@ -12,11 +13,10 @@ class AppServiceProvider extends ServiceProvider
public function boot()
{
// Custom validation methods
\Validator::extend('is_image', function($attribute, $value, $parameters, $validator) {
Validator::extend('is_image', function($attribute, $value, $parameters, $validator) {
$imageMimes = ['image/png', 'image/bmp', 'image/gif', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/webp'];
return in_array($value->getMimeType(), $imageMimes);
});
}
/**

View File

@@ -0,0 +1,26 @@
<?php
namespace BookStack\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Broadcast;
class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
// Broadcast::routes();
//
// /*
// * Authenticate the user's personal channel...
// */
// Broadcast::channel('BookStack.User.*', function ($user, $userId) {
// return (int) $user->id === (int) $userId;
// });
}
}

View File

@@ -21,13 +21,10 @@ class EventServiceProvider extends ServiceProvider
/**
* Register any other events for your application.
*
* @param \Illuminate\Contracts\Events\Dispatcher $events
* @return void
*/
public function boot(DispatcherContract $events)
public function boot()
{
parent::boot($events);
//
parent::boot();
}
}

View File

@@ -1,11 +1,12 @@
<?php namespace BookStack\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Pagination\PaginationServiceProvider as IlluminatePaginationServiceProvider;
use Illuminate\Pagination\Paginator;
class PaginationServiceProvider extends ServiceProvider
class PaginationServiceProvider extends IlluminatePaginationServiceProvider
{
/**
* Register the service provider.
*
@@ -13,6 +14,10 @@ class PaginationServiceProvider extends ServiceProvider
*/
public function register()
{
Paginator::viewFactoryResolver(function () {
return $this->app['view'];
});
Paginator::currentPathResolver(function () {
return baseUrl($this->app['request']->path());
});

View File

@@ -4,6 +4,7 @@ namespace BookStack\Providers;
use Illuminate\Routing\Router;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Route;
class RouteServiceProvider extends ServiceProvider
{
@@ -19,26 +20,54 @@ class RouteServiceProvider extends ServiceProvider
/**
* Define your route model bindings, pattern filters, etc.
*
* @param \Illuminate\Routing\Router $router
* @return void
*/
public function boot(Router $router)
public function boot()
{
//
parent::boot($router);
parent::boot();
}
/**
* Define the routes for the application.
*
* @param \Illuminate\Routing\Router $router
* @return void
*/
public function map(Router $router)
public function map()
{
$router->group(['namespace' => $this->namespace], function ($router) {
require app_path('Http/routes.php');
$this->mapWebRoutes();
// $this->mapApiRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
{
Route::group([
'middleware' => 'web',
'namespace' => $this->namespace,
], function ($router) {
require base_path('routes/web.php');
});
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*
* @return void
*/
protected function mapApiRoutes()
{
Route::group([
'middleware' => 'api',
'namespace' => $this->namespace,
'prefix' => 'api',
], function ($router) {
require base_path('routes/api.php');
});
}
}

View File

@@ -1,294 +0,0 @@
<?php namespace BookStack\Repos;
use Alpha\B;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
use BookStack\Book;
use Views;
class BookRepo extends EntityRepo
{
protected $pageRepo;
protected $chapterRepo;
/**
* BookRepo constructor.
* @param PageRepo $pageRepo
* @param ChapterRepo $chapterRepo
*/
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo)
{
$this->pageRepo = $pageRepo;
$this->chapterRepo = $chapterRepo;
parent::__construct();
}
/**
* Base query for getting books.
* Takes into account any restrictions.
* @return mixed
*/
private function bookQuery()
{
return $this->permissionService->enforceBookRestrictions($this->book, 'view');
}
/**
* Get the book that has the given id.
* @param $id
* @return mixed
*/
public function getById($id)
{
return $this->bookQuery()->findOrFail($id);
}
/**
* Get all books, Limited by count.
* @param int $count
* @return mixed
*/
public function getAll($count = 10)
{
$bookQuery = $this->bookQuery()->orderBy('name', 'asc');
if (!$count) return $bookQuery->get();
return $bookQuery->take($count)->get();
}
/**
* Get all books paginated.
* @param int $count
* @return mixed
*/
public function getAllPaginated($count = 10)
{
return $this->bookQuery()
->orderBy('name', 'asc')->paginate($count);
}
/**
* Get the latest books.
* @param int $count
* @return mixed
*/
public function getLatest($count = 10)
{
return $this->bookQuery()->orderBy('created_at', 'desc')->take($count)->get();
}
/**
* Gets the most recently viewed for a user.
* @param int $count
* @param int $page
* @return mixed
*/
public function getRecentlyViewed($count = 10, $page = 0)
{
return Views::getUserRecentlyViewed($count, $page, $this->book);
}
/**
* Gets the most viewed books.
* @param int $count
* @param int $page
* @return mixed
*/
public function getPopular($count = 10, $page = 0)
{
return Views::getPopular($count, $page, $this->book);
}
/**
* Get a book by slug
* @param $slug
* @return mixed
* @throws NotFoundException
*/
public function getBySlug($slug)
{
$book = $this->bookQuery()->where('slug', '=', $slug)->first();
if ($book === null) throw new NotFoundException('Book not found');
return $book;
}
/**
* Checks if a book exists.
* @param $id
* @return bool
*/
public function exists($id)
{
return $this->bookQuery()->where('id', '=', $id)->exists();
}
/**
* Get a new book instance from request input.
* @param array $input
* @return Book
*/
public function createFromInput($input)
{
$book = $this->book->newInstance($input);
$book->slug = $this->findSuitableSlug($book->name);
$book->created_by = auth()->user()->id;
$book->updated_by = auth()->user()->id;
$book->save();
$this->permissionService->buildJointPermissionsForEntity($book);
return $book;
}
/**
* Update the given book from user input.
* @param Book $book
* @param $input
* @return Book
*/
public function updateFromInput(Book $book, $input)
{
$book->fill($input);
$book->slug = $this->findSuitableSlug($book->name, $book->id);
$book->updated_by = auth()->user()->id;
$book->save();
$this->permissionService->buildJointPermissionsForEntity($book);
return $book;
}
/**
* Destroy the given book.
* @param Book $book
* @throws \Exception
*/
public function destroy(Book $book)
{
foreach ($book->pages as $page) {
$this->pageRepo->destroy($page);
}
foreach ($book->chapters as $chapter) {
$this->chapterRepo->destroy($chapter);
}
$book->views()->delete();
$book->permissions()->delete();
$this->permissionService->deleteJointPermissionsForEntity($book);
$book->delete();
}
/**
* Get the next child element priority.
* @param Book $book
* @return int
*/
public function getNewPriority($book)
{
$lastElem = $this->getChildren($book)->pop();
return $lastElem ? $lastElem->priority + 1 : 0;
}
/**
* @param string $slug
* @param bool|false $currentId
* @return bool
*/
public function doesSlugExist($slug, $currentId = false)
{
$query = $this->book->where('slug', '=', $slug);
if ($currentId) {
$query = $query->where('id', '!=', $currentId);
}
return $query->count() > 0;
}
/**
* Provides a suitable slug for the given book name.
* Ensures the returned slug is unique in the system.
* @param string $name
* @param bool|false $currentId
* @return string
*/
public function findSuitableSlug($name, $currentId = false)
{
$slug = Str::slug($name);
if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
while ($this->doesSlugExist($slug, $currentId)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}
return $slug;
}
/**
* Get all child objects of a book.
* Returns a sorted collection of Pages and Chapters.
* Loads the book slug onto child elements to prevent access database access for getting the slug.
* @param Book $book
* @param bool $filterDrafts
* @return mixed
*/
public function getChildren(Book $book, $filterDrafts = false)
{
$pageQuery = $book->pages()->where('chapter_id', '=', 0);
$pageQuery = $this->permissionService->enforcePageRestrictions($pageQuery, 'view');
if ($filterDrafts) {
$pageQuery = $pageQuery->where('draft', '=', false);
}
$pages = $pageQuery->get();
$chapterQuery = $book->chapters()->with(['pages' => function ($query) use ($filterDrafts) {
$this->permissionService->enforcePageRestrictions($query, 'view');
if ($filterDrafts) $query->where('draft', '=', false);
}]);
$chapterQuery = $this->permissionService->enforceChapterRestrictions($chapterQuery, 'view');
$chapters = $chapterQuery->get();
$children = $pages->values();
foreach ($chapters as $chapter) {
$children->push($chapter);
}
$bookSlug = $book->slug;
$children->each(function ($child) use ($bookSlug) {
$child->setAttribute('bookSlug', $bookSlug);
if ($child->isA('chapter')) {
$child->pages->each(function ($page) use ($bookSlug) {
$page->setAttribute('bookSlug', $bookSlug);
});
$child->pages = $child->pages->sortBy(function ($child, $key) {
$score = $child->priority;
if ($child->draft) $score -= 100;
return $score;
});
}
});
// Sort items with drafts first then by priority.
return $children->sortBy(function ($child, $key) {
$score = $child->priority;
if ($child->isA('page') && $child->draft) $score -= 100;
return $score;
});
}
/**
* Get books by search term.
* @param $term
* @param int $count
* @param array $paginationAppends
* @return mixed
*/
public function getBySearch($term, $count = 20, $paginationAppends = [])
{
$terms = $this->prepareSearchTerms($term);
$bookQuery = $this->permissionService->enforceBookRestrictions($this->book->fullTextSearchQuery(['name', 'description'], $terms));
$bookQuery = $this->addAdvancedSearchQueries($bookQuery, $term);
$books = $bookQuery->paginate($count)->appends($paginationAppends);
$words = join('|', explode(' ', preg_quote(trim($term), '/')));
foreach ($books as $book) {
//highlight
$result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $book->getExcerpt(100));
$book->searchSnippet = $result;
}
return $books;
}
}

View File

@@ -1,227 +0,0 @@
<?php namespace BookStack\Repos;
use Activity;
use BookStack\Book;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Support\Str;
use BookStack\Chapter;
class ChapterRepo extends EntityRepo
{
protected $pageRepo;
/**
* ChapterRepo constructor.
* @param $pageRepo
*/
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
parent::__construct();
}
/**
* Base query for getting chapters, Takes permissions into account.
* @return mixed
*/
private function chapterQuery()
{
return $this->permissionService->enforceChapterRestrictions($this->chapter, 'view');
}
/**
* Check if an id exists.
* @param $id
* @return bool
*/
public function idExists($id)
{
return $this->chapterQuery()->where('id', '=', $id)->count() > 0;
}
/**
* Get a chapter by a specific id.
* @param $id
* @return mixed
*/
public function getById($id)
{
return $this->chapterQuery()->findOrFail($id);
}
/**
* Get all chapters.
* @return \Illuminate\Database\Eloquent\Collection|static[]
*/
public function getAll()
{
return $this->chapterQuery()->all();
}
/**
* Get a chapter that has the given slug within the given book.
* @param $slug
* @param $bookId
* @return mixed
* @throws NotFoundException
*/
public function getBySlug($slug, $bookId)
{
$chapter = $this->chapterQuery()->where('slug', '=', $slug)->where('book_id', '=', $bookId)->first();
if ($chapter === null) throw new NotFoundException('Chapter not found');
return $chapter;
}
/**
* Get the child items for a chapter
* @param Chapter $chapter
*/
public function getChildren(Chapter $chapter)
{
$pages = $this->permissionService->enforcePageRestrictions($chapter->pages())->get();
// Sort items with drafts first then by priority.
return $pages->sortBy(function ($child, $key) {
$score = $child->priority;
if ($child->draft) $score -= 100;
return $score;
});
}
/**
* Create a new chapter from request input.
* @param $input
* @param Book $book
* @return Chapter
*/
public function createFromInput($input, Book $book)
{
$chapter = $this->chapter->newInstance($input);
$chapter->slug = $this->findSuitableSlug($chapter->name, $book->id);
$chapter->created_by = auth()->user()->id;
$chapter->updated_by = auth()->user()->id;
$chapter = $book->chapters()->save($chapter);
$this->permissionService->buildJointPermissionsForEntity($chapter);
return $chapter;
}
/**
* Destroy a chapter and its relations by providing its slug.
* @param Chapter $chapter
*/
public function destroy(Chapter $chapter)
{
if (count($chapter->pages) > 0) {
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->save();
}
}
Activity::removeEntity($chapter);
$chapter->views()->delete();
$chapter->permissions()->delete();
$this->permissionService->deleteJointPermissionsForEntity($chapter);
$chapter->delete();
}
/**
* Check if a chapter's slug exists.
* @param $slug
* @param $bookId
* @param bool|false $currentId
* @return bool
*/
public function doesSlugExist($slug, $bookId, $currentId = false)
{
$query = $this->chapter->where('slug', '=', $slug)->where('book_id', '=', $bookId);
if ($currentId) {
$query = $query->where('id', '!=', $currentId);
}
return $query->count() > 0;
}
/**
* Finds a suitable slug for the provided name.
* Checks database to prevent duplicate slugs.
* @param $name
* @param $bookId
* @param bool|false $currentId
* @return string
*/
public function findSuitableSlug($name, $bookId, $currentId = false)
{
$slug = Str::slug($name);
if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
while ($this->doesSlugExist($slug, $bookId, $currentId)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}
return $slug;
}
/**
* Get a new priority value for a new page to be added
* to the given chapter.
* @param Chapter $chapter
* @return int
*/
public function getNewPriority(Chapter $chapter)
{
$lastPage = $chapter->pages->last();
return $lastPage !== null ? $lastPage->priority + 1 : 0;
}
/**
* Get chapters by the given search term.
* @param string $term
* @param array $whereTerms
* @param int $count
* @param array $paginationAppends
* @return mixed
*/
public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = [])
{
$terms = $this->prepareSearchTerms($term);
$chapterQuery = $this->permissionService->enforceChapterRestrictions($this->chapter->fullTextSearchQuery(['name', 'description'], $terms, $whereTerms));
$chapterQuery = $this->addAdvancedSearchQueries($chapterQuery, $term);
$chapters = $chapterQuery->paginate($count)->appends($paginationAppends);
$words = join('|', explode(' ', preg_quote(trim($term), '/')));
foreach ($chapters as $chapter) {
//highlight
$result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $chapter->getExcerpt(100));
$chapter->searchSnippet = $result;
}
return $chapters;
}
/**
* Changes the book relation of this chapter.
* @param $bookId
* @param Chapter $chapter
* @param bool $rebuildPermissions
* @return Chapter
*/
public function changeBook($bookId, Chapter $chapter, $rebuildPermissions = false)
{
$chapter->book_id = $bookId;
// Update related activity
foreach ($chapter->activity as $activity) {
$activity->book_id = $bookId;
$activity->save();
}
$chapter->slug = $this->findSuitableSlug($chapter->name, $bookId, $chapter->id);
$chapter->save();
// Update all child pages
foreach ($chapter->pages as $page) {
$this->pageRepo->changeBook($bookId, $page);
}
// Update permissions if applicable
if ($rebuildPermissions) {
$chapter->load('book');
$this->permissionService->buildJointPermissionsForEntity($chapter->book);
}
return $chapter;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ use BookStack\Image;
use BookStack\Page;
use BookStack\Services\ImageService;
use BookStack\Services\PermissionService;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Setting;
use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -191,7 +192,12 @@ class ImageRepo
*/
public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
{
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
try {
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
} catch (FileNotFoundException $exception) {
$image->delete();
return [];
}
}

View File

@@ -1,650 +0,0 @@
<?php namespace BookStack\Repos;
use Activity;
use BookStack\Book;
use BookStack\Chapter;
use BookStack\Entity;
use BookStack\Exceptions\NotFoundException;
use Carbon\Carbon;
use DOMDocument;
use Illuminate\Support\Str;
use BookStack\Page;
use BookStack\PageRevision;
class PageRepo extends EntityRepo
{
protected $pageRevision;
protected $tagRepo;
/**
* PageRepo constructor.
* @param PageRevision $pageRevision
* @param TagRepo $tagRepo
*/
public function __construct(PageRevision $pageRevision, TagRepo $tagRepo)
{
$this->pageRevision = $pageRevision;
$this->tagRepo = $tagRepo;
parent::__construct();
}
/**
* Base query for getting pages, Takes restrictions into account.
* @param bool $allowDrafts
* @return mixed
*/
private function pageQuery($allowDrafts = false)
{
$query = $this->permissionService->enforcePageRestrictions($this->page, 'view');
if (!$allowDrafts) {
$query = $query->where('draft', '=', false);
}
return $query;
}
/**
* Get a page via a specific ID.
* @param $id
* @param bool $allowDrafts
* @return mixed
*/
public function getById($id, $allowDrafts = false)
{
return $this->pageQuery($allowDrafts)->findOrFail($id);
}
/**
* Get a page identified by the given slug.
* @param $slug
* @param $bookId
* @return mixed
* @throws NotFoundException
*/
public function getBySlug($slug, $bookId)
{
$page = $this->pageQuery()->where('slug', '=', $slug)->where('book_id', '=', $bookId)->first();
if ($page === null) throw new NotFoundException('Page not found');
return $page;
}
/**
* Search through page revisions and retrieve
* the last page in the current book that
* has a slug equal to the one given.
* @param $pageSlug
* @param $bookSlug
* @return null | Page
*/
public function findPageUsingOldSlug($pageSlug, $bookSlug)
{
$revision = $this->pageRevision->where('slug', '=', $pageSlug)
->whereHas('page', function ($query) {
$this->permissionService->enforcePageRestrictions($query);
})
->where('type', '=', 'version')
->where('book_slug', '=', $bookSlug)->orderBy('created_at', 'desc')
->with('page')->first();
return $revision !== null ? $revision->page : null;
}
/**
* Get a new Page instance from the given input.
* @param $input
* @return Page
*/
public function newFromInput($input)
{
$page = $this->page->fill($input);
return $page;
}
/**
* Count the pages with a particular slug within a book.
* @param $slug
* @param $bookId
* @return mixed
*/
public function countBySlug($slug, $bookId)
{
return $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId)->count();
}
/**
* Save a new page into the system.
* Input validation must be done beforehand.
* @param array $input
* @param Book $book
* @param int $chapterId
* @return Page
*/
public function saveNew(array $input, Book $book, $chapterId = null)
{
$page = $this->newFromInput($input);
$page->slug = $this->findSuitableSlug($page->name, $book->id);
if ($chapterId) $page->chapter_id = $chapterId;
$page->html = $this->formatHtml($input['html']);
$page->text = strip_tags($page->html);
$page->created_by = auth()->user()->id;
$page->updated_by = auth()->user()->id;
$book->pages()->save($page);
return $page;
}
/**
* Publish a draft page to make it a normal page.
* Sets the slug and updates the content.
* @param Page $draftPage
* @param array $input
* @return Page
*/
public function publishDraft(Page $draftPage, array $input)
{
$draftPage->fill($input);
// Save page tags if present
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
}
$draftPage->slug = $this->findSuitableSlug($draftPage->name, $draftPage->book->id);
$draftPage->html = $this->formatHtml($input['html']);
$draftPage->text = strip_tags($draftPage->html);
$draftPage->draft = false;
$draftPage->save();
$this->saveRevision($draftPage, 'Initial Publish');
return $draftPage;
}
/**
* Get a new draft page instance.
* @param Book $book
* @param Chapter|bool $chapter
* @return static
*/
public function getDraftPage(Book $book, $chapter = false)
{
$page = $this->page->newInstance();
$page->name = 'New Page';
$page->created_by = auth()->user()->id;
$page->updated_by = auth()->user()->id;
$page->draft = true;
if ($chapter) $page->chapter_id = $chapter->id;
$book->pages()->save($page);
$this->permissionService->buildJointPermissionsForEntity($page);
return $page;
}
/**
* Formats a page's html to be tagged correctly
* within the system.
* @param string $htmlText
* @return string
*/
protected function formatHtml($htmlText)
{
if ($htmlText == '') return $htmlText;
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
$container = $doc->documentElement;
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
// Ensure no duplicate ids are used
$idArray = [];
foreach ($childNodes as $index => $childNode) {
/** @var \DOMElement $childNode */
if (get_class($childNode) !== 'DOMElement') continue;
// Overwrite id if not a BookStack custom id
if ($childNode->hasAttribute('id')) {
$id = $childNode->getAttribute('id');
if (strpos($id, 'bkmrk') === 0 && array_search($id, $idArray) === false) {
$idArray[] = $id;
continue;
};
}
// 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($childNode->nodeValue))), 0, 20);
$newId = urlencode($contentId);
$loopIndex = 0;
while (in_array($newId, $idArray)) {
$newId = urlencode($contentId . '-' . $loopIndex);
$loopIndex++;
}
$childNode->setAttribute('id', $newId);
$idArray[] = $newId;
}
// Generate inner html as a string
$html = '';
foreach ($childNodes as $childNode) {
$html .= $doc->saveHTML($childNode);
}
return $html;
}
/**
* Gets pages by a search term.
* Highlights page content for showing in results.
* @param string $term
* @param array $whereTerms
* @param int $count
* @param array $paginationAppends
* @return mixed
*/
public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = [])
{
$terms = $this->prepareSearchTerms($term);
$pageQuery = $this->permissionService->enforcePageRestrictions($this->page->fullTextSearchQuery(['name', 'text'], $terms, $whereTerms));
$pageQuery = $this->addAdvancedSearchQueries($pageQuery, $term);
$pages = $pageQuery->paginate($count)->appends($paginationAppends);
// Add highlights to page text.
$words = join('|', explode(' ', preg_quote(trim($term), '/')));
//lookahead/behind assertions ensures cut between words
$s = '\s\x00-/:-@\[-`{-~'; //character set for start/end of words
foreach ($pages as $page) {
preg_match_all('#(?<=[' . $s . ']).{1,30}((' . $words . ').{1,30})+(?=[' . $s . '])#uis', $page->text, $matches, PREG_SET_ORDER);
//delimiter between occurrences
$results = [];
foreach ($matches as $line) {
$results[] = htmlspecialchars($line[0], 0, 'UTF-8');
}
$matchLimit = 6;
if (count($results) > $matchLimit) {
$results = array_slice($results, 0, $matchLimit);
}
$result = join('... ', $results);
//highlight
$result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $result);
if (strlen($result) < 5) {
$result = $page->getExcerpt(80);
}
$page->searchSnippet = $result;
}
return $pages;
}
/**
* Search for image usage.
* @param $imageString
* @return mixed
*/
public function searchForImage($imageString)
{
$pages = $this->pageQuery()->where('html', 'like', '%' . $imageString . '%')->get();
foreach ($pages as $page) {
$page->url = $page->getUrl();
$page->html = '';
$page->text = '';
}
return count($pages) > 0 ? $pages : false;
}
/**
* Updates a page with any fillable data and saves it into the database.
* @param Page $page
* @param int $book_id
* @param string $input
* @return Page
*/
public function updatePage(Page $page, $book_id, $input)
{
// Hold the old details to compare later
$oldHtml = $page->html;
$oldName = $page->name;
// Prevent slug being updated if no name change
if ($page->name !== $input['name']) {
$page->slug = $this->findSuitableSlug($input['name'], $book_id, $page->id);
}
// Save page tags if present
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($page, $input['tags']);
}
// Update with new details
$userId = auth()->user()->id;
$page->fill($input);
$page->html = $this->formatHtml($input['html']);
$page->text = strip_tags($page->html);
if (setting('app-editor') !== 'markdown') $page->markdown = '';
$page->updated_by = $userId;
$page->save();
// Remove all update drafts for this user & page.
$this->userUpdateDraftsQuery($page, $userId)->delete();
// Save a revision after updating
if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $input['summary'] !== null) {
$this->saveRevision($page, $input['summary']);
}
return $page;
}
/**
* Restores a revision's content back into a page.
* @param Page $page
* @param Book $book
* @param int $revisionId
* @return Page
*/
public function restoreRevision(Page $page, Book $book, $revisionId)
{
$this->saveRevision($page);
$revision = $this->getRevisionById($revisionId);
$page->fill($revision->toArray());
$page->slug = $this->findSuitableSlug($page->name, $book->id, $page->id);
$page->text = strip_tags($page->html);
$page->updated_by = auth()->user()->id;
$page->save();
return $page;
}
/**
* Saves a page revision into the system.
* @param Page $page
* @param null|string $summary
* @return $this
*/
public function saveRevision(Page $page, $summary = null)
{
$revision = $this->pageRevision->fill($page->toArray());
if (setting('app-editor') !== 'markdown') $revision->markdown = '';
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
$revision->created_by = auth()->user()->id;
$revision->created_at = $page->updated_at;
$revision->type = 'version';
$revision->summary = $summary;
$revision->save();
// Clear old revisions
if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) {
$this->pageRevision->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc')->skip(50)->take(5)->delete();
}
return $revision;
}
/**
* Save a page update draft.
* @param Page $page
* @param array $data
* @return PageRevision
*/
public function saveUpdateDraft(Page $page, $data = [])
{
$userId = auth()->user()->id;
$drafts = $this->userUpdateDraftsQuery($page, $userId)->get();
if ($drafts->count() > 0) {
$draft = $drafts->first();
} else {
$draft = $this->pageRevision->newInstance();
$draft->page_id = $page->id;
$draft->slug = $page->slug;
$draft->book_slug = $page->book->slug;
$draft->created_by = $userId;
$draft->type = 'update_draft';
}
$draft->fill($data);
if (setting('app-editor') !== 'markdown') $draft->markdown = '';
$draft->save();
return $draft;
}
/**
* Update a draft page.
* @param Page $page
* @param array $data
* @return Page
*/
public function updateDraftPage(Page $page, $data = [])
{
$page->fill($data);
if (isset($data['html'])) {
$page->text = strip_tags($data['html']);
}
$page->save();
return $page;
}
/**
* The base query for getting user update drafts.
* @param Page $page
* @param $userId
* @return mixed
*/
private function userUpdateDraftsQuery(Page $page, $userId)
{
return $this->pageRevision->where('created_by', '=', $userId)
->where('type', 'update_draft')
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc');
}
/**
* Checks whether a user has a draft version of a particular page or not.
* @param Page $page
* @param $userId
* @return bool
*/
public function hasUserGotPageDraft(Page $page, $userId)
{
return $this->userUpdateDraftsQuery($page, $userId)->count() > 0;
}
/**
* Get the latest updated draft revision for a particular page and user.
* @param Page $page
* @param $userId
* @return mixed
*/
public function getUserPageDraft(Page $page, $userId)
{
return $this->userUpdateDraftsQuery($page, $userId)->first();
}
/**
* Get the notification message that informs the user that they are editing a draft page.
* @param PageRevision $draft
* @return string
*/
public function getUserPageDraftMessage(PageRevision $draft)
{
$message = 'You are currently editing a draft that was last saved ' . $draft->updated_at->diffForHumans() . '.';
if ($draft->page->updated_at->timestamp > $draft->updated_at->timestamp) {
$message .= "\n This page has been updated by since that time. It is recommended that you discard this draft.";
}
return $message;
}
/**
* Check if a page is being actively editing.
* Checks for edits since last page updated.
* Passing in a minuted range will check for edits
* within the last x minutes.
* @param Page $page
* @param null $minRange
* @return bool
*/
public function isPageEditingActive(Page $page, $minRange = null)
{
$draftSearch = $this->activePageEditingQuery($page, $minRange);
return $draftSearch->count() > 0;
}
/**
* Get a notification message concerning the editing activity on
* a particular page.
* @param Page $page
* @param null $minRange
* @return string
*/
public function getPageEditingActiveMessage(Page $page, $minRange = null)
{
$pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get();
$userMessage = $pageDraftEdits->count() > 1 ? $pageDraftEdits->count() . ' users have' : $pageDraftEdits->first()->createdBy->name . ' has';
$timeMessage = $minRange === null ? 'since the page was last updated' : 'in the last ' . $minRange . ' minutes';
$message = '%s started editing this page %s. Take care not to overwrite each other\'s updates!';
return sprintf($message, $userMessage, $timeMessage);
}
/**
* A query to check for active update drafts on a particular page.
* @param Page $page
* @param null $minRange
* @return mixed
*/
private function activePageEditingQuery(Page $page, $minRange = null)
{
$query = $this->pageRevision->where('type', '=', 'update_draft')
->where('page_id', '=', $page->id)
->where('updated_at', '>', $page->updated_at)
->where('created_by', '!=', auth()->user()->id)
->with('createdBy');
if ($minRange !== null) {
$query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange));
}
return $query;
}
/**
* Gets a single revision via it's id.
* @param $id
* @return mixed
*/
public function getRevisionById($id)
{
return $this->pageRevision->findOrFail($id);
}
/**
* Checks if a slug exists within a book already.
* @param $slug
* @param $bookId
* @param bool|false $currentId
* @return bool
*/
public function doesSlugExist($slug, $bookId, $currentId = false)
{
$query = $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId);
if ($currentId) $query = $query->where('id', '!=', $currentId);
return $query->count() > 0;
}
/**
* Changes the related book for the specified page.
* Changes the book id of any relations to the page that store the book id.
* @param int $bookId
* @param Page $page
* @return Page
*/
public function changeBook($bookId, Page $page)
{
$page->book_id = $bookId;
foreach ($page->activity as $activity) {
$activity->book_id = $bookId;
$activity->save();
}
$page->slug = $this->findSuitableSlug($page->name, $bookId, $page->id);
$page->save();
return $page;
}
/**
* Change the page's parent to the given entity.
* @param Page $page
* @param Entity $parent
*/
public function changePageParent(Page $page, Entity $parent)
{
$book = $parent->isA('book') ? $parent : $parent->book;
$page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
$page->save();
$page = $this->changeBook($book->id, $page);
$page->load('book');
$this->permissionService->buildJointPermissionsForEntity($book);
}
/**
* Gets a suitable slug for the resource
* @param string $name
* @param int $bookId
* @param bool|false $currentId
* @return string
*/
public function findSuitableSlug($name, $bookId, $currentId = false)
{
$slug = Str::slug($name);
if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
while ($this->doesSlugExist($slug, $bookId, $currentId)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}
return $slug;
}
/**
* Destroy a given page along with its dependencies.
* @param $page
*/
public function destroy(Page $page)
{
Activity::removeEntity($page);
$page->views()->delete();
$page->tags()->delete();
$page->revisions()->delete();
$page->permissions()->delete();
$this->permissionService->deleteJointPermissionsForEntity($page);
$page->delete();
}
/**
* Get the latest pages added to the system.
* @param $count
*/
public function getRecentlyCreatedPaginated($count = 20)
{
return $this->pageQuery()->orderBy('created_at', 'desc')->paginate($count);
}
/**
* Get the latest pages added to the system.
* @param $count
*/
public function getRecentlyUpdatedPaginated($count = 20)
{
return $this->pageQuery()->orderBy('updated_at', 'desc')->paginate($count);
}
}

View File

@@ -35,7 +35,7 @@ class PermissionsRepo
*/
public function getAllRoles()
{
return $this->role->where('hidden', '=', false)->get();
return $this->role->all();
}
/**
@@ -45,7 +45,7 @@ class PermissionsRepo
*/
public function getAllRolesExcept(Role $role)
{
return $this->role->where('id', '!=', $role->id)->where('hidden', '=', false)->get();
return $this->role->where('id', '!=', $role->id)->get();
}
/**
@@ -90,12 +90,10 @@ class PermissionsRepo
{
$role = $this->role->findOrFail($roleId);
if ($role->hidden) throw new PermissionsException("Cannot update a hidden role");
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions);
if ($role->name === 'admin') {
if ($role->system_name === 'admin') {
$permissions = $this->permission->all()->pluck('id')->toArray();
$role->permissions()->sync($permissions);
}
@@ -135,9 +133,9 @@ class PermissionsRepo
// Prevent deleting admin role or default registration role.
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
throw new PermissionsException('This role is a system role and cannot be deleted');
throw new PermissionsException(trans('errors.role_system_cannot_be_deleted'));
} else if ($role->id == setting('registration-role')) {
throw new PermissionsException('This role cannot be deleted while set as the default registration role.');
throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
}
if ($migrateRoleId) {

View File

@@ -38,7 +38,7 @@ class TagRepo
{
$entityInstance = $this->entity->getEntityInstance($entityType);
$searchQuery = $entityInstance->where('id', '=', $entityId)->with('tags');
$searchQuery = $this->permissionService->enforceEntityRestrictions($searchQuery, $action);
$searchQuery = $this->permissionService->enforceEntityRestrictions($entityType, $searchQuery, $action);
return $searchQuery->first();
}
@@ -121,7 +121,7 @@ class TagRepo
/**
* Create a new Tag instance from user input.
* @param $input
* @return static
* @return Tag
*/
protected function newInstanceFromInput($input)
{

View File

@@ -2,7 +2,7 @@
use BookStack\Role;
use BookStack\User;
use Setting;
use Exception;
class UserRepo
{
@@ -84,9 +84,14 @@ class UserRepo
// Get avatar from gravatar and save
if (!config('services.disable_services')) {
$avatar = \Images::saveUserGravatar($user);
$user->avatar()->associate($avatar);
$user->save();
try {
$avatar = \Images::saveUserGravatar($user);
$user->avatar()->associate($avatar);
$user->save();
} catch (Exception $e) {
$user->save();
\Log::error('Failed to save user gravatar image');
}
}
return $user;
@@ -163,13 +168,13 @@ class UserRepo
public function getRecentlyCreated(User $user, $count = 20)
{
return [
'pages' => $this->entityRepo->getRecentlyCreatedPages($count, 0, function ($query) use ($user) {
'pages' => $this->entityRepo->getRecentlyCreated('page', $count, 0, function ($query) use ($user) {
$query->where('created_by', '=', $user->id);
}),
'chapters' => $this->entityRepo->getRecentlyCreatedChapters($count, 0, function ($query) use ($user) {
'chapters' => $this->entityRepo->getRecentlyCreated('chapter', $count, 0, function ($query) use ($user) {
$query->where('created_by', '=', $user->id);
}),
'books' => $this->entityRepo->getRecentlyCreatedBooks($count, 0, function ($query) use ($user) {
'books' => $this->entityRepo->getRecentlyCreated('book', $count, 0, function ($query) use ($user) {
$query->where('created_by', '=', $user->id);
})
];
@@ -193,9 +198,9 @@ class UserRepo
* Get the roles in the system that are assignable to a user.
* @return mixed
*/
public function getAssignableRoles()
public function getAllRoles()
{
return $this->role->visible();
return $this->role->all();
}
/**
@@ -205,7 +210,7 @@ class UserRepo
*/
public function getRestrictableRoles()
{
return $this->role->where('hidden', '=', false)->where('system_name', '=', '')->get();
return $this->role->where('system_name', '!=', 'admin')->get();
}
}

View File

@@ -66,7 +66,7 @@ class Role extends Model
/**
* Get the role object for the specified role.
* @param $roleName
* @return mixed
* @return Role
*/
public static function getRole($roleName)
{
@@ -76,7 +76,7 @@ class Role extends Model
/**
* Get the role object for the specified system role.
* @param $roleName
* @return mixed
* @return Role
*/
public static function getSystemRole($roleName)
{

View File

@@ -19,7 +19,7 @@ class ActivityService
{
$this->activity = $activity;
$this->permissionService = $permissionService;
$this->user = auth()->user();
$this->user = user();
}
/**
@@ -114,7 +114,7 @@ class ActivityService
$activity = $this->permissionService
->filterRestrictedEntityRelations($query, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')->skip($count * $page)->take($count)->get();
->orderBy('created_at', 'desc')->with(['entity', 'user.avatar'])->skip($count * $page)->take($count)->get();
return $this->filterSimilar($activity);
}

View File

@@ -0,0 +1,201 @@
<?php namespace BookStack\Services;
use BookStack\Exceptions\FileUploadException;
use BookStack\Attachment;
use Exception;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService extends UploadService
{
/**
* Get an attachment from storage.
* @param Attachment $attachment
* @return string
*/
public function getAttachmentFromStorage(Attachment $attachment)
{
$attachmentPath = $this->getStorageBasePath() . $attachment->path;
return $this->getStorage()->get($attachmentPath);
}
/**
* Store a new attachment upon user upload.
* @param UploadedFile $uploadedFile
* @param int $page_id
* @return Attachment
* @throws FileUploadException
*/
public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
{
$attachmentName = $uploadedFile->getClientOriginalName();
$attachmentPath = $this->putFileInStorage($attachmentName, $uploadedFile);
$largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
$attachment = Attachment::forceCreate([
'name' => $attachmentName,
'path' => $attachmentPath,
'extension' => $uploadedFile->getClientOriginalExtension(),
'uploaded_to' => $page_id,
'created_by' => user()->id,
'updated_by' => user()->id,
'order' => $largestExistingOrder + 1
]);
return $attachment;
}
/**
* Store a upload, saving to a file and deleting any existing uploads
* attached to that file.
* @param UploadedFile $uploadedFile
* @param Attachment $attachment
* @return Attachment
* @throws FileUploadException
*/
public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment)
{
if (!$attachment->external) {
$this->deleteFileInStorage($attachment);
}
$attachmentName = $uploadedFile->getClientOriginalName();
$attachmentPath = $this->putFileInStorage($attachmentName, $uploadedFile);
$attachment->name = $attachmentName;
$attachment->path = $attachmentPath;
$attachment->external = false;
$attachment->extension = $uploadedFile->getClientOriginalExtension();
$attachment->save();
return $attachment;
}
/**
* Save a new File attachment from a given link and name.
* @param string $name
* @param string $link
* @param int $page_id
* @return Attachment
*/
public function saveNewFromLink($name, $link, $page_id)
{
$largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
return Attachment::forceCreate([
'name' => $name,
'path' => $link,
'external' => true,
'extension' => '',
'uploaded_to' => $page_id,
'created_by' => user()->id,
'updated_by' => user()->id,
'order' => $largestExistingOrder + 1
]);
}
/**
* Get the file storage base path, amended for storage type.
* This allows us to keep a generic path in the database.
* @return string
*/
private function getStorageBasePath()
{
return $this->isLocal() ? 'storage/' : '';
}
/**
* Updates the file ordering for a listing of attached files.
* @param array $attachmentList
* @param $pageId
*/
public function updateFileOrderWithinPage($attachmentList, $pageId)
{
foreach ($attachmentList as $index => $attachment) {
Attachment::where('uploaded_to', '=', $pageId)->where('id', '=', $attachment['id'])->update(['order' => $index]);
}
}
/**
* Update the details of a file.
* @param Attachment $attachment
* @param $requestData
* @return Attachment
*/
public function updateFile(Attachment $attachment, $requestData)
{
$attachment->name = $requestData['name'];
if (isset($requestData['link']) && trim($requestData['link']) !== '') {
$attachment->path = $requestData['link'];
if (!$attachment->external) {
$this->deleteFileInStorage($attachment);
$attachment->external = true;
}
}
$attachment->save();
return $attachment;
}
/**
* Delete a File from the database and storage.
* @param Attachment $attachment
*/
public function deleteFile(Attachment $attachment)
{
if ($attachment->external) {
$attachment->delete();
return;
}
$this->deleteFileInStorage($attachment);
$attachment->delete();
}
/**
* Delete a file from the filesystem it sits on.
* Cleans any empty leftover folders.
* @param Attachment $attachment
*/
protected function deleteFileInStorage(Attachment $attachment)
{
$storedFilePath = $this->getStorageBasePath() . $attachment->path;
$storage = $this->getStorage();
$dirPath = dirname($storedFilePath);
$storage->delete($storedFilePath);
if (count($storage->allFiles($dirPath)) === 0) {
$storage->deleteDirectory($dirPath);
}
}
/**
* Store a file in storage with the given filename
* @param $attachmentName
* @param UploadedFile $uploadedFile
* @return string
* @throws FileUploadException
*/
protected function putFileInStorage($attachmentName, UploadedFile $uploadedFile)
{
$attachmentData = file_get_contents($uploadedFile->getRealPath());
$storage = $this->getStorage();
$attachmentBasePath = 'uploads/files/' . Date('Y-m-M') . '/';
$storageBasePath = $this->getStorageBasePath() . $attachmentBasePath;
$uploadFileName = $attachmentName;
while ($storage->exists($storageBasePath . $uploadFileName)) {
$uploadFileName = str_random(3) . $uploadFileName;
}
$attachmentPath = $attachmentBasePath . $uploadFileName;
$attachmentStoragePath = $this->getStorageBasePath() . $attachmentPath;
try {
$storage->put($attachmentStoragePath, $attachmentData);
} catch (Exception $e) {
throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentStoragePath]));
}
return $attachmentPath;
}
}

View File

@@ -1,30 +1,27 @@
<?php namespace BookStack\Services;
use BookStack\Notifications\ConfirmEmail;
use BookStack\Repos\UserRepo;
use Carbon\Carbon;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Mail\Message;
use BookStack\EmailConfirmation;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Repos\UserRepo;
use BookStack\Setting;
use BookStack\User;
use Illuminate\Database\Connection as Database;
class EmailConfirmationService
{
protected $mailer;
protected $emailConfirmation;
protected $db;
protected $users;
/**
* EmailConfirmationService constructor.
* @param Mailer $mailer
* @param EmailConfirmation $emailConfirmation
* @param Database $db
* @param UserRepo $users
*/
public function __construct(Mailer $mailer, EmailConfirmation $emailConfirmation)
public function __construct(Database $db, UserRepo $users)
{
$this->mailer = $mailer;
$this->emailConfirmation = $emailConfirmation;
$this->db = $db;
$this->users = $users;
}
/**
@@ -36,45 +33,59 @@ class EmailConfirmationService
public function sendConfirmation(User $user)
{
if ($user->email_confirmed) {
throw new ConfirmationEmailException('Email has already been confirmed, Try logging in.', '/login');
throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');
}
$this->deleteConfirmationsByUser($user);
$token = $this->createEmailConfirmation($user);
$user->notify(new ConfirmEmail($token));
}
/**
* Creates a new email confirmation in the database and returns the token.
* @param User $user
* @return string
*/
public function createEmailConfirmation(User $user)
{
$token = $this->getToken();
$this->emailConfirmation->create([
$this->db->table('email_confirmations')->insert([
'user_id' => $user->id,
'token' => $token,
'token' => $token,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now()
]);
$this->mailer->send('emails/email-confirmation', ['token' => $token], function (Message $message) use ($user) {
$appName = setting('app-name', 'BookStack');
$message->to($user->email, $user->name)->subject('Confirm your email on ' . $appName . '.');
});
return $token;
}
/**
* Gets an email confirmation by looking up the token,
* Ensures the token has not expired.
* @param string $token
* @return EmailConfirmation
* @return array|null|\stdClass
* @throws UserRegistrationException
*/
public function getEmailConfirmationFromToken($token)
{
$emailConfirmation = $this->emailConfirmation->where('token', '=', $token)->first();
// If not found
$emailConfirmation = $this->db->table('email_confirmations')->where('token', '=', $token)->first();
// If not found show error
if ($emailConfirmation === null) {
throw new UserRegistrationException('This confirmation token is not valid or has already been used, Please try registering again.', '/register');
throw new UserRegistrationException(trans('errors.email_confirmation_invalid'), '/register');
}
// If more than a day old
if (Carbon::now()->subDay()->gt($emailConfirmation->created_at)) {
$this->sendConfirmation($emailConfirmation->user);
throw new UserRegistrationException('The confirmation token has expired, A new confirmation email has been sent.', '/register/confirm');
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 User $user
@@ -82,7 +93,7 @@ class EmailConfirmationService
*/
public function deleteConfirmationsByUser(User $user)
{
return $this->emailConfirmation->where('user_id', '=', $user->id)->delete();
return $this->db->table('email_confirmations')->where('user_id', '=', $user->id)->delete();
}
/**
@@ -92,7 +103,7 @@ class EmailConfirmationService
protected function getToken()
{
$token = str_random(24);
while ($this->emailConfirmation->where('token', '=', $token)->exists()) {
while ($this->db->table('email_confirmations')->where('token', '=', $token)->exists()) {
$token = str_random(25);
}
return $token;

View File

@@ -1,11 +1,22 @@
<?php namespace BookStack\Services;
use BookStack\Page;
use BookStack\Repos\EntityRepo;
class ExportService
{
protected $entityRepo;
/**
* ExportService constructor.
* @param $entityRepo
*/
public function __construct(EntityRepo $entityRepo)
{
$this->entityRepo = $entityRepo;
}
/**
* Convert a page to a self-contained HTML file.
* Includes required CSS & image content. Images are base64 encoded into the HTML.
@@ -15,7 +26,7 @@ class ExportService
public function pageToContainedHtml(Page $page)
{
$cssContent = file_get_contents(public_path('/css/export-styles.css'));
$pageHtml = view('pages/export', ['page' => $page, 'css' => $cssContent])->render();
$pageHtml = view('pages/export', ['page' => $page, 'pageContent' => $this->entityRepo->renderPage($page), 'css' => $cssContent])->render();
return $this->containHtml($pageHtml);
}
@@ -27,9 +38,15 @@ class ExportService
public function pageToPdf(Page $page)
{
$cssContent = file_get_contents(public_path('/css/export-styles.css'));
$pageHtml = view('pages/pdf', ['page' => $page, 'css' => $cssContent])->render();
$pageHtml = view('pages/pdf', ['page' => $page, 'pageContent' => $this->entityRepo->renderPage($page), 'css' => $cssContent])->render();
// return $pageHtml;
$useWKHTML = config('snappy.pdf.binary') !== false;
$containedHtml = $this->containHtml($pageHtml);
$pdf = \PDF::loadHTML($containedHtml);
if ($useWKHTML) {
$pdf = \SnappyPDF::loadHTML($containedHtml);
} else {
$pdf = \PDF::loadHTML($containedHtml);
}
return $pdf->output();
}
@@ -55,9 +72,13 @@ class ExportService
$pathString = $srcString;
}
if ($isLocal && !file_exists($pathString)) continue;
$imageContent = file_get_contents($pathString);
$imageEncoded = 'data:image/' . pathinfo($pathString, PATHINFO_EXTENSION) . ';base64,' . base64_encode($imageContent);
$newImageString = str_replace($srcString, $imageEncoded, $oldImgString);
try {
$imageContent = file_get_contents($pathString);
$imageEncoded = 'data:image/' . pathinfo($pathString, PATHINFO_EXTENSION) . ';base64,' . base64_encode($imageContent);
$newImageString = str_replace($srcString, $imageEncoded, $oldImgString);
} catch (\ErrorException $e) {
$newImageString = '';
}
$htmlContent = str_replace($oldImgString, $newImageString, $htmlContent);
}
}
@@ -84,14 +105,14 @@ class ExportService
/**
* Converts the page contents into simple plain text.
* This method filters any bad looking content to
* provide a nice final output.
* This method filters any bad looking content to provide a nice final output.
* @param Page $page
* @return mixed
*/
public function pageToPlainText(Page $page)
{
$text = $page->text;
$html = $this->entityRepo->renderPage($page);
$text = strip_tags($html);
// Replace multiple spaces with single spaces
$text = preg_replace('/\ {2,}/', ' ', $text);
// Reduce multiple horrid whitespace characters.

View File

@@ -9,20 +9,13 @@ use Intervention\Image\ImageManager;
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
use Illuminate\Contracts\Cache\Repository as Cache;
use Setting;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImageService
class ImageService extends UploadService
{
protected $imageTool;
protected $fileSystem;
protected $cache;
/**
* @var FileSystemInstance
*/
protected $storageInstance;
protected $storageUrl;
/**
@@ -34,8 +27,8 @@ class ImageService
public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
{
$this->imageTool = $imageTool;
$this->fileSystem = $fileSystem;
$this->cache = $cache;
parent::__construct($fileSystem);
}
/**
@@ -66,7 +59,7 @@ class ImageService
{
$imageName = $imageName ? $imageName : basename($url);
$imageData = file_get_contents($url);
if($imageData === false) throw new \Exception('Cannot get image from ' . $url);
if($imageData === false) throw new \Exception(trans('errors.cannot_get_image_from_url', ['url' => $url]));
return $this->saveNew($imageName, $imageData, $type);
}
@@ -88,6 +81,9 @@ class ImageService
if ($secureUploads) $imageName = str_random(16) . '-' . $imageName;
$imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/';
if ($this->isLocal()) $imagePath = '/public' . $imagePath;
while ($storage->exists($imagePath . $imageName)) {
$imageName = str_random(3) . $imageName;
}
@@ -97,9 +93,11 @@ class ImageService
$storage->put($fullPath, $imageData);
$storage->setVisibility($fullPath, 'public');
} catch (Exception $e) {
throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.');
throw new ImageUploadException(trans('errors.path_not_writable', ['filePath' => $fullPath]));
}
if ($this->isLocal()) $fullPath = str_replace_first('/public', '', $fullPath);
$imageDetails = [
'name' => $imageName,
'path' => $fullPath,
@@ -108,8 +106,8 @@ class ImageService
'uploaded_to' => $uploadedTo
];
if (auth()->user() && auth()->user()->id !== 0) {
$userId = auth()->user()->id;
if (user()->id !== 0) {
$userId = user()->id;
$imageDetails['created_by'] = $userId;
$imageDetails['updated_by'] = $userId;
}
@@ -119,6 +117,16 @@ class ImageService
return $image;
}
/**
* Get the storage path, Dependant of storage type.
* @param Image $image
* @return mixed|string
*/
protected function getPath(Image $image)
{
return ($this->isLocal()) ? ('public/' . $image->path) : $image->path;
}
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
@@ -135,7 +143,8 @@ class ImageService
public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
{
$thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
$thumbFilePath = dirname($image->path) . $thumbDirName . basename($image->path);
$imagePath = $this->getPath($image);
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
return $this->getPublicUrl($thumbFilePath);
@@ -148,10 +157,10 @@ class ImageService
}
try {
$thumb = $this->imageTool->make($storage->get($image->path));
$thumb = $this->imageTool->make($storage->get($imagePath));
} catch (Exception $e) {
if ($e instanceof \ErrorException || $e instanceof NotSupportedException) {
throw new ImageUploadException('The server cannot create thumbnails. Please check you have the GD PHP extension installed.');
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
} else {
throw $e;
}
@@ -183,8 +192,8 @@ class ImageService
{
$storage = $this->getStorage();
$imageFolder = dirname($image->path);
$imageFileName = basename($image->path);
$imageFolder = dirname($this->getPath($image));
$imageFileName = basename($this->getPath($image));
$allImages = collect($storage->allFiles($imageFolder));
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
@@ -213,7 +222,7 @@ class ImageService
public function saveUserGravatar(User $user, $size = 500)
{
$emailHash = md5(strtolower(trim($user->email)));
$url = 'http://www.gravatar.com/avatar/' . $emailHash . '?s=' . $size . '&d=identicon';
$url = 'https://www.gravatar.com/avatar/' . $emailHash . '?s=' . $size . '&d=identicon';
$imageName = str_replace(' ', '-', $user->name . '-gravatar.png');
$image = $this->saveNewFromUrl($url, 'user', $imageName);
$image->created_by = $user->id;
@@ -222,35 +231,9 @@ class ImageService
return $image;
}
/**
* Get the storage that will be used for storing images.
* @return FileSystemInstance
*/
private function getStorage()
{
if ($this->storageInstance !== null) return $this->storageInstance;
$storageType = config('filesystems.default');
$this->storageInstance = $this->fileSystem->disk($storageType);
return $this->storageInstance;
}
/**
* Check whether or not a folder is empty.
* @param $path
* @return int
*/
private function isFolderEmpty($path)
{
$files = $this->getStorage()->files($path);
$folders = $this->getStorage()->directories($path);
return count($files) === 0 && count($folders) === 0;
}
/**
* Gets a public facing url for an image by checking relevant environment variables.
* @param $filePath
* @param string $filePath
* @return string
*/
private function getPublicUrl($filePath)
@@ -273,6 +256,8 @@ class ImageService
$this->storageUrl = $storageUrl;
}
if ($this->isLocal()) $filePath = str_replace_first('public/', '', $filePath);
return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath;
}

View File

@@ -94,4 +94,4 @@ class Ldap
return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
}
}
}

View File

@@ -94,7 +94,7 @@ class LdapService
$ldapBind = $this->ldap->bind($connection, $ldapDn, $ldapPass);
}
if (!$ldapBind) throw new LdapException('LDAP access failed using ' . ($isAnonymous ? ' anonymous bind.' : ' given dn & pass details'));
if (!$ldapBind) throw new LdapException(($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed')));
}
/**
@@ -109,15 +109,19 @@ class LdapService
// Check LDAP extension in installed
if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
throw new LdapException('LDAP PHP extension not installed');
throw new LdapException(trans('errors.ldap_extension_not_installed'));
}
// Get port from server string if specified.
// Get port from server string and protocol if specified.
$ldapServer = explode(':', $this->config['server']);
$ldapConnection = $this->ldap->connect($ldapServer[0], count($ldapServer) > 1 ? $ldapServer[1] : 389);
$hasProtocol = preg_match('/^ldaps{0,1}\:\/\//', $this->config['server']) === 1;
if (!$hasProtocol) array_unshift($ldapServer, '');
$hostName = $ldapServer[0] . ($hasProtocol?':':'') . $ldapServer[1];
$defaultPort = $ldapServer[0] === 'ldaps' ? 636 : 389;
$ldapConnection = $this->ldap->connect($hostName, count($ldapServer) > 2 ? intval($ldapServer[2]) : $defaultPort);
if ($ldapConnection === false) {
throw new LdapException('Cannot connect to ldap server, Initial connection failed');
throw new LdapException(trans('errors.ldap_cannot_connect'));
}
// Set any required options

View File

@@ -8,20 +8,24 @@ use BookStack\Ownable;
use BookStack\Page;
use BookStack\Role;
use BookStack\User;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class PermissionService
{
protected $userRoles;
protected $isAdmin;
protected $currentAction;
protected $currentUser;
protected $isAdminUser;
protected $userRoles = false;
protected $currentUserModel = false;
public $book;
public $chapter;
public $page;
protected $db;
protected $jointPermission;
protected $role;
@@ -30,24 +34,21 @@ class PermissionService
/**
* PermissionService constructor.
* @param JointPermission $jointPermission
* @param Connection $db
* @param Book $book
* @param Chapter $chapter
* @param Page $page
* @param Role $role
*/
public function __construct(JointPermission $jointPermission, Book $book, Chapter $chapter, Page $page, Role $role)
public function __construct(JointPermission $jointPermission, Connection $db, Book $book, Chapter $chapter, Page $page, Role $role)
{
$this->currentUser = auth()->user();
$userSet = $this->currentUser !== null;
$this->userRoles = false;
$this->isAdmin = $userSet ? $this->currentUser->hasRole('admin') : false;
if (!$userSet) $this->currentUser = new User();
$this->db = $db;
$this->jointPermission = $jointPermission;
$this->role = $role;
$this->book = $book;
$this->chapter = $chapter;
$this->page = $page;
// TODO - Update so admin still goes through filters
}
/**
@@ -117,7 +118,7 @@ class PermissionService
}
foreach ($this->currentUser->roles as $role) {
foreach ($this->currentUser()->roles as $role) {
$roles[] = $role->id;
}
return $roles;
@@ -156,7 +157,7 @@ class PermissionService
*/
public function buildJointPermissionsForEntity(Entity $entity)
{
$roles = $this->role->with('jointPermissions')->get();
$roles = $this->role->get();
$entities = collect([$entity]);
if ($entity->isA('book')) {
@@ -176,7 +177,7 @@ class PermissionService
*/
public function buildJointPermissionsForEntities(Collection $entities)
{
$roles = $this->role->with('jointPermissions')->get();
$roles = $this->role->get();
$this->deleteManyJointPermissionsForEntities($entities);
$this->createManyJointPermissions($entities, $roles);
}
@@ -307,6 +308,10 @@ class PermissionService
$explodedAction = explode('-', $action);
$restrictionAction = end($explodedAction);
if ($role->system_name === 'admin') {
return $this->createJointPermissionDataArray($entity, $role, $action, true, true);
}
if ($entity->isA('book')) {
if (!$entity->restricted) {
@@ -389,21 +394,25 @@ class PermissionService
*/
public function checkOwnableUserAccess(Ownable $ownable, $permission)
{
if ($this->isAdmin) return true;
if ($this->isAdmin()) {
$this->clean();
return true;
}
$explodedPermission = explode('-', $permission);
$baseQuery = $ownable->where('id', '=', $ownable->id);
$action = end($explodedPermission);
$this->currentAction = $action;
$nonJointPermissions = ['restrictions'];
$nonJointPermissions = ['restrictions', 'image', 'attachment'];
// Handle non entity specific jointPermissions
if (in_array($explodedPermission[0], $nonJointPermissions)) {
$allPermission = $this->currentUser && $this->currentUser->can($permission . '-all');
$ownPermission = $this->currentUser && $this->currentUser->can($permission . '-own');
$allPermission = $this->currentUser() && $this->currentUser()->can($permission . '-all');
$ownPermission = $this->currentUser() && $this->currentUser()->can($permission . '-own');
$this->currentAction = 'view';
$isOwner = $this->currentUser && $this->currentUser->id === $ownable->created_by;
$isOwner = $this->currentUser() && $this->currentUser()->id === $ownable->created_by;
return ($allPermission || ($isOwner && $ownPermission));
}
@@ -412,8 +421,9 @@ class PermissionService
$this->currentAction = $permission;
}
return $this->entityRestrictionQuery($baseQuery)->count() > 0;
$q = $this->entityRestrictionQuery($baseQuery)->count() > 0;
$this->clean();
return $q;
}
/**
@@ -443,7 +453,7 @@ class PermissionService
*/
protected function entityRestrictionQuery($query)
{
return $query->where(function ($parentQuery) {
$q = $query->where(function ($parentQuery) {
$parentQuery->whereHas('jointPermissions', function ($permissionQuery) {
$permissionQuery->whereIn('role_id', $this->getRoles())
->where('action', '=', $this->currentAction)
@@ -451,65 +461,77 @@ class PermissionService
$query->where('has_permission', '=', true)
->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('created_by', '=', $this->currentUser->id);
->where('created_by', '=', $this->currentUser()->id);
});
});
});
});
$this->clean();
return $q;
}
/**
* Add restrictions for a page query
* @param $query
* @param string $action
* @return mixed
* Get the children of a book in an efficient single query, Filtered by the permission system.
* @param integer $book_id
* @param bool $filterDrafts
* @return \Illuminate\Database\Query\Builder
*/
public function enforcePageRestrictions($query, $action = 'view')
{
// Prevent drafts being visible to others.
$query = $query->where(function ($query) {
$query->where('draft', '=', false);
if ($this->currentUser) {
$query->orWhere(function ($query) {
$query->where('draft', '=', true)->where('created_by', '=', $this->currentUser->id);
public function bookChildrenQuery($book_id, $filterDrafts = false) {
$pageSelect = $this->db->table('pages')->selectRaw("'BookStack\\\\Page' as entity_type, id, slug, name, text, '' as description, book_id, priority, chapter_id, draft")->where('book_id', '=', $book_id)->where(function($query) use ($filterDrafts) {
$query->where('draft', '=', 0);
if (!$filterDrafts) {
$query->orWhere(function($query) {
$query->where('draft', '=', 1)->where('created_by', '=', $this->currentUser()->id);
});
}
});
$chapterSelect = $this->db->table('chapters')->selectRaw("'BookStack\\\\Chapter' as entity_type, id, slug, name, '' as text, description, book_id, priority, 0 as chapter_id, 0 as draft")->where('book_id', '=', $book_id);
$query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U"))
->mergeBindings($pageSelect)->mergeBindings($chapterSelect);
return $this->enforceEntityRestrictions($query, $action);
}
if (!$this->isAdmin()) {
$whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)')
->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type')
->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles())
->where(function($query) {
$query->where('jp.has_permission', '=', 1)->orWhere(function($query) {
$query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id);
});
});
$query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery);
}
/**
* Add on permission restrictions to a chapter query.
* @param $query
* @param string $action
* @return mixed
*/
public function enforceChapterRestrictions($query, $action = 'view')
{
return $this->enforceEntityRestrictions($query, $action);
}
/**
* Add restrictions to a book query.
* @param $query
* @param string $action
* @return mixed
*/
public function enforceBookRestrictions($query, $action = 'view')
{
return $this->enforceEntityRestrictions($query, $action);
$query->orderBy('draft', 'desc')->orderBy('priority', 'asc');
$this->clean();
return $query;
}
/**
* Add restrictions for a generic entity
* @param $query
* @param string $entityType
* @param Builder|Entity $query
* @param string $action
* @return mixed
*/
public function enforceEntityRestrictions($query, $action = 'view')
public function enforceEntityRestrictions($entityType, $query, $action = 'view')
{
if ($this->isAdmin) return $query;
if (strtolower($entityType) === 'page') {
// Prevent drafts being visible to others.
$query = $query->where(function ($query) {
$query->where('draft', '=', false);
if ($this->currentUser()) {
$query->orWhere(function ($query) {
$query->where('draft', '=', true)->where('created_by', '=', $this->currentUser()->id);
});
}
});
}
if ($this->isAdmin()) {
$this->clean();
return $query;
}
$this->currentAction = $action;
return $this->entityRestrictionQuery($query);
}
@@ -524,11 +546,15 @@ class PermissionService
*/
public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn)
{
if ($this->isAdmin) return $query;
if ($this->isAdmin()) {
$this->clean();
return $query;
}
$this->currentAction = 'view';
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
return $query->where(function ($query) use ($tableDetails) {
$q = $query->where(function ($query) use ($tableDetails) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails) {
$permissionQuery->select('id')->from('joint_permissions')
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
@@ -538,12 +564,13 @@ class PermissionService
->where(function ($query) {
$query->where('has_permission', '=', true)->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('created_by', '=', $this->currentUser->id);
->where('created_by', '=', $this->currentUser()->id);
});
});
});
});
$this->clean();
return $q;
}
/**
@@ -555,11 +582,15 @@ class PermissionService
*/
public function filterRelatedPages($query, $tableName, $entityIdColumn)
{
if ($this->isAdmin) return $query;
if ($this->isAdmin()) {
$this->clean();
return $query;
}
$this->currentAction = 'view';
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
return $query->where(function ($query) use ($tableDetails) {
$q = $query->where(function ($query) use ($tableDetails) {
$query->where(function ($query) use (&$tableDetails) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails) {
$permissionQuery->select('id')->from('joint_permissions')
@@ -570,12 +601,50 @@ class PermissionService
->where(function ($query) {
$query->where('has_permission', '=', true)->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('created_by', '=', $this->currentUser->id);
->where('created_by', '=', $this->currentUser()->id);
});
});
});
})->orWhere($tableDetails['entityIdColumn'], '=', 0);
});
$this->clean();
return $q;
}
/**
* Check if the current user is an admin.
* @return bool
*/
private function isAdmin()
{
if ($this->isAdminUser === null) {
$this->isAdminUser = ($this->currentUser()->id !== null) ? $this->currentUser()->hasSystemRole('admin') : false;
}
return $this->isAdminUser;
}
/**
* Get the current user
* @return User
*/
private function currentUser()
{
if ($this->currentUserModel === false) {
$this->currentUserModel = user();
}
return $this->currentUserModel;
}
/**
* Clean the cached user elements.
*/
private function clean()
{
$this->currentUserModel = false;
$this->userRoles = false;
$this->isAdminUser = null;
}
}

View File

@@ -1,6 +1,7 @@
<?php namespace BookStack\Services;
use BookStack\Setting;
use BookStack\User;
use Illuminate\Contracts\Cache\Repository as Cache;
/**
@@ -38,10 +39,23 @@ class SettingService
*/
public function get($key, $default = false)
{
if ($default === false) $default = config('setting-defaults.' . $key, false);
$value = $this->getValueFromStore($key, $default);
return $this->formatValue($value, $default);
}
/**
* Get a user-specific setting from the database or cache.
* @param User $user
* @param $key
* @param bool $default
* @return bool|string
*/
public function getUser($user, $key, $default = false)
{
return $this->get($this->userKey($user->id, $key), $default);
}
/**
* Gets a setting value from the cache or database.
* Looks at the system defaults if not cached or in database.
@@ -69,14 +83,6 @@ class SettingService
return $value;
}
// Check the defaults set in the app config.
$configPrefix = 'setting-defaults.' . $key;
if (config()->has($configPrefix)) {
$value = config($configPrefix);
$this->cache->forever($cacheKey, $value);
return $value;
}
return $default;
}
@@ -118,6 +124,16 @@ class SettingService
return $setting !== null;
}
/**
* Check if a user setting is in the database.
* @param $key
* @return bool
*/
public function hasUser($key)
{
return $this->has($this->userKey($key));
}
/**
* Add a setting to the database.
* @param $key
@@ -135,6 +151,28 @@ class SettingService
return true;
}
/**
* Put a user-specific setting into the database.
* @param User $user
* @param $key
* @param $value
* @return bool
*/
public function putUser($user, $key, $value)
{
return $this->put($this->userKey($user->id, $key), $value);
}
/**
* Convert a setting key into a user-specific key.
* @param $key
* @return string
*/
protected function userKey($userId, $key = '')
{
return 'user:' . $userId . ':' . $key;
}
/**
* Removes a setting from the database.
* @param $key
@@ -150,6 +188,16 @@ class SettingService
return true;
}
/**
* Delete settings for a given user id.
* @param $userId
* @return mixed
*/
public function deleteUserSettings($userId)
{
return $this->setting->where('setting_key', 'like', $this->userKey($userId) . '%')->delete();
}
/**
* Gets a setting model from the database for the given key.
* @param $key

View File

@@ -70,12 +70,12 @@ class SocialAuthService
// Check social account has not already been used
if ($this->socialAccount->where('driver_id', '=', $socialUser->getId())->exists()) {
throw new UserRegistrationException('This ' . $socialDriver . ' account is already in use, Try logging in via the ' . $socialDriver . ' option.', '/login');
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount'=>$socialDriver]), '/login');
}
if ($this->userRepo->getByEmail($socialUser->getEmail())) {
$email = $socialUser->getEmail();
throw new UserRegistrationException('The email ' . $email . ' is already in use. If you already have an account you can connect your ' . $socialDriver . ' account from your profile settings.', '/login');
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount'=>$socialDriver, 'email' => $email]), '/login');
}
return $socialUser;
@@ -98,9 +98,8 @@ class SocialAuthService
// Get any attached social accounts or users
$socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first();
$user = $this->userRepo->getByEmail($socialUser->getEmail());
$isLoggedIn = auth()->check();
$currentUser = auth()->user();
$currentUser = user();
// When a user is not logged in and a matching SocialAccount exists,
// Simply log the user into the application.
@@ -113,27 +112,26 @@ class SocialAuthService
if ($isLoggedIn && $socialAccount === null) {
$this->fillSocialAccount($socialDriver, $socialUser);
$currentUser->socialAccounts()->save($this->socialAccount);
session()->flash('success', title_case($socialDriver) . ' account was successfully attached to your profile.');
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => title_case($socialDriver)]));
return redirect($currentUser->getEditUrl());
}
// When a user is logged in and the social account exists and is already linked to the current user.
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
session()->flash('error', 'This ' . title_case($socialDriver) . ' account is already attached to your profile.');
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => title_case($socialDriver)]));
return redirect($currentUser->getEditUrl());
}
// When a user is logged in, A social account exists but the users do not match.
// Change the user that the social account is assigned to.
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
session()->flash('success', 'This ' . title_case($socialDriver) . ' account is already used by another user.');
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => title_case($socialDriver)]));
return redirect($currentUser->getEditUrl());
}
// Otherwise let the user know this social account is not used by anyone.
$message = 'This ' . $socialDriver . ' account is not linked to any users. Please attach it in your profile settings';
$message = trans('errors.social_account_not_used', ['socialAccount' => title_case($socialDriver)]);
if (setting('registration-enabled')) {
$message .= ' or, If you do not yet have an account, You can register an account using the ' . $socialDriver . ' option';
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => title_case($socialDriver)]);
}
throw new SocialSignInException($message . '.', '/login');
@@ -157,8 +155,8 @@ class SocialAuthService
{
$driver = trim(strtolower($socialDriver));
if (!in_array($driver, $this->validSocialDrivers)) abort(404, 'Social Driver Not Found');
if (!$this->checkDriverConfigured($driver)) throw new SocialDriverNotConfigured("Your {$driver} social settings are not configured correctly.");
if (!in_array($driver, $this->validSocialDrivers)) abort(404, trans('errors.social_driver_not_found'));
if (!$this->checkDriverConfigured($driver)) throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => title_case($socialDriver)]));
return $driver;
}
@@ -214,9 +212,9 @@ class SocialAuthService
public function detachSocialAccount($socialDriver)
{
session();
auth()->user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
session()->flash('success', title_case($socialDriver) . ' account successfully detached');
return redirect(auth()->user()->getEditUrl());
user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => title_case($socialDriver)]));
return redirect(user()->getEditUrl());
}
}

View File

@@ -0,0 +1,64 @@
<?php namespace BookStack\Services;
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
class UploadService
{
/**
* @var FileSystem
*/
protected $fileSystem;
/**
* @var FileSystemInstance
*/
protected $storageInstance;
/**
* FileService constructor.
* @param $fileSystem
*/
public function __construct(FileSystem $fileSystem)
{
$this->fileSystem = $fileSystem;
}
/**
* Get the storage that will be used for storing images.
* @return FileSystemInstance
*/
protected function getStorage()
{
if ($this->storageInstance !== null) return $this->storageInstance;
$storageType = config('filesystems.default');
$this->storageInstance = $this->fileSystem->disk($storageType);
return $this->storageInstance;
}
/**
* Check whether or not a folder is empty.
* @param $path
* @return bool
*/
protected function isFolderEmpty($path)
{
$files = $this->getStorage()->files($path);
$folders = $this->getStorage()->directories($path);
return (count($files) === 0 && count($folders) === 0);
}
/**
* Check if using a local filesystem.
* @return bool
*/
protected function isLocal()
{
return strtolower(config('filesystems.default')) === 'local';
}
}

View File

@@ -5,9 +5,7 @@ use BookStack\View;
class ViewService
{
protected $view;
protected $user;
protected $permissionService;
/**
@@ -18,7 +16,6 @@ class ViewService
public function __construct(View $view, PermissionService $permissionService)
{
$this->view = $view;
$this->user = auth()->user();
$this->permissionService = $permissionService;
}
@@ -29,8 +26,9 @@ class ViewService
*/
public function add(Entity $entity)
{
if ($this->user === null) return 0;
$view = $entity->views()->where('user_id', '=', $this->user->id)->first();
$user = user();
if ($user === null || $user->isDefault()) return 0;
$view = $entity->views()->where('user_id', '=', $user->id)->first();
// Add view if model exists
if ($view) {
$view->increment('views');
@@ -39,7 +37,7 @@ class ViewService
// Otherwise create new view count
$entity->views()->save($this->view->create([
'user_id' => $this->user->id,
'user_id' => $user->id,
'views' => 1
]));
@@ -78,13 +76,14 @@ class ViewService
*/
public function getUserRecentlyViewed($count = 10, $page = 0, $filterModel = false)
{
if ($this->user === null) return collect();
$user = user();
if ($user === null || $user->isDefault()) return collect();
$query = $this->permissionService
->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type');
if ($filterModel) $query = $query->where('viewable_type', '=', get_class($filterModel));
$query = $query->where('user_id', '=', auth()->user()->id);
$query = $query->where('user_id', '=', $user->id);
$viewables = $query->with('viewable')->orderBy('updated_at', 'desc')
->skip($count * $page)->take($count)->get()->pluck('viewable');

View File

@@ -1,13 +1,16 @@
<?php namespace BookStack;
use BookStack\Notifications\ResetPassword;
use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Notifications\Notifiable;
class User extends Model implements AuthenticatableContract, CanResetPasswordContract
{
use Authenticatable, CanResetPassword;
use Authenticatable, CanResetPassword, Notifiable;
/**
* The database table used by the model.
@@ -34,21 +37,30 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
protected $permissions;
/**
* Returns a default guest user.
* Returns the default public user.
* @return User
*/
public static function getDefault()
{
return new static([
'email' => 'guest',
'name' => 'Guest'
]);
return static::where('system_name', '=', 'public')->first();
}
/**
* Check if the user is the default public user.
* @return bool
*/
public function isDefault()
{
return $this->system_name === 'public';
}
/**
* The roles that belong to the user.
* @return BelongsToMany
*/
public function roles()
{
if ($this->id === 0) return ;
return $this->belongsToMany(Role::class);
}
@@ -62,6 +74,16 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $this->roles->pluck('name')->contains($role);
}
/**
* Check if the user has a role.
* @param $role
* @return mixed
*/
public function hasSystemRole($role)
{
return $this->roles->pluck('system_name')->contains('admin');
}
/**
* Get all permissions belonging to a the current user.
* @param bool $cache
@@ -138,8 +160,16 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function getAvatar($size = 50)
{
if ($this->image_id === 0 || $this->image_id === '0' || $this->image_id === null) return baseUrl('/user_avatar.png');
return baseUrl($this->avatar->getThumb($size, $size, false));
$default = baseUrl('/user_avatar.png');
$imageId = $this->image_id;
if ($imageId === 0 || $imageId === '0' || $imageId === null) return $default;
try {
$avatar = baseUrl($this->avatar->getThumb($size, $size, false));
} catch (\Exception $err) {
$avatar = $default;
}
return $avatar;
}
/**
@@ -183,4 +213,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return '';
}
/**
* Send the password reset notification.
* @param string $token
* @return void
*/
public function sendPasswordResetNotification($token)
{
$this->notify(new ResetPassword($token));
}
}

View File

@@ -11,29 +11,30 @@ use BookStack\Ownable;
*/
function versioned_asset($file = '')
{
// Don't require css and JS assets for testing
if (config('app.env') === 'testing') return '';
static $version = null;
static $manifest = null;
$manifestPath = 'build/manifest.json';
if (is_null($manifest) && file_exists($manifestPath)) {
$manifest = json_decode(file_get_contents(public_path($manifestPath)), true);
} else if (!file_exists($manifestPath)) {
if (config('app.env') !== 'production') {
$path = public_path($manifestPath);
$error = "No {$path} file found, Ensure you have built the css/js assets using gulp.";
} else {
$error = "No {$manifestPath} file found, Ensure you are using the release version of BookStack";
}
throw new \Exception($error);
if (is_null($version)) {
$versionFile = base_path('version');
$version = trim(file_get_contents($versionFile));
}
if (isset($manifest[$file])) {
return baseUrl($manifest[$file]);
$additional = '';
if (config('app.env') === 'development') {
$additional = sha1_file(public_path($file));
}
throw new InvalidArgumentException("File {$file} not defined in asset manifest.");
$path = $file . '?version=' . urlencode($version) . $additional;
return baseUrl($path);
}
/**
* Helper method to get the current User.
* Defaults to public 'Guest' user if not logged in.
* @return \BookStack\User
*/
function user()
{
return auth()->user() ?: \BookStack\User::getDefault();
}
/**
@@ -47,7 +48,7 @@ function versioned_asset($file = '')
function userCan($permission, Ownable $ownable = null)
{
if ($ownable === null) {
return auth()->user() && auth()->user()->can($permission);
return user() && user()->can($permission);
}
// Check permission on ownable item
@@ -59,11 +60,12 @@ function userCan($permission, Ownable $ownable = null)
* Helper to access system settings.
* @param $key
* @param bool $default
* @return mixed
* @return bool|string|\BookStack\Services\SettingService
*/
function setting($key, $default = false)
function setting($key = null, $default = false)
{
$settingService = app('BookStack\Services\SettingService');
$settingService = app(\BookStack\Services\SettingService::class);
if (is_null($key)) return $settingService;
return $settingService->get($key, $default);
}
@@ -79,6 +81,7 @@ function baseUrl($path, $forceAppDomain = false)
if ($isFullUrl && !$forceAppDomain) return $path;
$path = trim($path, '/');
// Remove non-specified domain if forced and we have a domain
if ($isFullUrl && $forceAppDomain) {
$explodedPath = explode('/', $path);
$path = implode('/', array_splice($explodedPath, 3));
@@ -127,14 +130,14 @@ function sortUrl($path, $data, $overrideData = [])
{
$queryStringSections = [];
$queryData = array_merge($data, $overrideData);
// Change sorting direction is already sorted on current attribute
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
} else {
$queryData['order'] = 'asc';
}
foreach ($queryData as $name => $value) {
$trimmedVal = trim($value);
if ($trimmedVal === '') continue;
@@ -144,4 +147,4 @@ function sortUrl($path, $data, $overrideData = [])
if (count($queryStringSections) === 0) return $path;
return baseUrl($path . '?' . implode('&', $queryStringSections));
}
}

View File

@@ -5,23 +5,25 @@
"license": "MIT",
"type": "project",
"require": {
"php": ">=5.5.9",
"laravel/framework": "5.2.*",
"php": ">=5.6.4",
"laravel/framework": "^5.3.4",
"ext-tidy": "*",
"intervention/image": "^2.3",
"laravel/socialite": "^2.0",
"barryvdh/laravel-ide-helper": "^2.1",
"barryvdh/laravel-debugbar": "^2.0",
"barryvdh/laravel-debugbar": "^2.2.3",
"league/flysystem-aws-s3-v3": "^1.0",
"barryvdh/laravel-dompdf": "0.6.*",
"predis/predis": "^1.0"
"barryvdh/laravel-dompdf": "^0.7",
"predis/predis": "^1.1",
"gathercontent/htmldiff": "^0.2.1",
"barryvdh/laravel-snappy": "^0.3.1"
},
"require-dev": {
"fzaninotto/faker": "~1.4",
"mockery/mockery": "0.9.*",
"phpunit/phpunit": "~4.0",
"phpspec/phpspec": "~2.1",
"symfony/dom-crawler": "~3.0",
"symfony/css-selector": "~3.0"
"phpunit/phpunit": "~5.0",
"symfony/css-selector": "3.1.*",
"symfony/dom-crawler": "3.1.*"
},
"autoload": {
"classmap": [
@@ -37,21 +39,19 @@
]
},
"scripts": {
"post-install-cmd": [
"php artisan clear-compiled",
"php artisan optimize"
],
"pre-update-cmd": [
"php artisan clear-compiled"
],
"post-update-cmd": [
"php artisan optimize"
],
"post-root-package-install": [
"php -r \"copy('.env.example', '.env');\""
"php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"php artisan key:generate"
],
"post-install-cmd": [
"Illuminate\\Foundation\\ComposerScripts::postInstall",
"php artisan optimize"
],
"post-update-cmd": [
"Illuminate\\Foundation\\ComposerScripts::postUpdate",
"php artisan optimize"
]
},
"config": {

1660
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,7 @@ return [
|
*/
'locale' => 'en',
'locale' => env('APP_LANG', 'en'),
/*
|--------------------------------------------------------------------------
@@ -138,6 +138,7 @@ return [
Illuminate\Translation\TranslationServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Laravel\Socialite\SocialiteServiceProvider::class,
/**
@@ -147,6 +148,7 @@ return [
Barryvdh\DomPDF\ServiceProvider::class,
Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class,
Barryvdh\Debugbar\ServiceProvider::class,
Barryvdh\Snappy\ServiceProvider::class,
/*
@@ -156,6 +158,7 @@ return [
BookStack\Providers\AuthServiceProvider::class,
BookStack\Providers\AppServiceProvider::class,
BookStack\Providers\BroadcastServiceProvider::class,
BookStack\Providers\EventServiceProvider::class,
BookStack\Providers\RouteServiceProvider::class,
BookStack\Providers\CustomFacadeProvider::class,
@@ -194,6 +197,7 @@ return [
'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class,
'Notification' => Illuminate\Support\Facades\Notification::class,
'Password' => Illuminate\Support\Facades\Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class,
@@ -215,6 +219,7 @@ return [
'ImageTool' => Intervention\Image\Facades\Image::class,
'PDF' => Barryvdh\DomPDF\Facade::class,
'SnappyPDF' => Barryvdh\Snappy\Facades\SnappyPdf::class,
'Debugbar' => Barryvdh\Debugbar\Facade::class,
/**

View File

@@ -56,7 +56,7 @@ return [
'local' => [
'driver' => 'local',
'root' => public_path(),
'root' => base_path(),
],
'ftp' => [

View File

@@ -6,6 +6,8 @@
return [
'app-name' => 'BookStack',
'app-logo' => '',
'app-name-header' => true,
'app-editor' => 'wysiwyg',
'app-color' => '#0288D1',
'app-color-light' => 'rgba(21, 101, 192, 0.15)',

18
config/snappy.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
return [
'pdf' => [
'enabled' => true,
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
'timeout' => false,
'options' => [],
'env' => [],
],
'image' => [
'enabled' => false,
'binary' => '/usr/local/bin/wkhtmltoimage',
'timeout' => false,
'options' => [],
'env' => [],
],
];

View File

@@ -59,4 +59,14 @@ $factory->define(BookStack\Tag::class, function ($faker) {
'name' => $faker->city,
'value' => $faker->sentence(3)
];
});
$factory->define(BookStack\Image::class, function ($faker) {
return [
'name' => $faker->slug . '.jpg',
'url' => $faker->url,
'path' => $faker->url,
'type' => 'gallery',
'uploaded_to' => 0
];
});

View File

@@ -129,7 +129,7 @@ class AddRolesAndPermissions extends Migration
// Set all current users as admins
// (At this point only the initially create user should be an admin)
$users = DB::table('users')->get();
$users = DB::table('users')->get()->all();
foreach ($users as $user) {
DB::table('role_user')->insert([
'role_id' => $adminId,

View File

@@ -0,0 +1,66 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class RemoveHiddenRoles extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// Remove the hidden property from roles
Schema::table('roles', function(Blueprint $table) {
$table->dropColumn('hidden');
});
// Add column to mark system users
Schema::table('users', function(Blueprint $table) {
$table->string('system_name')->nullable()->index();
});
// Insert our new public system user.
$publicUserId = DB::table('users')->insertGetId([
'email' => 'guest@example.com',
'name' => 'Guest',
'system_name' => 'public',
'email_confirmed' => true,
'created_at' => \Carbon\Carbon::now(),
'updated_at' => \Carbon\Carbon::now(),
]);
// Get the public role
$publicRole = DB::table('roles')->where('system_name', '=', 'public')->first();
// Connect the new public user to the public role
DB::table('role_user')->insert([
'user_id' => $publicUserId,
'role_id' => $publicRole->id
]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('roles', function(Blueprint $table) {
$table->boolean('hidden')->default(false);
$table->index('hidden');
});
DB::table('users')->where('system_name', '=', 'public')->delete();
Schema::table('users', function(Blueprint $table) {
$table->dropColumn('system_name');
});
DB::table('roles')->where('system_name', '=', 'public')->update(['hidden' => true]);
}
}

View File

@@ -0,0 +1,71 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateAttachmentsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('attachments', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('path');
$table->string('extension', 20);
$table->integer('uploaded_to');
$table->boolean('external');
$table->integer('order');
$table->integer('created_by');
$table->integer('updated_by');
$table->index('uploaded_to');
$table->timestamps();
});
// Get roles with permissions we need to change
$adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
// Create & attach new entity permissions
$ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
$entity = 'Attachment';
foreach ($ops as $op) {
$permissionId = DB::table('role_permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
'display_name' => $op . ' ' . $entity . 's',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
'permission_id' => $permissionId
]);
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('attachments');
// Create & attach new entity permissions
$ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
$entity = 'Attachment';
foreach ($ops as $op) {
$permName = strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op));
DB::table('role_permissions')->where('name', '=', $permName)->delete();
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateCacheTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->unique();
$table->text('value');
$table->integer('expiration');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('cache');
}
}

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateSessionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->unique();
$table->integer('user_id')->nullable();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->text('payload');
$table->integer('last_activity');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('sessions');
}
}

View File

@@ -1,27 +1,8 @@
var elixir = require('laravel-elixir');
// Custom extensions
var gulp = require('gulp');
var Task = elixir.Task;
var fs = require('fs');
elixir.extend('queryVersion', function(inputFiles) {
new Task('queryVersion', function() {
var manifestObject = {};
var uidString = Date.now().toString(16).slice(4);
for (var i = 0; i < inputFiles.length; i++) {
var file = inputFiles[i];
manifestObject[file] = file + '?version=' + uidString;
}
var fileContents = JSON.stringify(manifestObject, null, 1);
fs.writeFileSync('public/build/manifest.json', fileContents);
}).watch(['./public/css/*.css', './public/js/*.js']);
});
elixir(function(mix) {
mix.sass('styles.scss')
.sass('print-styles.scss')
.sass('export-styles.scss')
.browserify('global.js', 'public/js/common.js')
.queryVersion(['css/styles.css', 'css/print-styles.css', 'js/common.js']);
elixir(mix => {
mix.sass('styles.scss');
mix.sass('print-styles.scss');
mix.sass('export-styles.scss');
mix.browserify('global.js', './public/js/common.js');
});

View File

@@ -1,20 +1,24 @@
{
"private": true,
"devDependencies": {
"gulp": "^3.9.0"
"scripts": {
"build": "gulp --production",
"dev": "gulp watch",
"watch": "gulp watch"
},
"dependencies": {
"devDependencies": {
"angular": "^1.5.5",
"angular-animate": "^1.5.5",
"angular-resource": "^1.5.5",
"angular-sanitize": "^1.5.5",
"angular-ui-sortable": "^0.14.0",
"babel-runtime": "^5.8.29",
"bootstrap-sass": "^3.0.0",
"angular-ui-sortable": "^0.15.0",
"dropzone": "^4.0.1",
"laravel-elixir": "^5.0.0",
"gulp": "^3.9.0",
"laravel-elixir": "^6.0.0-11",
"laravel-elixir-browserify-official": "^0.1.3",
"marked": "^0.3.5",
"moment": "^2.12.0",
"zeroclipboard": "^2.2.0"
"moment": "^2.12.0"
},
"dependencies": {
"clipboard": "^1.5.16"
}
}

View File

@@ -1,5 +0,0 @@
suites:
main:
namespace: BookStack
psr4_prefix: BookStack
src_path: app

View File

@@ -22,6 +22,7 @@
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_DEBUG" value="false"/>
<env name="APP_LANG" value="en"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
@@ -30,6 +31,7 @@
<env name="AUTH_METHOD" value="standard"/>
<env name="DISABLE_EXTERNAL_SERVICES" value="true"/>
<env name="LDAP_VERSION" value="3"/>
<env name="STORAGE_TYPE" value="local"/>
<env name="GITHUB_APP_ID" value="aaaaaaaaaaaaaa"/>
<env name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/>
<env name="GOOGLE_APP_ID" value="aaaaaaaaaaaaaa"/>

Binary file not shown.

View File

@@ -1,5 +0,0 @@
{
"css/styles.css": "css/styles.css?version=5be13a8",
"css/print-styles.css": "css/print-styles.css?version=5be13a8",
"js/common.js": "js/common.js?version=5be13a8"
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
header{display:none}body{font-size:12px}.faded-small{display:none}.page-content{margin:0 auto}.print-hidden{display:none}.print-full-width{width:100%;float:none;display:block}h2{font-size:2em;line-height:1;margin-top:.6em;margin-bottom:.3em}
.faded-small,.print-hidden,header{display:none}body{font-size:12px}.page-content{margin:0 auto}.print-full-width{width:100%;float:none;display:block}h2{font-size:2em;line-height:1;margin-top:.6em;margin-bottom:.3em}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,38 +2,57 @@
[![GitHub release](https://img.shields.io/github/release/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/releases/latest)
[![license](https://img.shields.io/github/license/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/blob/master/LICENSE)
[![Build Status](https://travis-ci.org/ssddanbrown/BookStack.svg)](https://travis-ci.org/ssddanbrown/BookStack)
[![Build Status](https://travis-ci.org/BookStackApp/BookStack.svg)](https://travis-ci.org/BookStackApp/BookStack)
A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/.
* [Installation Instructions](https://www.bookstackapp.com/docs/admin/installation)
* [Documentation](https://www.bookstackapp.com/docs)
* [Demo Instance](https://demo.bookstackapp.com) *(Login username: `admin@example.com`. Password: `password`)*
* [Demo Instance](https://demo.bookstackapp.com)
* *Username: `admin@example.com`*
* *Password: `password`*
* [BookStack Blog](https://www.bookstackapp.com/blog)
## 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 it's version. Here are the current development requirements:
* [Node.js](https://nodejs.org/en/)
* [Gulp](http://gulpjs.com/)
* [Node.js](https://nodejs.org/en/) v6.9+
SASS is used to help the CSS development and the JavaScript is run through browserify/babel to allow for writing ES6 code. Both of these are done using gulp.
SASS is used to help the CSS development and the JavaScript is run through browserify/babel to allow for writing ES6 code. Both of these are done using gulp. To run the build task you can use the following commands:
``` bash
# Build and minify for production
npm run-script build
# Build for dev (With sourcemaps) and watch for changes
npm run-script 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 installed and accessible via command line. 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.
The testing database will also need migrating and seeding beforehand. This can be done with the following commands:
```
``` bash
php artisan migrate --database=mysql_testing
php artisan db:seed --class=DummyContentSeeder --database=mysql_testing
```
Once done you can run `phpunit` (or `./vendor/bin/phpunit` if `phpunit` is not found) in the application root directory to run all tests.
Once done you can run `phpunit` in the application root directory to run all tests.
## Translations
As part of BookStack v0.14 support for translations has been built in. 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`.
Some strings have colon-prefixed variables in such as `:userName`. Leave these values as they are as they will be replaced at run-time.
## Website, Docs & Blog
The website project docs & Blog can be found in the [BookStackApp/website](https://github.com/BookStackApp/website) repo.
## License
BookStack is provided under the MIT License.
The BookStack source is provided under the MIT License.
## Attribution
@@ -51,3 +70,11 @@ These are the great projects used to help build BookStack:
* [TinyColorPicker](http://www.dematte.at/tinyColorPicker/index.html)
* [Marked](https://github.com/chjj/marked)
* [Moment.js](http://momentjs.com/)
* [BarryVD](https://github.com/barryvdh)
* [Debugbar](https://github.com/barryvdh/laravel-debugbar)
* [Dompdf](https://github.com/barryvdh/laravel-dompdf)
* [Snappy (WKHTML2PDF)](https://github.com/barryvdh/laravel-snappy)
* [Laravel IDE helper](https://github.com/barryvdh/laravel-ide-helper)
* [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html)
Additionally, Thank you [BrowserStack](https://www.browserstack.com/) for supporting us and making cross-browser testing easy.

View File

@@ -1,3 +0,0 @@
<div class="dropzone-container">
<div class="dz-message">Drop files or click here to upload</div>
</div>

View File

@@ -1,15 +0,0 @@
<div class="image-picker">
<div>
<img ng-if="image && image !== 'none'" ng-src="{{image}}" ng-class="{{imageClass}}" alt="Image Preview">
<img ng-if="image === '' && defaultImage" ng-src="{{defaultImage}}" ng-class="{{imageClass}}" alt="Image Preview">
</div>
<button class="button" type="button" ng-click="showImageManager()">Select Image</button>
<br>
<button class="text-button" ng-click="reset()" type="button">Reset</button>
<span ng-show="showRemove" class="sep">|</span>
<button ng-show="showRemove" class="text-button neg" ng-click="remove()" type="button">Remove</button>
<input type="hidden" ng-attr-name="{{name}}" ng-attr-id="{{name}}" ng-attr-value="{{value}}">
</div>

View File

@@ -1,4 +0,0 @@
<div class="toggle-switch" ng-click="switch()" ng-class="{'active': isActive}">
<input type="hidden" ng-attr-name="{{name}}" ng-attr-value="{{value}}"/>
<div class="switch-handle"></div>
</div>

View File

@@ -1,8 +1,12 @@
"use strict";
const moment = require('moment');
import moment from 'moment';
import 'moment/locale/en-gb';
import editorOptions from "./pages/page-form";
module.exports = function (ngApp, events) {
moment.locale('en-gb');
export default function (ngApp, events) {
ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService',
function ($scope, $attrs, $http, $timeout, imageManagerService) {
@@ -17,18 +21,18 @@ module.exports = function (ngApp, events) {
$scope.imageDeleteSuccess = false;
$scope.uploadedTo = $attrs.uploadedTo;
$scope.view = 'all';
$scope.searching = false;
$scope.searchTerm = '';
var page = 0;
var previousClickTime = 0;
var previousClickImage = 0;
var dataLoaded = false;
var callback = false;
let page = 0;
let previousClickTime = 0;
let previousClickImage = 0;
let dataLoaded = false;
let callback = false;
var preSearchImages = [];
var preSearchHasMore = false;
let preSearchImages = [];
let preSearchHasMore = false;
/**
* Used by dropzone to get the endpoint to upload to.
@@ -48,7 +52,7 @@ module.exports = function (ngApp, events) {
$scope.hasMore = preSearchHasMore;
}
$scope.cancelSearch = cancelSearch;
/**
* Runs on image upload, Adds an image to local list of images
@@ -60,7 +64,7 @@ module.exports = function (ngApp, events) {
$scope.$apply(() => {
$scope.images.unshift(data);
});
events.emit('success', 'Image uploaded');
events.emit('success', trans('components.image_upload_success'));
};
/**
@@ -77,9 +81,9 @@ module.exports = function (ngApp, events) {
* @param image
*/
$scope.imageSelect = function (image) {
var dblClickTime = 300;
var currentTime = Date.now();
var timeDiff = currentTime - previousClickTime;
let dblClickTime = 300;
let currentTime = Date.now();
let timeDiff = currentTime - previousClickTime;
if (timeDiff < dblClickTime && image.id === previousClickImage) {
// If double click
@@ -135,22 +139,21 @@ module.exports = function (ngApp, events) {
$('#image-manager').find('.overlay').fadeOut(240);
};
var baseUrl = window.baseUrl('/images/' + $scope.imageType + '/all/');
let baseUrl = window.baseUrl('/images/' + $scope.imageType + '/all/');
/**
* Fetch the list image data from the server.
*/
function fetchData() {
var url = baseUrl + page + '?';
var components = {};
let url = baseUrl + page + '?';
let components = {};
if ($scope.uploadedTo) components['page_id'] = $scope.uploadedTo;
if ($scope.searching) components['term'] = $scope.searchTerm;
var urlQueryString = Object.keys(components).map((key) => {
url += Object.keys(components).map((key) => {
return key + '=' + encodeURIComponent(components[key]);
}).join('&');
url += urlQueryString;
$http.get(url).then((response) => {
$scope.images = $scope.images.concat(response.data.images);
@@ -162,7 +165,6 @@ module.exports = function (ngApp, events) {
/**
* Start a search operation
* @param searchTerm
*/
$scope.searchImages = function() {
@@ -196,7 +198,7 @@ module.exports = function (ngApp, events) {
$scope.view = viewName;
baseUrl = window.baseUrl('/images/' + $scope.imageType + '/' + viewName + '/');
fetchData();
}
};
/**
* Save the details of an image.
@@ -204,13 +206,13 @@ module.exports = function (ngApp, events) {
*/
$scope.saveImageDetails = function (event) {
event.preventDefault();
var url = window.baseUrl('/images/update/' + $scope.selectedImage.id);
$http.put(url, this.selectedImage).then((response) => {
events.emit('success', 'Image details updated');
let url = window.baseUrl('/images/update/' + $scope.selectedImage.id);
$http.put(url, this.selectedImage).then(response => {
events.emit('success', trans('components.image_update_success'));
}, (response) => {
if (response.status === 422) {
var errors = response.data;
var message = '';
let errors = response.data;
let message = '';
Object.keys(errors).forEach((key) => {
message += errors[key].join('\n');
});
@@ -229,13 +231,13 @@ module.exports = function (ngApp, events) {
*/
$scope.deleteImage = function (event) {
event.preventDefault();
var force = $scope.dependantPages !== false;
var url = window.baseUrl('/images/' + $scope.selectedImage.id);
let force = $scope.dependantPages !== false;
let url = window.baseUrl('/images/' + $scope.selectedImage.id);
if (force) url += '?force=true';
$http.delete(url).then((response) => {
$scope.images.splice($scope.images.indexOf($scope.selectedImage), 1);
$scope.selectedImage = false;
events.emit('success', 'Image successfully deleted');
events.emit('success', trans('components.image_delete_success'));
}, (response) => {
// Pages failure
if (response.status === 400) {
@@ -265,11 +267,11 @@ module.exports = function (ngApp, events) {
$scope.searchBook = function (e) {
e.preventDefault();
var term = $scope.searchTerm;
let term = $scope.searchTerm;
if (term.length == 0) return;
$scope.searching = true;
$scope.searchResults = '';
var searchUrl = window.baseUrl('/search/book/' + $attrs.bookId);
let searchUrl = window.baseUrl('/search/book/' + $attrs.bookId);
searchUrl += '?term=' + encodeURIComponent(term);
$http.get(searchUrl).then((response) => {
$scope.searchResults = $sce.trustAsHtml(response.data);
@@ -293,31 +295,32 @@ module.exports = function (ngApp, events) {
ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', '$sce',
function ($scope, $http, $attrs, $interval, $timeout, $sce) {
$scope.editorOptions = require('./pages/page-form');
$scope.editorOptions = editorOptions();
$scope.editContent = '';
$scope.draftText = '';
var pageId = Number($attrs.pageId);
var isEdit = pageId !== 0;
var autosaveFrequency = 30; // AutoSave interval in seconds.
var isMarkdown = $attrs.editorType === 'markdown';
let pageId = Number($attrs.pageId);
let isEdit = pageId !== 0;
let autosaveFrequency = 30; // AutoSave interval in seconds.
let isMarkdown = $attrs.editorType === 'markdown';
$scope.draftsEnabled = $attrs.draftsEnabled === 'true';
$scope.isUpdateDraft = Number($attrs.pageUpdateDraft) === 1;
$scope.isNewPageDraft = Number($attrs.pageNewDraft) === 1;
// Set inital header draft text
// Set initial header draft text
if ($scope.isUpdateDraft || $scope.isNewPageDraft) {
$scope.draftText = 'Editing Draft'
$scope.draftText = trans('entities.pages_editing_draft');
} else {
$scope.draftText = 'Editing Page'
};
$scope.draftText = trans('entities.pages_editing_page');
}
var autoSave = false;
let autoSave = false;
var currentContent = {
let currentContent = {
title: false,
html: false
};
if (isEdit) {
if (isEdit && $scope.draftsEnabled) {
setTimeout(() => {
startAutoSave();
}, 1000);
@@ -336,6 +339,8 @@ module.exports = function (ngApp, events) {
$scope.editorChange = function() {};
}
let lastSave = 0;
/**
* Start the AutoSave loop, Checks for content change
* before performing the costly AJAX request.
@@ -345,8 +350,10 @@ module.exports = function (ngApp, events) {
currentContent.html = $scope.editContent;
autoSave = $interval(() => {
var newTitle = $('#name').val();
var newHtml = $scope.editContent;
// Return if manually saved recently to prevent bombarding the server
if (Date.now() - lastSave < (1000*autosaveFrequency)/2) return;
let newTitle = $('#name').val();
let newHtml = $scope.editContent;
if (newTitle !== currentContent.title || newHtml !== currentContent.html) {
currentContent.html = newHtml;
@@ -357,11 +364,13 @@ module.exports = function (ngApp, events) {
}, 1000 * autosaveFrequency);
}
let draftErroring = false;
/**
* Save a draft update into the system via an AJAX request.
*/
function saveDraft() {
var data = {
if (!$scope.draftsEnabled) return;
let data = {
name: $('#name').val(),
html: isMarkdown ? $sce.getTrustedHtml($scope.displayContent) : $scope.editContent
};
@@ -369,11 +378,17 @@ module.exports = function (ngApp, events) {
if (isMarkdown) data.markdown = $scope.editContent;
let url = window.baseUrl('/ajax/page/' + pageId + '/save-draft');
$http.put(url, data).then((responseData) => {
var updateTime = moment.utc(moment.unix(responseData.data.timestamp)).toDate();
$http.put(url, data).then(responseData => {
draftErroring = false;
let updateTime = moment.utc(moment.unix(responseData.data.timestamp)).toDate();
$scope.draftText = responseData.data.message + moment(updateTime).format('HH:mm');
if (!$scope.isNewPageDraft) $scope.isUpdateDraft = true;
showDraftSaveNotification();
lastSave = Date.now();
}, errorRes => {
if (draftErroring) return;
events.emit('error', trans('errors.page_draft_autosave_fail'));
draftErroring = true;
});
}
@@ -405,7 +420,7 @@ module.exports = function (ngApp, events) {
let url = window.baseUrl('/ajax/page/' + pageId);
$http.get(url).then((responseData) => {
if (autoSave) $interval.cancel(autoSave);
$scope.draftText = 'Editing Page';
$scope.draftText = trans('entities.pages_editing_page');
$scope.isUpdateDraft = false;
$scope.$broadcast('html-update', responseData.data.html);
$scope.$broadcast('markdown-update', responseData.data.markdown || responseData.data.html);
@@ -413,7 +428,7 @@ module.exports = function (ngApp, events) {
$timeout(() => {
startAutoSave();
}, 1000);
events.emit('success', 'Draft discarded, The editor has been updated with the current page content');
events.emit('success', trans('entities.pages_draft_discarded'));
});
};
@@ -424,7 +439,7 @@ module.exports = function (ngApp, events) {
const pageId = Number($attrs.pageId);
$scope.tags = [];
$scope.sortOptions = {
handle: '.handle',
items: '> tr',
@@ -447,7 +462,7 @@ module.exports = function (ngApp, events) {
* Get all tags for the current book and add into scope.
*/
function getTags() {
let url = window.baseUrl('/ajax/tags/get/page/' + pageId);
let url = window.baseUrl(`/ajax/tags/get/page/${pageId}`);
$http.get(url).then((responseData) => {
$scope.tags = responseData.data;
addEmptyTag();
@@ -491,20 +506,6 @@ module.exports = function (ngApp, events) {
}
};
/**
* Save the tags to the current page.
*/
$scope.saveTags = function() {
setTagOrder();
let postData = {tags: $scope.tags};
let url = window.baseUrl('/ajax/tags/update/page/' + pageId);
$http.post(url, postData).then((responseData) => {
$scope.tags = responseData.data.tags;
addEmptyTag();
events.emit('success', responseData.data.message);
})
};
/**
* Remove a tag from the current list.
* @param tag
@@ -516,21 +517,201 @@ module.exports = function (ngApp, events) {
}]);
ngApp.controller('PageAttachmentController', ['$scope', '$http', '$attrs',
function ($scope, $http, $attrs) {
const pageId = $scope.uploadedTo = $attrs.pageId;
let currentOrder = '';
$scope.files = [];
$scope.editFile = false;
$scope.file = getCleanFile();
$scope.errors = {
link: {},
edit: {}
};
function getCleanFile() {
return {
page_id: pageId
};
}
// Angular-UI-Sort options
$scope.sortOptions = {
handle: '.handle',
items: '> tr',
containment: "parent",
axis: "y",
stop: sortUpdate,
};
/**
* Event listener for sort changes.
* Updates the file ordering on the server.
* @param event
* @param ui
*/
function sortUpdate(event, ui) {
let newOrder = $scope.files.map(file => {return file.id}).join(':');
if (newOrder === currentOrder) return;
currentOrder = newOrder;
$http.put(window.baseUrl(`/attachments/sort/page/${pageId}`), {files: $scope.files}).then(resp => {
events.emit('success', resp.data.message);
}, checkError('sort'));
}
/**
* Used by dropzone to get the endpoint to upload to.
* @returns {string}
*/
$scope.getUploadUrl = function (file) {
let suffix = (typeof file !== 'undefined') ? `/${file.id}` : '';
return window.baseUrl(`/attachments/upload${suffix}`);
};
/**
* Get files for the current page from the server.
*/
function getFiles() {
let url = window.baseUrl(`/attachments/get/page/${pageId}`);
$http.get(url).then(resp => {
$scope.files = resp.data;
currentOrder = resp.data.map(file => {return file.id}).join(':');
}, checkError('get'));
}
getFiles();
/**
* Runs on file upload, Adds an file to local file list
* and shows a success message to the user.
* @param file
* @param data
*/
$scope.uploadSuccess = function (file, data) {
$scope.$apply(() => {
$scope.files.push(data);
});
events.emit('success', trans('entities.attachments_file_uploaded'));
};
/**
* Upload and overwrite an existing file.
* @param file
* @param data
*/
$scope.uploadSuccessUpdate = function (file, data) {
$scope.$apply(() => {
let search = filesIndexOf(data);
if (search !== -1) $scope.files[search] = data;
if ($scope.editFile) {
$scope.editFile = angular.copy(data);
data.link = '';
}
});
events.emit('success', trans('entities.attachments_file_updated'));
};
/**
* Delete a file from the server and, on success, the local listing.
* @param file
*/
$scope.deleteFile = function(file) {
if (!file.deleting) {
file.deleting = true;
return;
}
$http.delete(window.baseUrl(`/attachments/${file.id}`)).then(resp => {
events.emit('success', resp.data.message);
$scope.files.splice($scope.files.indexOf(file), 1);
}, checkError('delete'));
};
/**
* Attach a link to a page.
* @param file
*/
$scope.attachLinkSubmit = function(file) {
file.uploaded_to = pageId;
$http.post(window.baseUrl('/attachments/link'), file).then(resp => {
$scope.files.push(resp.data);
events.emit('success', trans('entities.attachments_link_attached'));
$scope.file = getCleanFile();
}, checkError('link'));
};
/**
* Start the edit mode for a file.
* @param file
*/
$scope.startEdit = function(file) {
$scope.editFile = angular.copy(file);
$scope.editFile.link = (file.external) ? file.path : '';
};
/**
* Cancel edit mode
*/
$scope.cancelEdit = function() {
$scope.editFile = false;
};
/**
* Update the name and link of a file.
* @param file
*/
$scope.updateFile = function(file) {
$http.put(window.baseUrl(`/attachments/${file.id}`), file).then(resp => {
let search = filesIndexOf(resp.data);
if (search !== -1) $scope.files[search] = resp.data;
if ($scope.editFile && !file.external) {
$scope.editFile.link = '';
}
$scope.editFile = false;
events.emit('success', trans('entities.attachments_updated_success'));
}, checkError('edit'));
};
/**
* Get the url of a file.
*/
$scope.getFileUrl = function(file) {
return window.baseUrl('/attachments/' + file.id);
};
/**
* Search the local files via another file object.
* Used to search via object copies.
* @param file
* @returns int
*/
function filesIndexOf(file) {
for (let i = 0; i < $scope.files.length; i++) {
if ($scope.files[i].id == file.id) return i;
}
return -1;
}
/**
* Check for an error response in a ajax request.
* @param errorGroupName
*/
function checkError(errorGroupName) {
$scope.errors[errorGroupName] = {};
return function(response) {
if (typeof response.data !== 'undefined' && typeof response.data.error !== 'undefined') {
events.emit('error', response.data.error);
}
if (typeof response.data !== 'undefined' && typeof response.data.validation !== 'undefined') {
$scope.errors[errorGroupName] = response.data.validation;
console.log($scope.errors[errorGroupName])
}
}
}
}]);
};

View File

@@ -1,105 +1,61 @@
"use strict";
const DropZone = require('dropzone');
const markdown = require('marked');
import DropZone from "dropzone";
import markdown from "marked";
const toggleSwitchTemplate = require('./components/toggle-switch.html');
const imagePickerTemplate = require('./components/image-picker.html');
const dropZoneTemplate = require('./components/drop-zone.html');
module.exports = function (ngApp, events) {
export default function (ngApp, events) {
/**
* Toggle Switches
* Has basic on/off functionality.
* Use string values of 'true' & 'false' to dictate the current state.
* Common tab controls using simple jQuery functions.
*/
ngApp.directive('toggleSwitch', function () {
ngApp.directive('tabContainer', function() {
return {
restrict: 'A',
template: toggleSwitchTemplate,
scope: true,
link: function (scope, element, attrs) {
scope.name = attrs.name;
scope.value = attrs.value;
scope.isActive = scope.value == true && scope.value != 'false';
scope.value = (scope.value == true && scope.value != 'false') ? 'true' : 'false';
const $content = element.find('[tab-content]');
const $buttons = element.find('[tab-button]');
scope.switch = function () {
scope.isActive = !scope.isActive;
scope.value = scope.isActive ? 'true' : 'false';
if (attrs.tabContainer) {
let initial = attrs.tabContainer;
$buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
$content.hide().filter(`[tab-content="${initial}"]`).show();
} else {
$content.hide().first().show();
$buttons.first().addClass('selected');
}
$buttons.click(function() {
let clickedTab = $(this);
$buttons.removeClass('selected');
$content.hide();
let name = clickedTab.addClass('selected').attr('tab-button');
$content.filter(`[tab-content="${name}"]`).show();
});
}
};
});
/**
* Image Picker
* Is a simple front-end interface that connects to an ImageManager if present.
* Sub form component to allow inner-form sections to act like their own forms.
*/
ngApp.directive('imagePicker', ['$http', 'imageManagerService', function ($http, imageManagerService) {
ngApp.directive('subForm', function() {
return {
restrict: 'E',
template: imagePickerTemplate,
scope: {
name: '@',
resizeHeight: '@',
resizeWidth: '@',
resizeCrop: '@',
showRemove: '=',
currentImage: '@',
currentId: '@',
defaultImage: '@',
imageClass: '@'
},
restrict: 'A',
link: function (scope, element, attrs) {
let usingIds = typeof scope.currentId !== 'undefined' || scope.currentId === 'false';
scope.image = scope.currentImage;
scope.value = scope.currentImage || '';
if (usingIds) scope.value = scope.currentId;
function setImage(imageModel, imageUrl) {
scope.image = imageUrl;
scope.value = usingIds ? imageModel.id : imageUrl;
}
scope.reset = function () {
setImage({id: 0}, scope.defaultImage);
};
scope.remove = function () {
scope.image = 'none';
scope.value = 'none';
};
scope.showImageManager = function () {
imageManagerService.show((image) => {
scope.updateImageFromModel(image);
});
};
scope.updateImageFromModel = function (model) {
let isResized = scope.resizeWidth && scope.resizeHeight;
if (!isResized) {
scope.$apply(() => {
setImage(model, model.url);
});
return;
element.on('keypress', e => {
if (e.keyCode === 13) {
submitEvent(e);
}
});
let cropped = scope.resizeCrop ? 'true' : 'false';
let requestString = '/images/thumb/' + model.id + '/' + scope.resizeWidth + '/' + scope.resizeHeight + '/' + cropped;
requestString = window.baseUrl(requestString);
$http.get(requestString).then((response) => {
setImage(model, response.data.url);
});
};
element.find('button[type="submit"]').click(submitEvent);
function submitEvent(e) {
e.preventDefault();
if (attrs.subForm) scope.$eval(attrs.subForm);
}
}
};
}]);
});
/**
* DropZone
@@ -108,22 +64,28 @@ module.exports = function (ngApp, events) {
ngApp.directive('dropZone', [function () {
return {
restrict: 'E',
template: dropZoneTemplate,
template: `
<div class="dropzone-container">
<div class="dz-message">{{message}}</div>
</div>
`,
scope: {
uploadUrl: '@',
eventSuccess: '=',
eventError: '=',
uploadedTo: '@'
uploadedTo: '@',
},
link: function (scope, element, attrs) {
var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
scope.message = attrs.message;
if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
let dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
url: scope.uploadUrl,
init: function () {
var dz = this;
let dz = this;
dz.on('sending', function (file, xhr, data) {
var token = window.document.querySelector('meta[name=token]').getAttribute('content');
let token = window.document.querySelector('meta[name=token]').getAttribute('content');
data.append('_token', token);
var uploadedTo = typeof scope.uploadedTo === 'undefined' ? 0 : scope.uploadedTo;
let uploadedTo = typeof scope.uploadedTo === 'undefined' ? 0 : scope.uploadedTo;
data.append('uploaded_to', uploadedTo);
});
if (typeof scope.eventSuccess !== 'undefined') dz.on('success', scope.eventSuccess);
@@ -140,7 +102,7 @@ module.exports = function (ngApp, events) {
$(file.previewElement).find('[data-dz-errormessage]').text(message);
}
if (xhr.status === 413) setMessage('The server does not allow uploads of this size. Please try a smaller file.');
if (xhr.status === 413) setMessage(trans('errors.server_upload_limit'));
if (errorMessage.file) setMessage(errorMessage.file[0]);
});
@@ -199,7 +161,7 @@ module.exports = function (ngApp, events) {
function tinyMceSetup(editor) {
editor.on('ExecCommand change NodeChange ObjectResized', (e) => {
var content = editor.getContent();
let content = editor.getContent();
$timeout(() => {
scope.mceModel = content;
});
@@ -227,9 +189,9 @@ module.exports = function (ngApp, events) {
// Custom tinyMCE plugins
tinymce.PluginManager.add('customhr', function (editor) {
editor.addCommand('InsertHorizontalRule', function () {
var hrElem = document.createElement('hr');
var cNode = editor.selection.getNode();
var parentNode = cNode.parentNode;
let hrElem = document.createElement('hr');
let cNode = editor.selection.getNode();
let parentNode = cNode.parentNode;
parentNode.insertBefore(hrElem, cNode);
});
@@ -299,15 +261,21 @@ module.exports = function (ngApp, events) {
link: function (scope, element, attrs) {
// Elements
const input = element.find('[markdown-input] textarea').first();
const display = element.find('.markdown-display').first();
const insertImage = element.find('button[data-action="insertImage"]');
const insertEntityLink = element.find('button[data-action="insertEntityLink"]')
const $input = element.find('[markdown-input] textarea').first();
const $display = element.find('.markdown-display').first();
const $insertImage = element.find('button[data-action="insertImage"]');
const $insertEntityLink = element.find('button[data-action="insertEntityLink"]');
// Prevent markdown display link click redirect
$display.on('click', 'a', function(event) {
event.preventDefault();
window.open(this.getAttribute('href'));
});
let currentCaretPos = 0;
input.blur(event => {
currentCaretPos = input[0].selectionStart;
$input.blur(event => {
currentCaretPos = $input[0].selectionStart;
});
// Scroll sync
@@ -317,10 +285,10 @@ module.exports = function (ngApp, events) {
displayHeight;
function setScrollHeights() {
inputScrollHeight = input[0].scrollHeight;
inputHeight = input.height();
displayScrollHeight = display[0].scrollHeight;
displayHeight = display.height();
inputScrollHeight = $input[0].scrollHeight;
inputHeight = $input.height();
displayScrollHeight = $display[0].scrollHeight;
displayHeight = $display.height();
}
setTimeout(() => {
@@ -329,29 +297,29 @@ module.exports = function (ngApp, events) {
window.addEventListener('resize', setScrollHeights);
let scrollDebounceTime = 800;
let lastScroll = 0;
input.on('scroll', event => {
$input.on('scroll', event => {
let now = Date.now();
if (now - lastScroll > scrollDebounceTime) {
setScrollHeights()
}
let scrollPercent = (input.scrollTop() / (inputScrollHeight - inputHeight));
let scrollPercent = ($input.scrollTop() / (inputScrollHeight - inputHeight));
let displayScrollY = (displayScrollHeight - displayHeight) * scrollPercent;
display.scrollTop(displayScrollY);
$display.scrollTop(displayScrollY);
lastScroll = now;
});
// Editor key-presses
input.keydown(event => {
$input.keydown(event => {
// Insert image shortcut
if (event.which === 73 && event.ctrlKey && event.shiftKey) {
event.preventDefault();
let caretPos = input[0].selectionStart;
let currentContent = input.val();
let caretPos = $input[0].selectionStart;
let currentContent = $input.val();
const mdImageText = "![](http://)";
input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
input.focus();
input[0].selectionStart = caretPos + ("![](".length);
input[0].selectionEnd = caretPos + ('![](http://'.length);
$input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
$input.focus();
$input[0].selectionStart = caretPos + ("![](".length);
$input[0].selectionEnd = caretPos + ('![](http://'.length);
return;
}
@@ -366,48 +334,48 @@ module.exports = function (ngApp, events) {
});
// Insert image from image manager
insertImage.click(event => {
$insertImage.click(event => {
window.ImageManager.showExternal(image => {
let caretPos = currentCaretPos;
let currentContent = input.val();
let currentContent = $input.val();
let mdImageText = "![" + image.name + "](" + image.thumbs.display + ")";
input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
input.change();
$input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
$input.change();
});
});
function showLinkSelector() {
window.showEntityLinkSelector((entity) => {
let selectionStart = currentCaretPos;
let selectionEnd = input[0].selectionEnd;
let selectionEnd = $input[0].selectionEnd;
let textSelected = (selectionEnd !== selectionStart);
let currentContent = input.val();
let currentContent = $input.val();
if (textSelected) {
let selectedText = currentContent.substring(selectionStart, selectionEnd);
let linkText = `[${selectedText}](${entity.link})`;
input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionEnd));
$input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionEnd));
} else {
let linkText = ` [${entity.name}](${entity.link}) `;
input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionStart))
$input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionStart))
}
input.change();
$input.change();
});
}
insertEntityLink.click(showLinkSelector);
$insertEntityLink.click(showLinkSelector);
// Upload and insert image on paste
function editorPaste(e) {
e = e.originalEvent;
if (!e.clipboardData) return
var items = e.clipboardData.items;
let items = e.clipboardData.items;
if (!items) return;
for (var i = 0; i < items.length; i++) {
for (let i = 0; i < items.length; i++) {
uploadImage(items[i].getAsFile());
}
}
input.on('paste', editorPaste);
$input.on('paste', editorPaste);
// Handle image drop, Uploads images to BookStack.
function handleImageDrop(event) {
@@ -419,17 +387,17 @@ module.exports = function (ngApp, events) {
}
}
input.on('drop', handleImageDrop);
$input.on('drop', handleImageDrop);
// Handle image upload and add image into markdown content
function uploadImage(file) {
if (file.type.indexOf('image') !== 0) return;
var formData = new FormData();
var ext = 'png';
var xhr = new XMLHttpRequest();
let formData = new FormData();
let ext = 'png';
let xhr = new XMLHttpRequest();
if (file.name) {
var fileNameMatches = file.name.match(/\.(.+)$/);
let fileNameMatches = file.name.match(/\.(.+)$/);
if (fileNameMatches) {
ext = fileNameMatches[1];
}
@@ -437,17 +405,17 @@ module.exports = function (ngApp, events) {
// Insert image into markdown
let id = "image-" + Math.random().toString(16).slice(2);
let selectStart = input[0].selectionStart;
let selectEnd = input[0].selectionEnd;
let content = input[0].value;
let selectStart = $input[0].selectionStart;
let selectEnd = $input[0].selectionEnd;
let content = $input[0].value;
let selectText = content.substring(selectStart, selectEnd);
let placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
let innerContent = ((selectEnd > selectStart) ? `![${selectText}]` : '![]') + `(${placeholderImage})`;
input[0].value = content.substring(0, selectStart) + innerContent + content.substring(selectEnd);
$input[0].value = content.substring(0, selectStart) + innerContent + content.substring(selectEnd);
input.focus();
input[0].selectionStart = selectStart;
input[0].selectionEnd = selectStart;
$input.focus();
$input[0].selectionStart = selectStart;
$input[0].selectionEnd = selectStart;
let remoteFilename = "image-" + Date.now() + "." + ext;
formData.append('file', file, remoteFilename);
@@ -455,20 +423,20 @@ module.exports = function (ngApp, events) {
xhr.open('POST', window.baseUrl('/images/gallery/upload'));
xhr.onload = function () {
let selectStart = input[0].selectionStart;
let selectStart = $input[0].selectionStart;
if (xhr.status === 200 || xhr.status === 201) {
var result = JSON.parse(xhr.responseText);
input[0].value = input[0].value.replace(placeholderImage, result.thumbs.display);
input.change();
let result = JSON.parse(xhr.responseText);
$input[0].value = $input[0].value.replace(placeholderImage, result.thumbs.display);
$input.change();
} else {
console.log('An error occurred uploading the image');
console.log(trans('errors.image_upload_error'));
console.log(xhr.responseText);
input[0].value = input[0].value.replace(innerContent, '');
input.change();
$input[0].value = $input[0].value.replace(innerContent, '');
$input.change();
}
input.focus();
input[0].selectionStart = selectStart;
input[0].selectionEnd = selectStart;
$input.focus();
$input[0].selectionStart = selectStart;
$input[0].selectionEnd = selectStart;
};
xhr.send(formData);
}
@@ -488,8 +456,8 @@ module.exports = function (ngApp, events) {
link: function (scope, elem, attrs) {
// Get common elements
const $buttons = elem.find('[tab-button]');
const $content = elem.find('[tab-content]');
const $buttons = elem.find('[toolbox-tab-button]');
const $content = elem.find('[toolbox-tab-content]');
const $toggle = elem.find('[toolbox-toggle]');
// Handle toolbox toggle click
@@ -501,17 +469,17 @@ module.exports = function (ngApp, events) {
function setActive(tabName, openToolbox) {
$buttons.removeClass('active');
$content.hide();
$buttons.filter(`[tab-button="${tabName}"]`).addClass('active');
$content.filter(`[tab-content="${tabName}"]`).show();
$buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active');
$content.filter(`[toolbox-tab-content="${tabName}"]`).show();
if (openToolbox) elem.addClass('open');
}
// Set the first tab content active on load
setActive($content.first().attr('tab-content'), false);
setActive($content.first().attr('toolbox-tab-content'), false);
// Handle tab button click
$buttons.click(function (e) {
let name = $(this).attr('tab-button');
let name = $(this).attr('toolbox-tab-button');
setActive(name, true);
});
}
@@ -549,7 +517,7 @@ module.exports = function (ngApp, events) {
let val = $input.val();
let url = $input.attr('autosuggest');
let type = $input.attr('autosuggest-type');
// Add name param to request if for a value
if (type.toLowerCase() === 'value') {
let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
@@ -606,8 +574,7 @@ module.exports = function (ngApp, events) {
}
// Enter or tab key
else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
let text = suggestionElems[active].textContent;
currentInput[0].value = text;
currentInput[0].value = suggestionElems[active].textContent;
currentInput.focus();
$suggestionBox.hide();
isShowing = false;
@@ -658,14 +625,13 @@ module.exports = function (ngApp, events) {
// Build suggestions
$suggestionBox[0].innerHTML = '';
for (let i = 0; i < suggestions.length; i++) {
var suggestion = document.createElement('li');
let suggestion = document.createElement('li');
suggestion.textContent = suggestions[i];
suggestion.onclick = suggestionClick;
if (i === 0) {
suggestion.className = 'active'
suggestion.className = 'active';
active = 0;
}
;
$suggestionBox[0].appendChild(suggestion);
}
@@ -674,12 +640,11 @@ module.exports = function (ngApp, events) {
// Suggestion click event
function suggestionClick(event) {
let text = this.textContent;
currentInput[0].value = text;
currentInput[0].value = this.textContent;
currentInput.focus();
$suggestionBox.hide();
isShowing = false;
};
}
// Get suggestions & cache
function getSuggestions(input, url) {
@@ -705,7 +670,7 @@ module.exports = function (ngApp, events) {
ngApp.directive('entityLinkSelector', [function($http) {
return {
restict: 'A',
restrict: 'A',
link: function(scope, element, attrs) {
const selectButton = element.find('.entity-link-selector-confirm');
@@ -769,7 +734,7 @@ module.exports = function (ngApp, events) {
const input = element.find('[entity-selector-input]').first();
// Detect double click events
var lastClick = 0;
let lastClick = 0;
function isDoubleClick() {
let now = Date.now();
let answer = now - lastClick < 300;
@@ -850,17 +815,3 @@ module.exports = function (ngApp, events) {
};
}]);
};

View File

@@ -1,11 +1,11 @@
"use strict";
// AngularJS - Create application and load components
var angular = require('angular');
var ngResource = require('angular-resource');
var ngAnimate = require('angular-animate');
var ngSanitize = require('angular-sanitize');
require('angular-ui-sortable');
import angular from "angular";
import "angular-resource";
import "angular-animate";
import "angular-sanitize";
import "angular-ui-sortable";
// Url retrieval function
window.baseUrl = function(path) {
@@ -15,7 +15,13 @@ window.baseUrl = function(path) {
return basePath + '/' + path;
};
var ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']);
let ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']);
// Translation setup
// Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
import Translations from "./translations"
let translator = new Translations(window.translations);
window.trans = translator.get.bind(translator);
// Global Event System
class EventManager {
@@ -25,9 +31,9 @@ class EventManager {
emit(eventName, eventData) {
if (typeof this.listeners[eventName] === 'undefined') return this;
var eventsToStart = this.listeners[eventName];
let eventsToStart = this.listeners[eventName];
for (let i = 0; i < eventsToStart.length; i++) {
var event = eventsToStart[i];
let event = eventsToStart[i];
event(eventData);
}
return this;
@@ -38,23 +44,26 @@ class EventManager {
this.listeners[eventName].push(callback);
return this;
}
};
}
window.Events = new EventManager();
var services = require('./services')(ngApp, window.Events);
var directives = require('./directives')(ngApp, window.Events);
var controllers = require('./controllers')(ngApp, window.Events);
// Load in angular specific items
import Services from './services';
import Directives from './directives';
import Controllers from './controllers';
Services(ngApp, window.Events);
Directives(ngApp, window.Events);
Controllers(ngApp, window.Events);
//Global jQuery Config & Extensions
// Smooth scrolling
jQuery.fn.smoothScrollTo = function () {
if (this.length === 0) return;
let scrollElem = document.documentElement.scrollTop === 0 ? document.body : document.documentElement;
$(scrollElem).animate({
$('html, body').animate({
scrollTop: this.offset().top - 60 // Adjust to change final scroll position top margin
}, 800); // Adjust to change animations speed (ms)
}, 300); // Adjust to change animations speed (ms)
return this;
};
@@ -66,93 +75,83 @@ jQuery.expr[":"].contains = $.expr.createPseudo(function (arg) {
});
// Global jQuery Elements
$(function () {
var notifications = $('.notification');
var successNotification = notifications.filter('.pos');
var errorNotification = notifications.filter('.neg');
var warningNotification = notifications.filter('.warning');
// Notification Events
window.Events.listen('success', function (text) {
successNotification.hide();
successNotification.find('span').text(text);
setTimeout(() => {
successNotification.show();
}, 1);
});
window.Events.listen('warning', function (text) {
warningNotification.find('span').text(text);
warningNotification.show();
});
window.Events.listen('error', function (text) {
errorNotification.find('span').text(text);
errorNotification.show();
});
// Notification hiding
notifications.click(function () {
$(this).fadeOut(100);
});
// Chapter page list toggles
$('.chapter-toggle').click(function (e) {
e.preventDefault();
$(this).toggleClass('open');
$(this).closest('.chapter').find('.inset-list').slideToggle(180);
});
// Back to top button
$('#back-to-top').click(function() {
$('#header').smoothScrollTo();
});
var scrollTopShowing = false;
var scrollTop = document.getElementById('back-to-top');
var scrollTopBreakpoint = 1200;
window.addEventListener('scroll', function() {
let scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0;
if (!scrollTopShowing && scrollTopPos > scrollTopBreakpoint) {
scrollTop.style.display = 'block';
scrollTopShowing = true;
setTimeout(() => {
scrollTop.style.opacity = 0.4;
}, 1);
} else if (scrollTopShowing && scrollTopPos < scrollTopBreakpoint) {
scrollTop.style.opacity = 0;
scrollTopShowing = false;
setTimeout(() => {
scrollTop.style.display = 'none';
}, 500);
}
});
// Common jQuery actions
$('[data-action="expand-entity-list-details"]').click(function() {
$('.entity-list.compact').find('p').not('.empty-text').slideToggle(240);
});
// Popup close
$('.popup-close').click(function() {
$(this).closest('.overlay').fadeOut(240);
});
$('.overlay').click(function(event) {
if (!$(event.target).hasClass('overlay')) return;
$(this).fadeOut(240);
});
// Prevent markdown display link click redirect
$('.markdown-display').on('click', 'a', function(event) {
event.preventDefault();
window.open($(this).attr('href'));
});
// Detect IE for css
if(navigator.userAgent.indexOf('MSIE')!==-1
|| navigator.appVersion.indexOf('Trident/') > 0
|| navigator.userAgent.indexOf('Safari') !== -1){
$('body').addClass('flexbox-support');
}
let notifications = $('.notification');
let successNotification = notifications.filter('.pos');
let errorNotification = notifications.filter('.neg');
let warningNotification = notifications.filter('.warning');
// Notification Events
window.Events.listen('success', function (text) {
successNotification.hide();
successNotification.find('span').text(text);
setTimeout(() => {
successNotification.show();
}, 1);
});
window.Events.listen('warning', function (text) {
warningNotification.find('span').text(text);
warningNotification.show();
});
window.Events.listen('error', function (text) {
errorNotification.find('span').text(text);
errorNotification.show();
});
// Notification hiding
notifications.click(function () {
$(this).fadeOut(100);
});
// Chapter page list toggles
$('.chapter-toggle').click(function (e) {
e.preventDefault();
$(this).toggleClass('open');
$(this).closest('.chapter').find('.inset-list').slideToggle(180);
});
// Back to top button
$('#back-to-top').click(function() {
$('#header').smoothScrollTo();
});
let scrollTopShowing = false;
let scrollTop = document.getElementById('back-to-top');
let scrollTopBreakpoint = 1200;
window.addEventListener('scroll', function() {
let scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0;
if (!scrollTopShowing && scrollTopPos > scrollTopBreakpoint) {
scrollTop.style.display = 'block';
scrollTopShowing = true;
setTimeout(() => {
scrollTop.style.opacity = 0.4;
}, 1);
} else if (scrollTopShowing && scrollTopPos < scrollTopBreakpoint) {
scrollTop.style.opacity = 0;
scrollTopShowing = false;
setTimeout(() => {
scrollTop.style.display = 'none';
}, 500);
}
});
// Common jQuery actions
$('[data-action="expand-entity-list-details"]').click(function() {
$('.entity-list.compact').find('p').not('.empty-text').slideToggle(240);
});
// Popup close
$('.popup-close').click(function() {
$(this).closest('.overlay').fadeOut(240);
});
$('.overlay').click(function(event) {
if (!$(event.target).hasClass('overlay')) return;
$(this).fadeOut(240);
});
// Detect IE for css
if(navigator.userAgent.indexOf('MSIE')!==-1
|| navigator.appVersion.indexOf('Trident/') > 0
|| navigator.userAgent.indexOf('Safari') !== -1){
$('body').addClass('flexbox-support');
}
// Page specific items
require('./pages/page-show');
import "./pages/page-show";

View File

@@ -6,11 +6,11 @@
* @param editor - editor instance
*/
function editorPaste(e, editor) {
if (!e.clipboardData) return
if (!e.clipboardData) return;
let items = e.clipboardData.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") === -1) return
if (items[i].type.indexOf("image") === -1) return;
let file = items[i].getAsFile();
let formData = new FormData();
@@ -60,107 +60,108 @@ function registerEditorShortcuts(editor) {
editor.addShortcut('meta+shift+E', '', ['FormatBlock', false, 'code']);
}
var mceOptions = module.exports = {
selector: '#html-editor',
content_css: [
window.baseUrl('/css/styles.css'),
window.baseUrl('/libs/material-design-iconic-font/css/material-design-iconic-font.min.css')
],
body_class: 'page-content',
relative_urls: false,
remove_script_host: false,
document_base_url: window.baseUrl('/'),
statusbar: false,
menubar: false,
paste_data_images: false,
extended_valid_elements: 'pre[*]',
automatic_uploads: false,
valid_children: "-div[p|pre|h1|h2|h3|h4|h5|h6|blockquote]",
plugins: "image table textcolor paste link fullscreen imagetools code customhr autosave lists",
imagetools_toolbar: 'imageoptions',
toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr | removeformat code fullscreen",
content_style: "body {padding-left: 15px !important; padding-right: 15px !important; margin:0!important; margin-left:auto!important;margin-right:auto!important;}",
style_formats: [
{title: "Header 1", format: "h1"},
{title: "Header 2", format: "h2"},
{title: "Header 3", format: "h3"},
{title: "Paragraph", format: "p", exact: true, classes: ''},
{title: "Blockquote", format: "blockquote"},
{title: "Code Block", icon: "code", format: "pre"},
{title: "Inline Code", icon: "code", inline: "code"},
{title: "Callouts", items: [
{title: "Success", block: 'p', exact: true, attributes : {'class' : 'callout success'}},
{title: "Info", block: 'p', exact: true, attributes : {'class' : 'callout info'}},
{title: "Warning", block: 'p', exact: true, attributes : {'class' : 'callout warning'}},
{title: "Danger", block: 'p', exact: true, attributes : {'class' : 'callout danger'}}
]}
],
style_formats_merge: false,
formats: {
alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},
alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'},
},
file_browser_callback: function (field_name, url, type, win) {
export default function() {
let settings = {
selector: '#html-editor',
content_css: [
window.baseUrl('/css/styles.css'),
window.baseUrl('/libs/material-design-iconic-font/css/material-design-iconic-font.min.css')
],
body_class: 'page-content',
relative_urls: false,
remove_script_host: false,
document_base_url: window.baseUrl('/'),
statusbar: false,
menubar: false,
paste_data_images: false,
extended_valid_elements: 'pre[*]',
automatic_uploads: false,
valid_children: "-div[p|pre|h1|h2|h3|h4|h5|h6|blockquote]",
plugins: "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists",
imagetools_toolbar: 'imageoptions',
toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr | removeformat code fullscreen",
content_style: "body {padding-left: 15px !important; padding-right: 15px !important; margin:0!important; margin-left:auto!important;margin-right:auto!important;}",
style_formats: [
{title: "Header Large", format: "h2"},
{title: "Header Medium", format: "h3"},
{title: "Header Small", format: "h4"},
{title: "Header Tiny", format: "h5"},
{title: "Paragraph", format: "p", exact: true, classes: ''},
{title: "Blockquote", format: "blockquote"},
{title: "Code Block", icon: "code", format: "pre"},
{title: "Inline Code", icon: "code", inline: "code"},
{title: "Callouts", items: [
{title: "Success", block: 'p', exact: true, attributes : {'class' : 'callout success'}},
{title: "Info", block: 'p', exact: true, attributes : {'class' : 'callout info'}},
{title: "Warning", block: 'p', exact: true, attributes : {'class' : 'callout warning'}},
{title: "Danger", block: 'p', exact: true, attributes : {'class' : 'callout danger'}}
]}
],
style_formats_merge: false,
formats: {
alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},
alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'},
},
file_browser_callback: function (field_name, url, type, win) {
if (type === 'file') {
window.showEntityLinkSelector(function(entity) {
let originalField = win.document.getElementById(field_name);
originalField.value = entity.link;
$(originalField).closest('.mce-form').find('input').eq(2).val(entity.name);
});
}
if (type === 'file') {
window.showEntityLinkSelector(function(entity) {
let originalField = win.document.getElementById(field_name);
originalField.value = entity.link;
$(originalField).closest('.mce-form').find('input').eq(2).val(entity.name);
});
}
if (type === 'image') {
// Show image manager
window.ImageManager.showExternal(function (image) {
if (type === 'image') {
// Show image manager
window.ImageManager.showExternal(function (image) {
// Set popover link input to image url then fire change event
// to ensure the new value sticks
win.document.getElementById(field_name).value = image.url;
if ("createEvent" in document) {
let evt = document.createEvent("HTMLEvents");
evt.initEvent("change", false, true);
win.document.getElementById(field_name).dispatchEvent(evt);
} else {
win.document.getElementById(field_name).fireEvent("onchange");
}
// Set popover link input to image url then fire change event
// to ensure the new value sticks
win.document.getElementById(field_name).value = image.url;
if ("createEvent" in document) {
let evt = document.createEvent("HTMLEvents");
evt.initEvent("change", false, true);
win.document.getElementById(field_name).dispatchEvent(evt);
} else {
win.document.getElementById(field_name).fireEvent("onchange");
}
// Replace the actively selected content with the linked image
let html = `<a href="${image.url}" target="_blank">`;
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
html += '</a>';
win.tinyMCE.activeEditor.execCommand('mceInsertContent', false, html);
});
}
// Replace the actively selected content with the linked image
let html = `<a href="${image.url}" target="_blank">`;
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
html += '</a>';
win.tinyMCE.activeEditor.execCommand('mceInsertContent', false, html);
});
}
},
paste_preprocess: function (plugin, args) {
let content = args.content;
if (content.indexOf('<img src="file://') !== -1) {
args.content = '';
}
},
extraSetups: [],
setup: function (editor) {
},
paste_preprocess: function (plugin, args) {
let content = args.content;
if (content.indexOf('<img src="file://') !== -1) {
args.content = '';
}
},
extraSetups: [],
setup: function (editor) {
// Run additional setup actions
// Used by the angular side of things
for (let i = 0; i < mceOptions.extraSetups.length; i++) {
mceOptions.extraSetups[i](editor);
}
// Run additional setup actions
// Used by the angular side of things
for (let i = 0; i < settings.extraSetups.length; i++) {
settings.extraSetups[i](editor);
}
registerEditorShortcuts(editor);
registerEditorShortcuts(editor);
(function () {
var wrap;
let wrap;
function hasTextContent(node) {
return node && !!( node.textContent || node.innerText );
}
editor.on('dragstart', function () {
var node = editor.selection.getNode();
let node = editor.selection.getNode();
if (node.nodeName !== 'IMG') return;
wrap = editor.dom.getParent(node, '.mceTemp');
@@ -171,7 +172,7 @@ var mceOptions = module.exports = {
});
editor.on('drop', function (event) {
var dom = editor.dom,
let dom = editor.dom,
rng = tinymce.dom.RangeUtils.getCaretRangeFromPoint(event.clientX, event.clientY, editor.getDoc());
// Don't allow anything to be dropped in a captioned image.
@@ -189,26 +190,27 @@ var mceOptions = module.exports = {
wrap = null;
});
})();
// Custom Image picker button
editor.addButton('image-insert', {
title: 'My title',
icon: 'image',
tooltip: 'Insert an image',
onclick: function () {
window.ImageManager.showExternal(function (image) {
let html = `<a href="${image.url}" target="_blank">`;
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
html += '</a>';
editor.execCommand('mceInsertContent', false, html);
});
}
});
// Custom Image picker button
editor.addButton('image-insert', {
title: 'My title',
icon: 'image',
tooltip: 'Insert an image',
onclick: function () {
window.ImageManager.showExternal(function (image) {
let html = `<a href="${image.url}" target="_blank">`;
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
html += '</a>';
editor.execCommand('mceInsertContent', false, html);
});
}
});
// Paste image-uploads
editor.on('paste', function(event) {
editorPaste(event, editor);
});
}
};
// Paste image-uploads
editor.on('paste', function(event) {
editorPaste(event, editor);
});
}
};
return settings;
}

View File

@@ -1,16 +1,16 @@
"use strict";
// Configure ZeroClipboard
var zeroClipBoard = require('zeroclipboard');
zeroClipBoard.config({
swfPath: window.baseUrl('/ZeroClipboard.swf')
});
import Clipboard from "clipboard";
window.setupPageShow = module.exports = function (pageId) {
export default window.setupPageShow = function (pageId) {
// Set up pointer
var $pointer = $('#pointer').detach();
var $pointerInner = $pointer.children('div.pointer').first();
var isSelection = false;
let $pointer = $('#pointer').detach();
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', function (e) {
@@ -18,35 +18,53 @@ window.setupPageShow = module.exports = function (pageId) {
e.stopPropagation();
});
// Set up copy-to-clipboard
new zeroClipBoard($pointer.find('button').first()[0]);
// Pointer mode toggle
$pointer.on('click', 'span.icon', event => {
let $icon = $(event.currentTarget);
pointerModeLink = !pointerModeLink;
$icon.html(pointerModeLink ? '<i class="zmdi zmdi-link"></i>' : '<i class="zmdi zmdi-square-down"></i>');
updatePointerContent();
});
// Set up clipboard
let clipboard = new Clipboard('#pointer button');
// Hide pointer when clicking away
$(document.body).find('*').on('click focus', function (e) {
if (!isSelection) {
$pointer.detach();
}
$(document.body).find('*').on('click focus', event => {
if (!pointerShowing || isSelection) return;
let target = $(event.target);
if (target.is('.zmdi') || $(event.target).closest('#pointer').length === 1) return;
$pointer.detach();
pointerShowing = false;
});
function updatePointerContent() {
let inputText = pointerModeLink ? window.baseUrl(`/link/${pageId}#${pointerSectionId}`) : `{{@${pageId}#${pointerSectionId}}}`;
if (pointerModeLink && inputText.indexOf('http') !== 0) inputText = window.location.protocol + "//" + window.location.host + inputText;
$pointer.find('input').val(inputText);
}
// Show pointer when selecting a single block of tagged content
$('.page-content [id^="bkmrk"]').on('mouseup keyup', function (e) {
e.stopPropagation();
var selection = window.getSelection();
let selection = window.getSelection();
if (selection.toString().length === 0) return;
// Show pointer and set link
var $elem = $(this);
let link = window.baseUrl('/link/' + pageId + '#' + $elem.attr('id'));
if (link.indexOf('http') !== 0) link = window.location.protocol + "//" + window.location.host + link;
$pointer.find('input').val(link);
$pointer.find('button').first().attr('data-clipboard-text', link);
let $elem = $(this);
pointerSectionId = $elem.attr('id');
updatePointerContent();
$elem.before($pointer);
$pointer.show();
pointerShowing = true;
// Set pointer to sit near mouse-up position
var pointerLeftOffset = (e.pageX - $elem.offset().left - ($pointerInner.width() / 2));
let pointerLeftOffset = (e.pageX - $elem.offset().left - ($pointerInner.width() / 2));
if (pointerLeftOffset < 0) pointerLeftOffset = 0;
var pointerLeftOffsetPercent = (pointerLeftOffset / $elem.width()) * 100;
let pointerLeftOffsetPercent = (pointerLeftOffset / $elem.width()) * 100;
$pointerInner.css('left', pointerLeftOffsetPercent + '%');
isSelection = true;
@@ -57,10 +75,12 @@ window.setupPageShow = module.exports = function (pageId) {
// Go to, and highlight if necessary, the specified text.
function goToText(text) {
var idElem = $('.page-content #' + text).first();
if (idElem.length !== 0) {
idElem.smoothScrollTo();
idElem.css('background-color', 'rgba(244, 249, 54, 0.25)');
let idElem = document.getElementById(text);
$('.page-content [data-highlighted]').attr('data-highlighted', '').css('background-color', '');
if (idElem !== null) {
let $idElem = $(idElem);
let color = $('#custom-styles').attr('data-color-light');
$idElem.css('background-color', color).attr('data-highlighted', 'true').smoothScrollTo();
} else {
$('.page-content').find(':contains("' + text + '")').smoothScrollTo();
}
@@ -68,19 +88,24 @@ window.setupPageShow = module.exports = function (pageId) {
// Check the hash on load
if (window.location.hash) {
var text = window.location.hash.replace(/\%20/g, ' ').substr(1);
let text = window.location.hash.replace(/\%20/g, ' ').substr(1);
goToText(text);
}
// Sidebar page nav click event
$('.sidebar-page-nav').on('click', 'a', event => {
goToText(event.target.getAttribute('href').substr(1));
});
// Make the book-tree sidebar stick in view on scroll
var $window = $(window);
var $bookTree = $(".book-tree");
var $bookTreeParent = $bookTree.parent();
let $window = $(window);
let $bookTree = $(".book-tree");
let $bookTreeParent = $bookTree.parent();
// Check the page is scrollable and the content is taller than the tree
var pageScrollable = ($(document).height() > $window.height()) && ($bookTree.height() < $('.page-content').height());
let pageScrollable = ($(document).height() > $window.height()) && ($bookTree.height() < $('.page-content').height());
// Get current tree's width and header height
var headerHeight = $("#header").height() + $(".toolbar").height();
var isFixed = $window.scrollTop() > headerHeight;
let headerHeight = $("#header").height() + $(".toolbar").height();
let isFixed = $window.scrollTop() > headerHeight;
// Function to fix the tree as a sidebar
function stickTree() {
$bookTree.width($bookTreeParent.width() + 15);
@@ -95,7 +120,7 @@ window.setupPageShow = module.exports = function (pageId) {
}
// Checks if the tree stickiness state should change
function checkTreeStickiness(skipCheck) {
var shouldBeFixed = $window.scrollTop() > headerHeight;
let shouldBeFixed = $window.scrollTop() > headerHeight;
if (shouldBeFixed && (!isFixed || skipCheck)) {
stickTree();
} else if (!shouldBeFixed && (isFixed || skipCheck)) {

View File

@@ -0,0 +1,47 @@
/**
* Translation Manager
* Handles the JavaScript side of translating strings
* in a way which fits with Laravel.
*/
class Translator {
/**
* Create an instance, Passing in the required translations
* @param translations
*/
constructor(translations) {
this.store = translations;
}
/**
* Get a translation, Same format as laravel's 'trans' helper
* @param key
* @param replacements
* @returns {*}
*/
get(key, replacements) {
let splitKey = key.split('.');
let value = splitKey.reduce((a, b) => {
return a != undefined ? a[b] : a;
}, this.store);
if (value === undefined) {
console.log(`Translation with key "${key}" does not exist`);
value = key;
}
if (replacements === undefined) return value;
let replaceMatches = value.match(/:([\S]+)/g);
if (replaceMatches === null) return value;
replaceMatches.forEach(match => {
let key = match.substring(1);
if (typeof replacements[key] === 'undefined') return;
value = value.replace(match, replacements[key]);
});
return value;
}
}
export default Translator

View File

@@ -135,7 +135,7 @@
border-left: 3px solid #BBB;
background-color: #EEE;
padding: $-s;
display: flex;
display: block;
&:before {
font-family: 'Material-Design-Iconic-Font';
padding-right: $-s;

View File

@@ -108,5 +108,4 @@ $button-border-radius: 2px;
cursor: default;
box-shadow: none;
}
}
}

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