diff --git a/.env.example b/.env.example index 4dee3b33441..13114b8b076 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,13 @@ DB_DATABASE=database_database DB_USERNAME=database_username DB_PASSWORD=database_user_password +# Storage system to use +# By default files are stored on the local filesystem, with images being placed in +# public web space so they can be efficiently served directly by the web-server. +# For other options with different security levels & considerations, refer to: +# https://www.bookstackapp.com/docs/admin/upload-config/ +STORAGE_TYPE=local + # Mail system to use # Can be 'smtp' or 'sendmail' MAIL_DRIVER=smtp diff --git a/.env.example.complete b/.env.example.complete index e8520a24cae..6c773f601f1 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -36,10 +36,14 @@ APP_LANG=en # APP_LANG will be used if such a header is not provided. APP_AUTO_LANG_PUBLIC=true -# Application timezone -# Used where dates are displayed such as on exported content. +# Application timezones +# The first option is used to determine what timezone is used for date storage. +# Leaving that as "UTC" is advised. +# The second option is used to set the timezone which will be used for date +# formatting and display. This defaults to the "APP_TIMEZONE" value. # Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php APP_TIMEZONE=UTC +APP_DISPLAY_TIMEZONE=UTC # Application theme # Used to specific a themes/ folder where BookStack UI @@ -56,6 +60,7 @@ APP_PROXIES=null # Database details # Host can contain a port (localhost:3306) or a separate DB_PORT option can be used. +# An ipv6 address can be used via the square bracket format ([::1]). DB_HOST=localhost DB_PORT=3306 DB_DATABASE=database_database @@ -215,10 +220,11 @@ LDAP_SERVER=false LDAP_BASE_DN=false LDAP_DN=false LDAP_PASS=false -LDAP_USER_FILTER=false +LDAP_USER_FILTER="(&(uid={user}))" LDAP_VERSION=false LDAP_START_TLS=false LDAP_TLS_INSECURE=false +LDAP_TLS_CA_CERT=false LDAP_ID_ATTRIBUTE=uid LDAP_EMAIL_ATTRIBUTE=mail LDAP_DISPLAY_NAME_ATTRIBUTE=cn @@ -267,6 +273,7 @@ OIDC_ISSUER_DISCOVER=false OIDC_PUBLIC_KEY=null OIDC_AUTH_ENDPOINT=null OIDC_TOKEN_ENDPOINT=null +OIDC_USERINFO_ENDPOINT=null OIDC_ADDITIONAL_SCOPES=null OIDC_DUMP_USER_DETAILS=false OIDC_USER_TO_GROUPS=false @@ -324,6 +331,19 @@ FILE_UPLOAD_SIZE_LIMIT=50 # Can be 'a4' or 'letter'. EXPORT_PAGE_SIZE=a4 +# Export PDF Command +# Set a command which can be used to convert a HTML file into a PDF file. +# When false this will not be used. +# String values represent the command to be called for conversion. +# Supports '{input_html_path}' and '{output_pdf_path}' placeholder values. +# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}" +EXPORT_PDF_COMMAND=false + +# Export PDF Command Timeout +# The number of seconds that the export PDF command will run before a timeout occurs. +# Only applies for the EXPORT_PDF_COMMAND option, not for DomPDF or wkhtmltopdf. +EXPORT_PDF_COMMAND_TIMEOUT=15 + # Set path to wkhtmltopdf binary for PDF generation. # Can be 'false' or a path path like: '/home/bins/wkhtmltopdf' # When false, BookStack will attempt to find a wkhtmltopdf in the application @@ -331,10 +351,25 @@ EXPORT_PAGE_SIZE=a4 # Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections. WKHTMLTOPDF=false -# Allow -@endpush - {{ csrf_field() }}
@@ -22,7 +18,7 @@ @include('form.image-picker', [ 'defaultImage' => url('/book_default_cover.png'), - 'currentImage' => (isset($model) && $model->cover) ? $model->getBookCover() : url('/book_default_cover.png') , + 'currentImage' => (($model ?? null)?->coverInfo()->getUrl(440, 250, null) ?? url('/book_default_cover.png')), 'name' => 'image', 'imageClass' => 'cover' ]) @@ -40,24 +36,10 @@
-
-

- {{ trans('entities.books_default_template_explain') }} -

- -
- @include('form.page-picker', [ - 'name' => 'default_template_id', - 'placeholder' => trans('entities.books_default_template_select'), - 'value' => $book->default_template_id ?? null, - 'selectorEndpoint' => '/search/entity-selector-templates', - ]) -
-
- + @include('entities.template-selector', ['entity' => $book ?? null])
diff --git a/resources/views/books/parts/index-sidebar-section-actions.blade.php b/resources/views/books/parts/index-sidebar-section-actions.blade.php new file mode 100644 index 00000000000..8f8b254c819 --- /dev/null +++ b/resources/views/books/parts/index-sidebar-section-actions.blade.php @@ -0,0 +1,25 @@ +
+
{{ trans('common.actions') }}
+ +
\ No newline at end of file diff --git a/resources/views/books/parts/index-sidebar-section-new.blade.php b/resources/views/books/parts/index-sidebar-section-new.blade.php new file mode 100644 index 00000000000..a9aa52c5910 --- /dev/null +++ b/resources/views/books/parts/index-sidebar-section-new.blade.php @@ -0,0 +1,8 @@ +
+
{{ trans('entities.books_new') }}
+ @if(count($new) > 0) + @include('entities.list', ['entities' => $new, 'style' => 'compact']) + @else +

{{ trans('entities.books_new_empty') }}

+ @endif +
\ No newline at end of file diff --git a/resources/views/books/parts/index-sidebar-section-popular.blade.php b/resources/views/books/parts/index-sidebar-section-popular.blade.php new file mode 100644 index 00000000000..030c75eb9a7 --- /dev/null +++ b/resources/views/books/parts/index-sidebar-section-popular.blade.php @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/resources/views/books/parts/index-sidebar-section-recents.blade.php b/resources/views/books/parts/index-sidebar-section-recents.blade.php new file mode 100644 index 00000000000..f1a68ba4f99 --- /dev/null +++ b/resources/views/books/parts/index-sidebar-section-recents.blade.php @@ -0,0 +1,6 @@ +@if($recents) +
+
{{ trans('entities.recently_viewed') }}
+ @include('entities.list', ['entities' => $recents, 'style' => 'compact']) +
+@endif \ No newline at end of file diff --git a/resources/views/books/parts/list-item.blade.php b/resources/views/books/parts/list-item.blade.php index a3ff0971f11..0852670fe6d 100644 --- a/resources/views/books/parts/list-item.blade.php +++ b/resources/views/books/parts/list-item.blade.php @@ -1,11 +1,16 @@ +@php + /** + * @var \BookStack\Entities\Models\Book $book + */ +@endphp -
+
@icon('book')

{{ $book->name }}

-

{{ $book->description }}

+

{{ $book->descriptionInfo()->getPlain() }}

\ No newline at end of file diff --git a/resources/views/books/parts/list.blade.php b/resources/views/books/parts/list.blade.php index 8b658c711a1..13784d24e8d 100644 --- a/resources/views/books/parts/list.blade.php +++ b/resources/views/books/parts/list.blade.php @@ -24,7 +24,7 @@
@else

{{ trans('entities.books_empty') }}

- @if(userCan('book-create-all')) + @if(userCan(\BookStack\Permissions\Permission::BookCreateAll))
diff --git a/resources/views/books/parts/show-sidebar-section-actions.blade.php b/resources/views/books/parts/show-sidebar-section-actions.blade.php new file mode 100644 index 00000000000..8e5b5c4d74a --- /dev/null +++ b/resources/views/books/parts/show-sidebar-section-actions.blade.php @@ -0,0 +1,61 @@ +
+
{{ trans('common.actions') }}
+
+
\ No newline at end of file diff --git a/resources/views/books/parts/show-sidebar-section-activity.blade.php b/resources/views/books/parts/show-sidebar-section-activity.blade.php new file mode 100644 index 00000000000..c1c5c1d3ecf --- /dev/null +++ b/resources/views/books/parts/show-sidebar-section-activity.blade.php @@ -0,0 +1,6 @@ +@if(count($activity) > 0) +
+
{{ trans('entities.recent_activity') }}
+ @include('common.activity-list', ['activity' => $activity]) +
+@endif \ No newline at end of file diff --git a/resources/views/books/parts/show-sidebar-section-details.blade.php b/resources/views/books/parts/show-sidebar-section-details.blade.php new file mode 100644 index 00000000000..709d0ffd9a1 --- /dev/null +++ b/resources/views/books/parts/show-sidebar-section-details.blade.php @@ -0,0 +1,21 @@ +
+
{{ trans('common.details') }}
+ +
\ No newline at end of file diff --git a/resources/views/books/parts/show-sidebar-section-shelves.blade.php b/resources/views/books/parts/show-sidebar-section-shelves.blade.php new file mode 100644 index 00000000000..9de9b95c62e --- /dev/null +++ b/resources/views/books/parts/show-sidebar-section-shelves.blade.php @@ -0,0 +1,6 @@ +@if(count($bookParentShelves) > 0) +
+
{{ trans('entities.shelves') }}
+ @include('entities.list', ['entities' => $bookParentShelves, 'style' => 'compact']) +
+@endif \ No newline at end of file diff --git a/resources/views/books/parts/show-sidebar-section-tags.blade.php b/resources/views/books/parts/show-sidebar-section-tags.blade.php new file mode 100644 index 00000000000..440a780c814 --- /dev/null +++ b/resources/views/books/parts/show-sidebar-section-tags.blade.php @@ -0,0 +1,5 @@ +@if($book->tags->count() > 0) +
+ @include('entities.tag-list', ['entity' => $book]) +
+@endif \ No newline at end of file diff --git a/resources/views/books/parts/sort-box.blade.php b/resources/views/books/parts/sort-box.blade.php index 03998e26178..6fdb1819e61 100644 --- a/resources/views/books/parts/sort-box.blade.php +++ b/resources/views/books/parts/sort-box.blade.php @@ -8,14 +8,24 @@ @icon('book') {{ $book->name }}
+
+ @if($book->sortRule) + @icon('auto-sort') + @endif +
- - - - - + + + + +
+
+ +
+ @include('entities.template-selector', ['entity' => $chapter ?? null]) +
+
+
{{ trans('common.cancel') }} diff --git a/resources/views/chapters/parts/show-sidebar-section-actions.blade.php b/resources/views/chapters/parts/show-sidebar-section-actions.blade.php new file mode 100644 index 00000000000..55df999a22c --- /dev/null +++ b/resources/views/chapters/parts/show-sidebar-section-actions.blade.php @@ -0,0 +1,65 @@ +
+
{{ trans('common.actions') }}
+ +
\ No newline at end of file diff --git a/resources/views/chapters/parts/show-sidebar-section-details.blade.php b/resources/views/chapters/parts/show-sidebar-section-details.blade.php new file mode 100644 index 00000000000..a424b8d3fac --- /dev/null +++ b/resources/views/chapters/parts/show-sidebar-section-details.blade.php @@ -0,0 +1,38 @@ +
+
{{ trans('common.details') }}
+ +
\ No newline at end of file diff --git a/resources/views/chapters/parts/show-sidebar-section-tags.blade.php b/resources/views/chapters/parts/show-sidebar-section-tags.blade.php new file mode 100644 index 00000000000..d28ff63833e --- /dev/null +++ b/resources/views/chapters/parts/show-sidebar-section-tags.blade.php @@ -0,0 +1,5 @@ +@if($chapter->tags->count() > 0) +
+ @include('entities.tag-list', ['entity' => $chapter]) +
+@endif \ No newline at end of file diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index 45e43ad96a9..7ef877661c0 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -24,7 +24,7 @@

{{ $chapter->name }}

-
{!! $chapter->descriptionHtml() !!}
+
{!! $chapter->descriptionInfo()->getHtml() !!}
@if(count($pages) > 0)
@foreach($pages as $page) @@ -37,13 +37,13 @@

{{ trans('entities.chapters_empty') }}

- @if(userCan('page-create', $chapter)) + @if(userCan(\BookStack\Permissions\Permission::PageCreate, $chapter)) @icon('page') {{ trans('entities.books_empty_create_page') }} @endif - @if(userCan('book-update', $book)) + @if(userCan(\BookStack\Permissions\Permission::BookUpdate, $book)) @icon('book') {{ trans('entities.books_empty_sort_current_book') }} @@ -63,123 +63,13 @@ @stop @section('right') - -
-
{{ trans('common.details') }}
-
-
- -
-
{{ trans('common.actions') }}
- -
+ @include('chapters.parts.show-sidebar-section-details', ['chapter' => $chapter, 'book' => $book, 'watchOptions' => $watchOptions]) + @include('chapters.parts.show-sidebar-section-actions', ['chapter' => $chapter, 'watchOptions' => $watchOptions]) @stop @section('left') - @include('entities.search-form', ['label' => trans('entities.chapters_search_this')]) - - @if($chapter->tags->count() > 0) -
- @include('entities.tag-list', ['entity' => $chapter]) -
- @endif - + @include('chapters.parts.show-sidebar-section-tags', ['chapter' => $chapter]) @include('entities.book-tree', ['book' => $book, 'sidebarTree' => $sidebarTree]) @stop diff --git a/resources/views/comments/comment-branch.blade.php b/resources/views/comments/comment-branch.blade.php index 78d19ac3ea4..658c33219c3 100644 --- a/resources/views/comments/comment-branch.blade.php +++ b/resources/views/comments/comment-branch.blade.php @@ -1,13 +1,16 @@ +{{-- +$branch CommentTreeNode +--}}
-
- @include('comments.comment', ['comment' => $branch['comment']]) +
+ @include('comments.comment', ['comment' => $branch->comment])
- @foreach($branch['children'] as $childBranch) + @foreach($branch->children as $childBranch) @include('comments.comment-branch', ['branch' => $childBranch]) @endforeach
diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 1cb70916087..10cd02035fd 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -1,16 +1,21 @@ +@php + $commentHtml = $comment->safeHtml(); +@endphp
@if ($comment->createdBy)
- {{ $comment->createdBy->name }} + {{ $comment->createdBy->name }}
@endif
@@ -19,44 +24,55 @@ class="comment-box"> @else {{ trans('common.deleted_user') }} @endif -  {{ trans('entities.comment_created', ['createDiff' => $comment->created]) }} +  {{ trans('entities.comment_created', ['createDiff' => $dates->relative($comment->created_at) ]) }} @if($comment->isUpdated()) - + {{ trans('entities.comment_updated_indicator') }} @endif
- @if(!$readOnly && (userCan('comment-create-all') || userCan('comment-update', $comment) || userCan('comment-delete', $comment))) -
- @if(userCan('comment-create-all')) - - @endif - @if(userCan('comment-update', $comment)) - - @endif - @if(userCan('comment-delete', $comment)) - - @endif - + @if(!$readOnly && (userCan(\BookStack\Permissions\Permission::CommentCreateAll) || userCan(\BookStack\Permissions\Permission::CommentUpdate, $comment) || userCan(\BookStack\Permissions\Permission::CommentDelete, $comment))) +
+ @if(userCan(\BookStack\Permissions\Permission::CommentCreateAll)) + + @endif + @if(!$comment->parent_id && (userCan(\BookStack\Permissions\Permission::CommentUpdate, $comment) || userCan(\BookStack\Permissions\Permission::CommentDelete, $comment))) + + @endif + @if(userCan(\BookStack\Permissions\Permission::CommentUpdate, $comment)) + + @endif + @if(userCan(\BookStack\Permissions\Permission::CommentDelete, $comment)) + + @endif +  •  -
+
@endif
@@ -66,19 +82,33 @@ class="comment-box"> - @if(!$readOnly && userCan('comment-update', $comment)) + @if(!$readOnly && userCan(\BookStack\Permissions\Permission::CommentUpdate, $comment)) diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index 26d286290c2..9dc0a9b8982 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -1,37 +1,80 @@ -
-
-
{{ trans_choice('entities.comment_count', $commentTree->count(), ['count' => $commentTree->count()]) }}
- @if ($commentTree->empty() && userCan('comment-create-all')) -
+
+
+ + +
+ @if ($commentTree->empty() && userCan(\BookStack\Permissions\Permission::CommentCreateAll)) +
+ class="button outline mb-m ml-auto">{{ trans('entities.comment_add') }}
@endif
-
- @foreach($commentTree->get() as $branch) +
+
+ @foreach($commentTree->getActive() as $branch) + @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false]) + @endforeach +
+ +

{{ trans('entities.comment_none') }}

+ + @if(userCan(\BookStack\Permissions\Permission::CommentCreateAll)) + @include('comments.create') + @if (!$commentTree->empty()) +
+ +
+ @endif + @endif +
+ + - @if(userCan('comment-create-all')) - @include('comments.create') - - @if (!$commentTree->empty()) -
- -
- @endif + @if(userCan(\BookStack\Permissions\Permission::CommentCreateAll) || $commentTree->canUpdateAny()) + @push('body-end') + @include('form.editor-translations') + @include('entities.selector-popup') + @endpush @endif
\ No newline at end of file diff --git a/resources/views/comments/create.blade.php b/resources/views/comments/create.blade.php index cb7905ddc44..134ed516425 100644 --- a/resources/views/comments/create.blade.php +++ b/resources/views/comments/create.blade.php @@ -12,11 +12,21 @@
+
-
diff --git a/resources/views/common/activity-item.blade.php b/resources/views/common/activity-item.blade.php index 89d44b15231..1d3c7bd755f 100644 --- a/resources/views/common/activity-item.blade.php +++ b/resources/views/common/activity-item.blade.php @@ -16,15 +16,15 @@ {{ $activity->getText() }} - @if($activity->entity && is_null($activity->entity->deleted_at)) - {{ $activity->entity->name }} + @if($activity->loggable && is_null($activity->loggable->deleted_at)) + {{ $activity->loggable->name }} @endif - @if($activity->entity && !is_null($activity->entity->deleted_at)) - "{{ $activity->entity->name }}" + @if($activity->loggable && !is_null($activity->loggable->deleted_at)) + "{{ $activity->loggable->name }}" @endif
- @icon('time'){{ $activity->created_at->diffForHumans() }} + @icon('time'){{ $dates->relative($activity->created_at) }}
diff --git a/resources/views/common/dark-mode-toggle.blade.php b/resources/views/common/dark-mode-toggle.blade.php index 531755109ad..38493251902 100644 --- a/resources/views/common/dark-mode-toggle.blade.php +++ b/resources/views/common/dark-mode-toggle.blade.php @@ -3,8 +3,8 @@ {{ method_field('patch') }} @if(setting()->getForCurrentUser('dark-mode-enabled')) - + @else - + @endif \ No newline at end of file diff --git a/resources/views/common/sort.blade.php b/resources/views/common/sort.blade.php index e5336d3a255..d47351cb347 100644 --- a/resources/views/common/sort.blade.php +++ b/resources/views/common/sort.blade.php @@ -29,10 +29,14 @@
diff --git a/resources/views/entities/body-tag-classes.blade.php b/resources/views/entities/body-tag-classes.blade.php index 08427f1a5ed..f9ba023c37e 100644 --- a/resources/views/entities/body-tag-classes.blade.php +++ b/resources/views/entities/body-tag-classes.blade.php @@ -1 +1 @@ -@push('body-class', e((new \BookStack\Activity\Tools\TagClassGenerator($entity->tags->all()))->generateAsString() . ' ')) \ No newline at end of file +@push('body-class', e((new \BookStack\Activity\Tools\TagClassGenerator($entity))->generateAsString() . ' ')) \ No newline at end of file diff --git a/resources/views/entities/book-tree.blade.php b/resources/views/entities/book-tree.blade.php index ce016143a30..c95b1eebc43 100644 --- a/resources/views/entities/book-tree.blade.php +++ b/resources/views/entities/book-tree.blade.php @@ -5,7 +5,7 @@ class="book-tree mb-xl"
{{ trans('entities.books_navigation') }}
diff --git a/resources/views/errors/debug.blade.php b/resources/views/errors/debug.blade.php index e7155431c86..969c49595ef 100644 --- a/resources/views/errors/debug.blade.php +++ b/resources/views/errors/debug.blade.php @@ -113,13 +113,13 @@ Review BookStack debugging documentation »
  • - Ensure your instance is up-to-date » + Ensure your instance is up-to-date »
  • - Search for the issue on GitHub » + Search for the issue on GitHub »
  • - Ask for help via Discord » + Ask for help in our community forums »
  • Search the error message » diff --git a/resources/views/exports/book.blade.php b/resources/views/exports/book.blade.php index 9de7b8eba6c..8935c515184 100644 --- a/resources/views/exports/book.blade.php +++ b/resources/views/exports/book.blade.php @@ -5,7 +5,7 @@ @section('content')

    {{$book->name}}

    -
    {!! $book->descriptionHtml() !!}
    +
    {!! $book->descriptionInfo()->getHtml() !!}
    @include('exports.parts.book-contents-menu', ['children' => $bookChildren]) diff --git a/resources/views/exports/chapter.blade.php b/resources/views/exports/chapter.blade.php index 515366d60e3..46a07388749 100644 --- a/resources/views/exports/chapter.blade.php +++ b/resources/views/exports/chapter.blade.php @@ -5,7 +5,7 @@ @section('content')

    {{$chapter->name}}

    -
    {!! $chapter->descriptionHtml() !!}
    +
    {!! $chapter->descriptionInfo()->getHtml() !!}
    @include('exports.parts.chapter-contents-menu', ['pages' => $pages]) diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php new file mode 100644 index 00000000000..1c46b7a0b87 --- /dev/null +++ b/resources/views/exports/import-show.blade.php @@ -0,0 +1,88 @@ +@extends('layouts.simple') + +@section('body') +
    + +
    +

    {{ trans('entities.import_continue') }}

    +

    {{ trans('entities.import_continue_desc') }}

    + + @if(session()->has('import_errors')) +
    + +

    {{ trans('entities.import_errors_desc') }}

    + @foreach(session()->get('import_errors') ?? [] as $error) +

    {{ $error }}

    + @endforeach +
    +
    + @endif + +
    + +
    +
    + @include('exports.parts.import-item', ['type' => $import->type, 'model' => $data]) +
    +
    +
    {{ trans('entities.import_size', ['size' => $import->getSizeString()]) }}
    +
    {{ trans('entities.import_uploaded_at', ['relativeTime' => $dates->relative($import->created_at)]) }}
    + @if($import->createdBy) +
    + {{ trans('entities.import_uploaded_by') }} + {{ $import->createdBy->name }} +
    + @endif +
    +
    +
    + +
    + {{ csrf_field() }} + + @if($import->type === 'page' || $import->type === 'chapter') +
    + +

    {{ trans('entities.import_location_desc') }}

    + @if($errors->has('parent')) +
    + @include('form.errors', ['name' => 'parent']) +
    + @endif + @include('entities.selector', [ + 'name' => 'parent', + 'entityTypes' => $import->type === 'page' ? 'chapter,book' : 'book', + 'entityPermission' => "{$import->type}-create", + 'selectorSize' => 'compact small', + ]) + @endif + +
    + {{ trans('common.cancel') }} +
    + + +
    + +
    +
    +
    +
    + +
    + {{ method_field('DELETE') }} + {{ csrf_field() }} +
    + +@stop diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php new file mode 100644 index 00000000000..be9de4c0e91 --- /dev/null +++ b/resources/views/exports/import.blade.php @@ -0,0 +1,56 @@ +@extends('layouts.simple') + +@section('body') + +
    + +
    +

    {{ trans('entities.import') }}

    +
    + {{ csrf_field() }} +
    +

    {{ trans('entities.import_desc') }}

    +
    +
    + + + @include('form.errors', ['name' => 'file']) +
    +
    +
    + + @if(count($zipErrors) > 0) +

    {{ trans('entities.import_zip_validation_errors') }}

    +
      + @foreach($zipErrors as $key => $error) +
    • [{{ $key }}]: {{ $error }}
    • + @endforeach +
    + @endif + +
    + {{ trans('common.cancel') }} + +
    +
    +
    + +
    +

    {{ trans('entities.import_pending') }}

    + @if(count($imports) === 0) +

    {{ trans('entities.import_pending_none') }}

    + @else +
    + @foreach($imports as $import) + @include('exports.parts.import', ['import' => $import]) + @endforeach +
    + @endif +
    +
    + +@stop diff --git a/resources/views/exports/parts/chapter-item.blade.php b/resources/views/exports/parts/chapter-item.blade.php index fa0b1f22884..beb3b8abbe9 100644 --- a/resources/views/exports/parts/chapter-item.blade.php +++ b/resources/views/exports/parts/chapter-item.blade.php @@ -1,7 +1,7 @@

    {{ $chapter->name }}

    -
    {!! $chapter->descriptionHtml() !!}
    +
    {!! $chapter->descriptionInfo()->getHtml() !!}
    @if(count($chapter->visible_pages) > 0) @foreach($chapter->visible_pages as $page) diff --git a/resources/views/exports/parts/import-item.blade.php b/resources/views/exports/parts/import-item.blade.php new file mode 100644 index 00000000000..5da4b21405d --- /dev/null +++ b/resources/views/exports/parts/import-item.blade.php @@ -0,0 +1,28 @@ +{{-- +$type - string +$model - object +--}} +
    +

    @icon($type){{ $model->name }}

    +
    +
    + @if($model->attachments ?? []) + @icon('attach'){{ count($model->attachments) }} + @endif + @if($model->images ?? []) + @icon('image'){{ count($model->images) }} + @endif + @if($model->tags ?? []) + @icon('tag'){{ count($model->tags) }} + @endif +
    + @if(method_exists($model, 'children')) + @foreach($model->children() as $child) + @include('exports.parts.import-item', [ + 'type' => ($child instanceof \BookStack\Exports\ZipExports\Models\ZipExportPage) ? 'page' : 'chapter', + 'model' => $child + ]) + @endforeach + @endif +
    +
    \ No newline at end of file diff --git a/resources/views/exports/parts/import.blade.php b/resources/views/exports/parts/import.blade.php new file mode 100644 index 00000000000..dc287370e70 --- /dev/null +++ b/resources/views/exports/parts/import.blade.php @@ -0,0 +1,10 @@ +
    + +
    +
    {{ $import->getSizeString() }}
    +
    @icon('time'){{ $dates->relative($import->created_at) }}
    +
    +
    \ No newline at end of file diff --git a/resources/views/exports/parts/meta.blade.php b/resources/views/exports/parts/meta.blade.php index d4128898bc8..07eff14a470 100644 --- a/resources/views/exports/parts/meta.blade.php +++ b/resources/views/exports/parts/meta.blade.php @@ -1,16 +1,16 @@
    - @if ($entity->isA('page')) + @if ($entity->isA('page') && userCan(\BookStack\Permissions\Permission::RevisionViewAll)) @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }}
    @endif @icon('star'){!! trans('entities.meta_created' . ($entity->createdBy ? '_name' : ''), [ - 'timeLength' => $entity->created_at->isoFormat('D MMMM Y HH:mm:ss'), + 'timeLength' => $dates->absolute($entity->created_at), 'user' => e($entity->createdBy->name ?? ''), ]) !!}
    @icon('edit'){!! trans('entities.meta_updated' . ($entity->updatedBy ? '_name' : ''), [ - 'timeLength' => $entity->updated_at->isoFormat('D MMMM Y HH:mm:ss'), + 'timeLength' => $dates->absolute($entity->updated_at), 'user' => e($entity->updatedBy->name ?? '') ]) !!}
    \ No newline at end of file diff --git a/resources/views/form/description-html-input.blade.php b/resources/views/form/description-html-input.blade.php index 3cf726ba489..4b0a74df1b3 100644 --- a/resources/views/form/description-html-input.blade.php +++ b/resources/views/form/description-html-input.blade.php @@ -1,8 +1,7 @@ + @if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ?? $model->descriptionInfo()->getHtml() }}@else{{ '

    ' }}@endif @if($errors->has('description_html'))
    {{ $errors->first('description_html') }}
    @endif \ No newline at end of file diff --git a/resources/views/form/errors.blade.php b/resources/views/form/errors.blade.php index 03cd4be88f0..72d41ee56c7 100644 --- a/resources/views/form/errors.blade.php +++ b/resources/views/form/errors.blade.php @@ -1,3 +1,6 @@ +{{-- +$name - string +--}} @if($errors->has($name))
    {{ $errors->first($name) }}
    @endif \ No newline at end of file diff --git a/resources/views/form/number.blade.php b/resources/views/form/number.blade.php index a37cd36941c..b70b7e8db3c 100644 --- a/resources/views/form/number.blade.php +++ b/resources/views/form/number.blade.php @@ -6,7 +6,8 @@ @if($readonly ?? false) readonly="readonly" @endif @if($min ?? false) min="{{ $min }}" @endif @if($max ?? false) max="{{ $max }}" @endif - @if(isset($model) || old($name)) value="{{ old($name) ? old($name) : $model->$name}}" @endif> + @if($step ?? false) step="{{ $step }}" @endif + @if(isset($model) || old($name) || isset($value)) value="{{ old($name) ?? $model->$name ?? $value }}" @endif> @if($errors->has($name))
    {{ $errors->first($name) }}
    @endif diff --git a/resources/views/form/user-mention-list.blade.php b/resources/views/form/user-mention-list.blade.php new file mode 100644 index 00000000000..020cfb35b9d --- /dev/null +++ b/resources/views/form/user-mention-list.blade.php @@ -0,0 +1,14 @@ +@if($users->isEmpty()) + +@endif +@foreach($users as $user) + + {{ $user->name }} + {{ $user->name }} + +@endforeach \ No newline at end of file diff --git a/resources/views/help/licenses.blade.php b/resources/views/help/licenses.blade.php new file mode 100644 index 00000000000..09126ddadb6 --- /dev/null +++ b/resources/views/help/licenses.blade.php @@ -0,0 +1,67 @@ +@extends('layouts.simple') + +@section('body') + +
    + +
     
    + + + +
    +

    {{ trans('settings.licenses_bookstack') }}

    +
    {{ $license }}
    +

    BookStack® is a UK registered trade mark of Daniel Brown.

    +
    + +
    +

    {{ trans('settings.licenses_php') }}

    +
    {{ $phpLibData }}
    +
    + +
    +

    {{ trans('settings.licenses_js') }}

    +
    {{ $jsLibData }}
    +
    + +
    +

    {{ trans('settings.licenses_other') }}

    +
    BookStack makes heavy use of PHP: + License: PHP License, version 3.01 + License File: https://www.php.net/license/3_01.txt + Copyright: Copyright (c) 1999 - 2019 The PHP Group. All rights reserved. + Link: https://www.php.net/ + ----------- + BookStack uses Icons from Google Material Icons: + License: Apache License Version 2.0 + License File: https://github.com/google/material-design-icons/blob/master/LICENSE + Copyright: Copyright 2020 Google LLC + Link: https://github.com/google/material-design-icons + ----------- + BookStack is distributed with TinyMCE: + License: MIT + License File: https://github.com/tinymce/tinymce/blob/release/6.7/LICENSE.TXT + Copyright: Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc. + Link: https://github.com/tinymce/tinymce + ----------- + BookStack's newer WYSIWYG editor is based upon lexical code: + License: MIT + License File: https://github.com/facebook/lexical/blob/v0.17.1/LICENSE + Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. + Link: https://github.com/facebook/lexical +
    +
    +
    + +@endsection \ No newline at end of file diff --git a/resources/views/help/tinymce.blade.php b/resources/views/help/tinymce.blade.php new file mode 100644 index 00000000000..8ff59c8d664 --- /dev/null +++ b/resources/views/help/tinymce.blade.php @@ -0,0 +1,146 @@ +@extends('layouts.plain') +@section('document-class', 'bg-white ' . (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '')) + +@section('content') +
    + +

    {{ trans('editor.editor_license') }}

    +

    + {!! trans('editor.editor_tiny_license', ['tinyLink' => 'TinyMCE']) !!} +
    + {{ trans('editor.editor_tiny_license_link') }} +

    + +

    {{ trans('editor.shortcuts') }}

    + +

    {{ trans('editor.shortcuts_intro') }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
    Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
    Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
    Ctrl+BCmd+B{{ trans('editor.bold') }}
    Ctrl+ICmd+I{{ trans('editor.italic') }}
    + Ctrl+1
    + Ctrl+2
    + Ctrl+3
    + Ctrl+4 +
    + Cmd+1
    + Cmd+2
    + Cmd+3
    + Cmd+4 +
    + {{ trans('editor.header_large') }}
    + {{ trans('editor.header_medium') }}
    + {{ trans('editor.header_small') }}
    + {{ trans('editor.header_tiny') }} +
    + Ctrl+5
    + Ctrl+D +
    + Cmd+5
    + Cmd+D +
    {{ trans('editor.paragraph') }}
    + Ctrl+6
    + Ctrl+Q +
    + Cmd+6
    + Cmd+Q +
    {{ trans('editor.blockquote') }}
    + Ctrl+7
    + Ctrl+E +
    + Cmd+7
    + Cmd+E +
    {{ trans('editor.insert_code_block') }}
    + Ctrl+8
    + Ctrl+Shift+E +
    + Cmd+8
    + Cmd+Shift+E +
    {{ trans('editor.inline_code') }}
    Ctrl+9Cmd+9 + {{ trans('editor.callouts') }}
    + {{ trans('editor.callouts_cycle') }} +
    + Ctrl+O
    + Ctrl+P +
    + Cmd+O
    + Cmd+P +
    + {{ trans('editor.list_numbered') }}
    + {{ trans('editor.list_bullet') }} +
    + Ctrl+Shift+K + + Cmd+Shift+K + {{ trans('editor.link_selector') }}
    + +
    +@endsection + diff --git a/resources/views/help/wysiwyg.blade.php b/resources/views/help/wysiwyg.blade.php index 8fc0912586d..4fc00b0e1e6 100644 --- a/resources/views/help/wysiwyg.blade.php +++ b/resources/views/help/wysiwyg.blade.php @@ -1,146 +1,138 @@ -@extends('layouts.plain') -@section('document-class', 'bg-white ' . (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '')) +

    {{ trans('editor.shortcuts') }}

    -@section('content') -
    - -

    {{ trans('editor.editor_license') }}

    -

    - {!! trans('editor.editor_tiny_license', ['tinyLink' => 'TinyMCE']) !!} -
    - {{ trans('editor.editor_tiny_license_link') }} -

    - -

    {{ trans('editor.shortcuts') }}

    - -

    {{ trans('editor.shortcuts_intro') }}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
    Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
    Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
    Ctrl+BCmd+B{{ trans('editor.bold') }}
    Ctrl+ICmd+I{{ trans('editor.italic') }}
    - Ctrl+1
    - Ctrl+2
    - Ctrl+3
    - Ctrl+4 -
    - Cmd+1
    - Cmd+2
    - Cmd+3
    - Cmd+4 -
    - {{ trans('editor.header_large') }}
    - {{ trans('editor.header_medium') }}
    - {{ trans('editor.header_small') }}
    - {{ trans('editor.header_tiny') }} -
    - Ctrl+5
    - Ctrl+D -
    - Cmd+5
    - Cmd+D -
    {{ trans('editor.paragraph') }}
    - Ctrl+6
    - Ctrl+Q -
    - Cmd+6
    - Cmd+Q -
    {{ trans('editor.blockquote') }}
    - Ctrl+7
    - Ctrl+E -
    - Cmd+7
    - Cmd+E -
    {{ trans('editor.insert_code_block') }}
    - Ctrl+Shift+8
    - Ctrl+Shift+E -
    - Cmd+Shift+8
    - Cmd+Shift+E -
    {{ trans('editor.inline_code') }}
    Ctrl+9Cmd+9 - {{ trans('editor.callouts') }}
    - {{ trans('editor.callouts_cycle') }} -
    - Ctrl+O
    - Ctrl+P -
    - Cmd+O
    - Cmd+P -
    - {{ trans('editor.list_numbered') }}
    - {{ trans('editor.list_bullet') }} -
    - Ctrl+Shift+K - - Cmd+Shift+K - {{ trans('editor.link_selector') }}
    - -
    -@endsection +

    {{ trans('editor.shortcuts_intro') }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
    Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
    Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
    Ctrl+BCmd+B{{ trans('editor.bold') }}
    Ctrl+ICmd+I{{ trans('editor.italic') }}
    + Ctrl+1
    + Ctrl+2
    + Ctrl+3
    + Ctrl+4 +
    + Cmd+1
    + Cmd+2
    + Cmd+3
    + Cmd+4 +
    + {{ trans('editor.header_large') }}
    + {{ trans('editor.header_medium') }}
    + {{ trans('editor.header_small') }}
    + {{ trans('editor.header_tiny') }} +
    + Ctrl+5
    + Ctrl+D +
    + Cmd+5
    + Cmd+D +
    {{ trans('editor.paragraph') }}
    + Ctrl+6
    + Ctrl+Q +
    + Cmd+6
    + Cmd+Q +
    {{ trans('editor.blockquote') }}
    + Ctrl+7
    + Ctrl+E +
    + Cmd+7
    + Cmd+E +
    {{ trans('editor.insert_code_block') }}
    + Ctrl+8
    + Ctrl+Shift+E +
    + Cmd+8
    + Cmd+Shift+E +
    {{ trans('editor.inline_code') }}
    Ctrl+9Cmd+9 + {{ trans('editor.callouts') }}
    + {{ trans('editor.callouts_cycle') }} +
    + Ctrl+O
    + Ctrl+P +
    + Cmd+O
    + Cmd+P +
    + {{ trans('editor.list_numbered') }}
    + {{ trans('editor.list_bullet') }} +
    + Ctrl+Shift+K + + Cmd+Shift+K + {{ trans('editor.link_selector') }}
    +

    {{ trans('editor.editor_license') }}

    +

    + {!! trans('editor.editor_lexical_license', ['lexicalLink' => 'Lexical']) !!} +
    + Copyright (c) Meta Platforms, Inc. and affiliates. +
    + {{ trans('editor.editor_lexical_license_link') }} +

    \ No newline at end of file diff --git a/resources/views/home/books.blade.php b/resources/views/home/books.blade.php index a2f2bf79605..afd3f30fce2 100644 --- a/resources/views/home/books.blade.php +++ b/resources/views/home/books.blade.php @@ -12,7 +12,7 @@
    {{ trans('common.actions') }}
  • @include('entities.sibling-navigation', ['next' => $next, 'previous' => $previous]) @if ($commentTree->enabled()) - @if(($previous || $next)) - - @endif -
    diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index 89d743fdc7c..0407275e059 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -26,11 +26,11 @@ class="flex-container-row wrap justify-flex-start gap-x-m gap-y-xs"> class="input-base text-left">{{ $filters['event'] ?: trans('settings.audit_event_filter_no_filter') }} @@ -88,14 +88,18 @@ class="text-item">{{ $type }} @foreach($activities as $activity)
    - @include('settings.parts.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id]) + @if($activity->user && $activity->user->created_at <= $activity->created_at) + @include('settings.parts.table-user', ['user' => $activity->user]) + @else + [ID: {{ $activity->user_id }}] {{ trans('common.deleted_user') }} + @endif
    {{ trans('settings.audit_table_event') }} : {{ $activity->type }}
    - @if($activity->entity) - @include('entities.icon-link', ['entity' => $activity->entity]) + @if($activity->loggable instanceof \BookStack\Entities\Models\Entity) + @include('entities.icon-link', ['entity' => $activity->loggable]) @elseif($activity->detail && $activity->isForEntity())
    {{ trans('settings.audit_deleted_item') }}
    diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/categories/customization.blade.php similarity index 98% rename from resources/views/settings/customization.blade.php rename to resources/views/settings/categories/customization.blade.php index 4845e2055fc..732cb01988a 100644 --- a/resources/views/settings/customization.blade.php +++ b/resources/views/settings/categories/customization.blade.php @@ -32,6 +32,7 @@
    diff --git a/resources/views/settings/features.blade.php b/resources/views/settings/categories/features.blade.php similarity index 97% rename from resources/views/settings/features.blade.php rename to resources/views/settings/categories/features.blade.php index 5935e21f549..d451a0ba35e 100644 --- a/resources/views/settings/features.blade.php +++ b/resources/views/settings/categories/features.blade.php @@ -13,7 +13,7 @@

    {!! trans('settings.app_public_access_desc') !!}

    - @if(userCan('users-manage')) + @if(userCan(\BookStack\Permissions\Permission::UsersManage))

    {!! trans('settings.app_public_access_desc_guest') !!}

    diff --git a/resources/views/settings/registration.blade.php b/resources/views/settings/categories/registration.blade.php similarity index 100% rename from resources/views/settings/registration.blade.php rename to resources/views/settings/categories/registration.blade.php diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php new file mode 100644 index 00000000000..5678434e0a5 --- /dev/null +++ b/resources/views/settings/categories/sorting.blade.php @@ -0,0 +1,94 @@ +@extends('settings.layout') + +@php + $sortRules = \BookStack\Sorting\SortRule::allByName(); +@endphp + +@section('card') +

    {{ trans('settings.sorting') }}

    +
    + {{ csrf_field() }} + + +
    +
    +
    + +

    {{ trans('settings.sorting_page_limits_desc') }}

    +
    +
    + @php + $labelByKey = ['shelves' => trans('entities.shelves'), 'books' => trans('entities.books'), 'search' => trans('entities.search_results')]; + @endphp + @foreach($labelByKey as $key => $label) +
    + + @include('form.number', [ + 'name' => 'setting-lists-page-count-' . $key, + 'value' => setting()->getInteger('lists-page-count-' . $key, 18, 1, 1000), + 'min' => 1, + 'step' => 1, + ]) +
    + @endforeach +
    +
    + +
    +
    + +

    {{ trans('settings.sorting_book_default_desc') }}

    +
    +
    + +

    + {{ trans('settings.sort_rule_create') }} +

    +
    +
    + +
    + +
    + +
    +
    +@endsection + +@section('after-card') +
    +
    +
    +

    {{ trans('settings.sorting_rules') }}

    +

    {{ trans('settings.sorting_rules_desc') }}

    +
    + +
    + + @if(empty($sortRules)) +

    {{ trans('common.no_items') }}

    + @else +
    + @foreach($sortRules as $rule) + @include('settings.sort-rules.parts.sort-rule-list-item', ['rule' => $rule]) + @endforeach +
    + @endif +
    +@endsection \ No newline at end of file diff --git a/resources/views/settings/layout.blade.php b/resources/views/settings/layout.blade.php index 94a8f7725d9..2ca06b86ac0 100644 --- a/resources/views/settings/layout.blade.php +++ b/resources/views/settings/layout.blade.php @@ -13,13 +13,16 @@ @icon('star') {{ trans('settings.app_features_security') }} @icon('palette') {{ trans('settings.app_customization') }} @icon('security') {{ trans('settings.reg_settings') }} + @icon('sort') {{ trans('settings.sorting') }}
    {{ trans('settings.system_version') }}
    @@ -27,6 +30,7 @@
    @yield('card')
    + @yield('after-card')
    diff --git a/resources/views/settings/parts/navbar.blade.php b/resources/views/settings/parts/navbar.blade.php index e229f5e654f..cda8bdae0c1 100644 --- a/resources/views/settings/parts/navbar.blade.php +++ b/resources/views/settings/parts/navbar.blade.php @@ -1,19 +1,19 @@ \ No newline at end of file diff --git a/resources/views/settings/parts/table-user.blade.php b/resources/views/settings/parts/table-user.blade.php index d29ad1979a0..affc7b6c4c4 100644 --- a/resources/views/settings/parts/table-user.blade.php +++ b/resources/views/settings/parts/table-user.blade.php @@ -1,12 +1,7 @@ {{-- -$user - User mode to display, Can be null. -$user_id - Id of user to show. Must be provided. +$user - User to display. --}} -@if($user) - -
    {{ $user->name }}
    -
    {{ $user->name }}
    -
    -@else - [ID: {{ $user_id }}] {{ trans('common.deleted_user') }} -@endif \ No newline at end of file + +
    {{ $user->name }}
    +
    {{ $user->name }}
    +
    \ No newline at end of file diff --git a/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php b/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php index 8af598b1e51..2dad617dcad 100644 --- a/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php +++ b/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php @@ -33,15 +33,26 @@ @endif
    -
    {{ trans('settings.recycle_bin_deleted_by') }}:
    @include('settings.parts.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])
    +
    + {{ trans('settings.recycle_bin_deleted_by') }}:
    + @if($deletion->deleter) + @include('settings.parts.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by]) + @else + {{ trans('common.deleted_user') }} + @endif +
    {{ trans('settings.recycle_bin_deleted_at') }}:
    {{ $deletion->created_at }}
    diff --git a/resources/views/settings/roles/edit.blade.php b/resources/views/settings/roles/edit.blade.php index bd1dbb76322..da7835e1a02 100644 --- a/resources/views/settings/roles/edit.blade.php +++ b/resources/views/settings/roles/edit.blade.php @@ -35,11 +35,11 @@ {{ $user->name }}
    - @if(userCan('users-manage') || user()->id == $user->id) + @if(userCan(\BookStack\Permissions\Permission::UsersManage) || user()->id == $user->id) id}") }}"> @endif {{ $user->name }} - @if(userCan('users-manage') || user()->id == $user->id) + @if(userCan(\BookStack\Permissions\Permission::UsersManage) || user()->id == $user->id) @endif
    diff --git a/resources/views/settings/roles/index.blade.php b/resources/views/settings/roles/index.blade.php index 27ee9ce3f90..3ccdd9b851c 100644 --- a/resources/views/settings/roles/index.blade.php +++ b/resources/views/settings/roles/index.blade.php @@ -24,6 +24,7 @@
    diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index 9fa76f2bfd7..890f790574e 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -30,13 +30,14 @@ {{ trans('common.toggle_all') }} -
    +
    -
    @include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])
    -
    @include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])
    +
    @include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')]) 1
    +
    @include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')]) 1
    @include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])
    @include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])
    @include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])
    +
    @include('settings.roles.parts.checkbox', ['permission' => 'content-import', 'label' => trans('settings.role_import_content')])
    @include('settings.roles.parts.checkbox', ['permission' => 'editor-change', 'label' => trans('settings.role_editor_change')])
    @include('settings.roles.parts.checkbox', ['permission' => 'receive-notifications', 'label' => trans('settings.role_notifications')])
    @@ -47,6 +48,10 @@

    {{ trans('settings.roles_system_warning') }}

    + +

    + 1 {{ trans('settings.role_permission_note_users_and_roles') }} +

    @@ -74,7 +79,8 @@ class="item-list toggle-switch-list"> @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.books'), 'permissionPrefix' => 'book']) @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.chapters'), 'permissionPrefix' => 'chapter']) @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.pages'), 'permissionPrefix' => 'page']) - @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.images'), 'permissionPrefix' => 'image', 'refMark' => '1']) + @include('settings.roles.parts.revisions-permissions-row', ['title' => trans('entities.revisions'), 'permissionPrefix' => 'revision']) + @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.images'), 'permissionPrefix' => 'image']) @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.attachments'), 'permissionPrefix' => 'attachment']) @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.comments'), 'permissionPrefix' => 'comment'])
    @@ -82,6 +88,8 @@ class="item-list toggle-switch-list">

    1 {{ trans('settings.role_asset_image_view_note') }} +
    + 2 {{ trans('settings.role_asset_users_note') }}

    diff --git a/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php b/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php index 62fdd6b744a..b9bf5427f83 100644 --- a/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php @@ -6,16 +6,19 @@
    {{ trans('common.create') }}
    @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => '']) + @if($permissionPrefix === 'comment')2@endif
    {{ trans('common.view') }}
    - {{ trans('settings.role_controlled_by_asset') }}@if($refMark ?? false){{ $refMark }}@endif + {{ trans('settings.role_controlled_by_asset') }}@if($permissionPrefix === 'image')1@endif
    {{ trans('common.edit') }}
    @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own')]) + @if($permissionPrefix === 'comment')2@endif
    @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all')]) + @if($permissionPrefix === 'comment')2@endif
    {{ trans('common.delete') }}
    diff --git a/resources/views/settings/roles/parts/revisions-permissions-row.blade.php b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php new file mode 100644 index 00000000000..326925ef93c --- /dev/null +++ b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php @@ -0,0 +1,22 @@ +
    + +
    + {{ trans('common.create') }}
    + - +
    +
    + {{ trans('common.view') }}
    + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')]) +
    +
    + {{ trans('common.edit') }}
    + - +
    +
    + {{ trans('common.delete') }}
    + {{ trans('settings.role_controlled_by_page_delete') }} +
    +
    diff --git a/resources/views/settings/sort-rules/create.blade.php b/resources/views/settings/sort-rules/create.blade.php new file mode 100644 index 00000000000..e1d5c7c4630 --- /dev/null +++ b/resources/views/settings/sort-rules/create.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.simple') + +@section('body') + +
    + + @include('settings.parts.navbar', ['selected' => 'settings']) + +
    +

    {{ trans('settings.sort_rule_create') }}

    + +
    + {{ csrf_field() }} + @include('settings.sort-rules.parts.form', ['model' => null]) + +
    + {{ trans('common.cancel') }} + +
    +
    +
    +
    + +@stop diff --git a/resources/views/settings/sort-rules/edit.blade.php b/resources/views/settings/sort-rules/edit.blade.php new file mode 100644 index 00000000000..8bf04701fe0 --- /dev/null +++ b/resources/views/settings/sort-rules/edit.blade.php @@ -0,0 +1,54 @@ +@extends('layouts.simple') + +@section('body') + +
    + + @include('settings.parts.navbar', ['selected' => 'settings']) + +
    +

    {{ trans('settings.sort_rule_edit') }}

    + +
    + {{ method_field('PUT') }} + {{ csrf_field() }} + + @include('settings.sort-rules.parts.form', ['model' => $rule]) + +
    + {{ trans('common.cancel') }} + +
    +
    +
    + +
    +
    +
    +

    {{ trans('settings.sort_rule_delete') }}

    +

    {{ trans('settings.sort_rule_delete_desc') }}

    + @if($errors->has('delete')) + @foreach($errors->get('delete') as $error) +

    {{ $error }}

    + @endforeach + @endif +
    +
    +
    + {{ method_field('DELETE') }} + {{ csrf_field() }} + + @if($errors->has('delete')) + + @endif + +
    + +
    +
    +
    +
    +
    +
    + +@stop diff --git a/resources/views/settings/sort-rules/parts/form.blade.php b/resources/views/settings/sort-rules/parts/form.blade.php new file mode 100644 index 00000000000..d6de947b6c7 --- /dev/null +++ b/resources/views/settings/sort-rules/parts/form.blade.php @@ -0,0 +1,56 @@ +
    +
    +
    + +

    {{ trans('settings.sort_rule_details_desc') }}

    +
    +
    +
    + + @include('form.text', ['name' => 'name']) +
    +
    +
    + +
    + +

    {{ trans('settings.sort_rule_operations_desc') }}

    + @include('form.errors', ['name' => 'sequence']) + + + + @php + $configuredOps = old('sequence') ? \BookStack\Sorting\SortRuleOperation::fromSequence(old('sequence')) : ($model?->getOperations() ?? []); + @endphp + +
    +
    + +
      +
    • {{ trans('settings.sort_rule_configured_operations_empty') }}
    • + + @foreach($configuredOps as $operation) + @include('settings.sort-rules.parts.operation', ['operation' => $operation]) + @endforeach +
    +
    + +
    + +
      +
    • {{ trans('settings.sort_rule_available_operations_empty') }}
    • + @foreach(\BookStack\Sorting\SortRuleOperation::allExcluding($configuredOps) as $operation) + @include('settings.sort-rules.parts.operation', ['operation' => $operation]) + @endforeach +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/resources/views/settings/sort-rules/parts/operation.blade.php b/resources/views/settings/sort-rules/parts/operation.blade.php new file mode 100644 index 00000000000..3feb68a4742 --- /dev/null +++ b/resources/views/settings/sort-rules/parts/operation.blade.php @@ -0,0 +1,15 @@ +
  • +
    @icon('grip')
    +
    {{ $operation->getLabel() }}
    +
    + + + + +
    +
  • \ No newline at end of file diff --git a/resources/views/settings/sort-rules/parts/sort-rule-list-item.blade.php b/resources/views/settings/sort-rules/parts/sort-rule-list-item.blade.php new file mode 100644 index 00000000000..5236cb412a3 --- /dev/null +++ b/resources/views/settings/sort-rules/parts/sort-rule-list-item.blade.php @@ -0,0 +1,12 @@ +
    + +
    + {{ implode(', ', array_map(fn ($op) => $op->getLabel(), $rule->getOperations())) }} +
    +
    + @icon('book'){{ $rule->books_count ?? 0 }} +
    +
    \ No newline at end of file diff --git a/resources/views/settings/webhooks/edit.blade.php b/resources/views/settings/webhooks/edit.blade.php index 680fc1207b0..22d0c0183c2 100644 --- a/resources/views/settings/webhooks/edit.blade.php +++ b/resources/views/settings/webhooks/edit.blade.php @@ -14,9 +14,17 @@

    - {{ trans('settings.webhooks_last_called') }} {{ $webhook->last_called_at ? $webhook->last_called_at->diffForHumans() : trans('common.never') }} + @if($webhook->last_called_at) + {{ trans('settings.webhooks_last_called') }} {{ $dates->relative($webhook->last_called_at) }} + @else + {{ trans('settings.webhooks_last_called') }} {{ trans('common.never') }} + @endif
    - {{ trans('settings.webhooks_last_errored') }} {{ $webhook->last_errored_at ? $webhook->last_errored_at->diffForHumans() : trans('common.never') }} + @if($webhook->last_errored_at) + {{ trans('settings.webhooks_last_errored') }} {{ $dates->relative($webhook->last_errored_at) }} + @else + {{ trans('settings.webhooks_last_errored') }} {{ trans('common.never') }} + @endif

    diff --git a/resources/views/shelves/index.blade.php b/resources/views/shelves/index.blade.php index 85663b41307..70357068d7e 100644 --- a/resources/views/shelves/index.blade.php +++ b/resources/views/shelves/index.blade.php @@ -5,51 +5,11 @@ @stop @section('right') - -
    -
    {{ trans('common.actions') }}
    - -
    - + @include('shelves.parts.index-sidebar-section-actions', ['view' => $view]) @stop @section('left') - @if($recents) -
    -
    {{ trans('entities.recently_viewed') }}
    - @include('entities.list', ['entities' => $recents, 'style' => 'compact']) -
    - @endif - - - -
    -
    {{ trans('entities.shelves_new') }}
    - @if(count($new) > 0) - @include('entities.list', ['entities' => $new, 'style' => 'compact']) - @else -

    {{ trans('entities.shelves_new_empty') }}

    - @endif -
    + @include('shelves.parts.index-sidebar-section-recents', ['recents' => $recents]) + @include('shelves.parts.index-sidebar-section-popular', ['popular' => $popular]) + @include('shelves.parts.index-sidebar-section-new', ['new' => $new]) @stop \ No newline at end of file diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php index a75dd6ac1b5..0a5343b16eb 100644 --- a/resources/views/shelves/parts/form.blade.php +++ b/resources/views/shelves/parts/form.blade.php @@ -1,7 +1,3 @@ -@push('head') - -@endpush - {{ csrf_field() }}
    @@ -30,15 +26,18 @@ class="icon-button px-xs py-xxs mx-xs text-bigger" @icon('more')
      + class="scroll-box configured-option-list"> @foreach (($shelf->visibleBooks ?? []) as $book) @include('shelves.parts.shelf-sort-book-item', ['book' => $book]) @endforeach @@ -46,10 +45,11 @@ class="scroll-box">
    - +
      + class="scroll-box available-option-list"> @foreach ($books as $book) @include('shelves.parts.shelf-sort-book-item', ['book' => $book]) @endforeach @@ -58,7 +58,6 @@ class="scroll-box">
    -
    - {{ trans('common.cancel') }} + {{ trans('common.cancel') }}
    diff --git a/resources/views/shelves/parts/index-sidebar-section-actions.blade.php b/resources/views/shelves/parts/index-sidebar-section-actions.blade.php new file mode 100644 index 00000000000..d5cdb4056a4 --- /dev/null +++ b/resources/views/shelves/parts/index-sidebar-section-actions.blade.php @@ -0,0 +1,18 @@ +
    +
    {{ trans('common.actions') }}
    + +
    \ No newline at end of file diff --git a/resources/views/shelves/parts/index-sidebar-section-new.blade.php b/resources/views/shelves/parts/index-sidebar-section-new.blade.php new file mode 100644 index 00000000000..602f60ebeaa --- /dev/null +++ b/resources/views/shelves/parts/index-sidebar-section-new.blade.php @@ -0,0 +1,8 @@ +
    +
    {{ trans('entities.shelves_new') }}
    + @if(count($new) > 0) + @include('entities.list', ['entities' => $new, 'style' => 'compact']) + @else +

    {{ trans('entities.shelves_new_empty') }}

    + @endif +
    \ No newline at end of file diff --git a/resources/views/shelves/parts/index-sidebar-section-popular.blade.php b/resources/views/shelves/parts/index-sidebar-section-popular.blade.php new file mode 100644 index 00000000000..956321c5e01 --- /dev/null +++ b/resources/views/shelves/parts/index-sidebar-section-popular.blade.php @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/resources/views/shelves/parts/index-sidebar-section-recents.blade.php b/resources/views/shelves/parts/index-sidebar-section-recents.blade.php new file mode 100644 index 00000000000..f1a68ba4f99 --- /dev/null +++ b/resources/views/shelves/parts/index-sidebar-section-recents.blade.php @@ -0,0 +1,6 @@ +@if($recents) +
    +
    {{ trans('entities.recently_viewed') }}
    + @include('entities.list', ['entities' => $recents, 'style' => 'compact']) +
    +@endif \ No newline at end of file diff --git a/resources/views/shelves/parts/list-item.blade.php b/resources/views/shelves/parts/list-item.blade.php index 00cacfa707c..5fc8a362b32 100644 --- a/resources/views/shelves/parts/list-item.blade.php +++ b/resources/views/shelves/parts/list-item.blade.php @@ -1,5 +1,5 @@ -
    +
    @icon('bookshelf')
    diff --git a/resources/views/shelves/parts/list.blade.php b/resources/views/shelves/parts/list.blade.php index 7f6e02a8b7c..5be5a7bb6c4 100644 --- a/resources/views/shelves/parts/list.blade.php +++ b/resources/views/shelves/parts/list.blade.php @@ -29,7 +29,7 @@
    @else

    {{ trans('entities.shelves_empty') }}

    - @if(userCan('bookshelf-create-all')) + @if(userCan(\BookStack\Permissions\Permission::BookshelfCreateAll))
    diff --git a/resources/views/shelves/parts/show-sidebar-section-actions.blade.php b/resources/views/shelves/parts/show-sidebar-section-actions.blade.php new file mode 100644 index 00000000000..ba92e5f703b --- /dev/null +++ b/resources/views/shelves/parts/show-sidebar-section-actions.blade.php @@ -0,0 +1,43 @@ +
    +
    {{ trans('common.actions') }}
    +
    +
    \ No newline at end of file diff --git a/resources/views/shelves/parts/show-sidebar-section-activity.blade.php b/resources/views/shelves/parts/show-sidebar-section-activity.blade.php new file mode 100644 index 00000000000..c1c5c1d3ecf --- /dev/null +++ b/resources/views/shelves/parts/show-sidebar-section-activity.blade.php @@ -0,0 +1,6 @@ +@if(count($activity) > 0) +
    +
    {{ trans('entities.recent_activity') }}
    + @include('common.activity-list', ['activity' => $activity]) +
    +@endif \ No newline at end of file diff --git a/resources/views/shelves/parts/show-sidebar-section-details.blade.php b/resources/views/shelves/parts/show-sidebar-section-details.blade.php new file mode 100644 index 00000000000..8933cc41973 --- /dev/null +++ b/resources/views/shelves/parts/show-sidebar-section-details.blade.php @@ -0,0 +1,21 @@ +
    +
    {{ trans('common.details') }}
    + +
    \ No newline at end of file diff --git a/resources/views/shelves/parts/show-sidebar-section-tags.blade.php b/resources/views/shelves/parts/show-sidebar-section-tags.blade.php new file mode 100644 index 00000000000..265d61cd0d3 --- /dev/null +++ b/resources/views/shelves/parts/show-sidebar-section-tags.blade.php @@ -0,0 +1,5 @@ +@if($shelf->tags->count() > 0) +
    + @include('entities.tag-list', ['entity' => $shelf]) +
    +@endif \ No newline at end of file diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php index 11baccaf463..9d07e5da018 100644 --- a/resources/views/shelves/show.blade.php +++ b/resources/views/shelves/show.blade.php @@ -2,8 +2,8 @@ @push('social-meta') - @if($shelf->cover) - + @if($shelf->coverInfo()->exists()) + @endif @endpush @@ -28,7 +28,7 @@
    -
    {!! $shelf->descriptionHtml() !!}
    +
    {!! $shelf->descriptionInfo()->getHtml() !!}
    @if(count($sortedVisibleShelfBooks) > 0) @if($view === 'list')
    @@ -48,13 +48,13 @@

    {{ trans('entities.shelves_empty_contents') }}

    - @if(userCan('book-create-all') && userCan('bookshelf-update', $shelf)) + @if(userCan(\BookStack\Permissions\Permission::BookCreateAll) && userCan(\BookStack\Permissions\Permission::BookshelfUpdate, $shelf)) @icon('add') {{ trans('entities.books_create') }} @endif - @if(userCan('bookshelf-update', $shelf)) + @if(userCan(\BookStack\Permissions\Permission::BookshelfUpdate, $shelf)) @icon('edit') {{ trans('entities.shelves_edit_and_assign') }} @@ -69,87 +69,13 @@ @stop @section('left') - - @if($shelf->tags->count() > 0) -
    - @include('entities.tag-list', ['entity' => $shelf]) -
    - @endif - -
    - - @if(count($activity) > 0) -
    -
    {{ trans('entities.recent_activity') }}
    - @include('common.activity-list', ['activity' => $activity]) -
    - @endif + @include('shelves.parts.show-sidebar-section-tags', ['shelf' => $shelf]) + @include('shelves.parts.show-sidebar-section-details', ['shelf' => $shelf]) + @include('shelves.parts.show-sidebar-section-activity', ['activity' => $activity]) @stop @section('right') -
    -
    {{ trans('common.actions') }}
    - -
    + @include('shelves.parts.show-sidebar-section-actions', ['shelf' => $shelf, 'view' => $view]) @stop diff --git a/resources/views/users/account/auth.blade.php b/resources/views/users/account/auth.blade.php index d6f85093bf3..57e6c1f9cb3 100644 --- a/resources/views/users/account/auth.blade.php +++ b/resources/views/users/account/auth.blade.php @@ -81,7 +81,7 @@ class="button small outline">{{ trans('settings.users_social_connect') }} @endif - @if(userCan('access-api')) + @if(userCan(\BookStack\Permissions\Permission::AccessApi)) @include('users.api-tokens.parts.list', ['user' => user(), 'context' => 'my-account']) @endif @stop diff --git a/resources/views/users/account/delete.blade.php b/resources/views/users/account/delete.blade.php index 75698d91994..aa051e9adc5 100644 --- a/resources/views/users/account/delete.blade.php +++ b/resources/views/users/account/delete.blade.php @@ -12,7 +12,7 @@

    {{ trans('preferences.delete_my_account_desc') }}

    - @if(userCan('users-manage')) + @if(userCan(\BookStack\Permissions\Permission::UsersManage))
    diff --git a/resources/views/users/account/layout.blade.php b/resources/views/users/account/layout.blade.php index f54a51c5ac8..df8ebc2d904 100644 --- a/resources/views/users/account/layout.blade.php +++ b/resources/views/users/account/layout.blade.php @@ -12,7 +12,7 @@ @icon('user') {{ trans('preferences.profile') }} @icon('security') {{ trans('preferences.auth') }} @icon('shortcuts') {{ trans('preferences.shortcuts_interface') }} - @if(userCan('receive-notifications')) + @if(userCan(\BookStack\Permissions\Permission::ReceiveNotifications)) @icon('notifications') {{ trans('preferences.notifications') }} @endif diff --git a/resources/views/users/account/notifications.blade.php b/resources/views/users/account/notifications.blade.php index b3b082bd7e3..c61cf4af8f7 100644 --- a/resources/views/users/account/notifications.blade.php +++ b/resources/views/users/account/notifications.blade.php @@ -33,6 +33,13 @@ 'label' => trans('preferences.notifications_opt_comment_replies'), ])
    +
    + @include('form.toggle-switch', [ + 'name' => 'preferences[comment-mentions]', + 'value' => $preferences->notifyOnCommentMentions(), + 'label' => trans('preferences.notifications_opt_comment_mentions'), + ]) +
    @endif
    diff --git a/resources/views/users/account/profile.blade.php b/resources/views/users/account/profile.blade.php index 617c0972302..778e35885f6 100644 --- a/resources/views/users/account/profile.blade.php +++ b/resources/views/users/account/profile.blade.php @@ -35,10 +35,10 @@

    {{ trans('preferences.profile_email_desc') }}

    - @include('form.text', ['name' => 'email', 'disabled' => !userCan('users-manage')]) + @include('form.text', ['name' => 'email', 'disabled' => !userCan(\BookStack\Permissions\Permission::UsersManage)])
    - @if(!userCan('users-manage')) + @if(!userCan(\BookStack\Permissions\Permission::UsersManage))

    {{ trans('preferences.profile_email_no_permission') }}

    @endif
    @@ -75,7 +75,7 @@ class="setting-list-label">{{ trans('settings.users_avatar') }} - @if(userCan('users-manage')) + @if(userCan(\BookStack\Permissions\Permission::UsersManage))
    diff --git a/resources/views/users/api-tokens/edit.blade.php b/resources/views/users/api-tokens/edit.blade.php index aa3e49dedf8..3a1ff49d3c9 100644 --- a/resources/views/users/api-tokens/edit.blade.php +++ b/resources/views/users/api-tokens/edit.blade.php @@ -42,12 +42,12 @@
    - - {{ trans('settings.user_api_token_created', ['timeAgo' => $token->created_at->diffForHumans()]) }} + + {{ trans('settings.user_api_token_created', ['timeAgo' => $dates->relative($token->created_at)]) }}
    - - {{ trans('settings.user_api_token_updated', ['timeAgo' => $token->created_at->diffForHumans()]) }} + + {{ trans('settings.user_api_token_updated', ['timeAgo' => $dates->relative($token->created_at)]) }}
    diff --git a/resources/views/users/api-tokens/parts/list.blade.php b/resources/views/users/api-tokens/parts/list.blade.php index 70aaa58f3f6..bd8dd53cc35 100644 --- a/resources/views/users/api-tokens/parts/list.blade.php +++ b/resources/views/users/api-tokens/parts/list.blade.php @@ -2,7 +2,7 @@

    {{ trans('settings.users_api_tokens') }}

    - @if(userCan('access-api')) + @if(userCan(\BookStack\Permissions\Permission::AccessApi)) {{ trans('settings.users_api_tokens_docs') }} {{ trans('settings.users_api_tokens_create') }} @endif diff --git a/resources/views/users/create.blade.php b/resources/views/users/create.blade.php index dafc623e120..daffc91a39d 100644 --- a/resources/views/users/create.blade.php +++ b/resources/views/users/create.blade.php @@ -18,7 +18,7 @@
    diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 2b736d81ede..8a4a8bcce79 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -34,7 +34,9 @@ class="setting-list-label">{{ trans('settings.users_avatar') }}
    - @include('users.parts.language-option-row', ['value' => old('language') ?? $user->getLocale()->appLocale()]) + @if(!$user->isGuest()) + @include('users.parts.language-option-row', ['value' => old('language') ?? $user->getLocale()->appLocale()]) + @endif
    @@ -69,6 +71,40 @@ class="button outline">{{ trans('settings.users_mfa_configure') }}
    + @if($mfaMethods->count() > 0) +
    + +
    +
    +

    {{ trans('settings.users_mfa_reset_desc') }}

    +
    id}/mfa") }}" method="POST" style="display: inline;"> + {{ csrf_field() }} + {{ method_field('DELETE') }} + +
    +
    +
    +
    + @endif
    @if(count($activeSocialDrivers) > 0) diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php index 0dd607f8c7d..5d546076f98 100644 --- a/resources/views/users/index.blade.php +++ b/resources/views/users/index.blade.php @@ -22,6 +22,7 @@
    diff --git a/resources/views/users/parts/form.blade.php b/resources/views/users/parts/form.blade.php index bf1eb08a760..86287646f05 100644 --- a/resources/views/users/parts/form.blade.php +++ b/resources/views/users/parts/form.blade.php @@ -17,9 +17,9 @@ @include('form.text', ['name' => 'name'])
    - @if($authMethod !== 'ldap' || userCan('users-manage')) + @if($authMethod !== 'ldap' || userCan(\BookStack\Permissions\Permission::UsersManage)) - @include('form.text', ['name' => 'email', 'disabled' => !userCan('users-manage')]) + @include('form.text', ['name' => 'email', 'disabled' => !userCan(\BookStack\Permissions\Permission::UsersManage)]) @endif
    diff --git a/resources/views/users/parts/users-list-item.blade.php b/resources/views/users/parts/users-list-item.blade.php index dc7c9f272b8..9c7ecd14761 100644 --- a/resources/views/users/parts/users-list-item.blade.php +++ b/resources/views/users/parts/users-list-item.blade.php @@ -20,7 +20,7 @@ @if($user->last_activity_at) {{ trans('settings.users_latest_activity') }}
    - {{ $user->last_activity_at->diffForHumans() }} + {{ $dates->relative($user->last_activity_at) }} @endif diff --git a/resources/views/users/profile.blade.php b/resources/views/users/profile.blade.php index a8be8a4c198..9879091a5b9 100644 --- a/resources/views/users/profile.blade.php +++ b/resources/views/users/profile.blade.php @@ -23,7 +23,7 @@

    {{ $user->name }}

    - {{ trans('entities.profile_user_for_x', ['time' => $user->created_at->diffForHumans(null, true)]) }} + {{ trans('entities.profile_user_for_x', ['time' => $dates->relative($user->created_at, false)]) }}

    diff --git a/routes/api.php b/routes/api.php index 04c94a9664d..5a9df3cc422 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,12 +2,16 @@ /** * Routes for the BookStack API. - * Routes have a uri prefix of /api/. + * Routes have a URI prefix of /api/. * Controllers all end with "ApiController" */ +use BookStack\Activity\Controllers as ActivityControllers; +use BookStack\Activity\Controllers\TagApiController; use BookStack\Api\ApiDocsController; +use BookStack\App\SystemApiController; use BookStack\Entities\Controllers as EntityControllers; +use BookStack\Exports\Controllers as ExportControllers; use BookStack\Permissions\ContentPermissionApiController; use BookStack\Search\SearchApiController; use BookStack\Uploads\Controllers\AttachmentApiController; @@ -16,66 +20,85 @@ use BookStack\Users\Controllers\UserApiController; use Illuminate\Support\Facades\Route; -Route::get('docs.json', [ApiDocsController::class, 'json']); +// Main Entity Routes -Route::get('attachments', [AttachmentApiController::class, 'list']); -Route::post('attachments', [AttachmentApiController::class, 'create']); -Route::get('attachments/{id}', [AttachmentApiController::class, 'read']); -Route::put('attachments/{id}', [AttachmentApiController::class, 'update']); -Route::delete('attachments/{id}', [AttachmentApiController::class, 'delete']); +Route::get('pages', [EntityControllers\PageApiController::class, 'list']); +Route::post('pages', [EntityControllers\PageApiController::class, 'create']); +Route::get('pages/{id}', [EntityControllers\PageApiController::class, 'read']); +Route::put('pages/{id}', [EntityControllers\PageApiController::class, 'update']); +Route::delete('pages/{id}', [EntityControllers\PageApiController::class, 'delete']); +Route::get('pages/{id}/export/html', [ExportControllers\PageExportApiController::class, 'exportHtml']); +Route::get('pages/{id}/export/pdf', [ExportControllers\PageExportApiController::class, 'exportPdf']); +Route::get('pages/{id}/export/plaintext', [ExportControllers\PageExportApiController::class, 'exportPlainText']); +Route::get('pages/{id}/export/markdown', [ExportControllers\PageExportApiController::class, 'exportMarkdown']); +Route::get('pages/{id}/export/zip', [ExportControllers\PageExportApiController::class, 'exportZip']); + +Route::get('chapters', [EntityControllers\ChapterApiController::class, 'list']); +Route::post('chapters', [EntityControllers\ChapterApiController::class, 'create']); +Route::get('chapters/{id}', [EntityControllers\ChapterApiController::class, 'read']); +Route::put('chapters/{id}', [EntityControllers\ChapterApiController::class, 'update']); +Route::delete('chapters/{id}', [EntityControllers\ChapterApiController::class, 'delete']); +Route::get('chapters/{id}/export/html', [ExportControllers\ChapterExportApiController::class, 'exportHtml']); +Route::get('chapters/{id}/export/pdf', [ExportControllers\ChapterExportApiController::class, 'exportPdf']); +Route::get('chapters/{id}/export/plaintext', [ExportControllers\ChapterExportApiController::class, 'exportPlainText']); +Route::get('chapters/{id}/export/markdown', [ExportControllers\ChapterExportApiController::class, 'exportMarkdown']); +Route::get('chapters/{id}/export/zip', [ExportControllers\ChapterExportApiController::class, 'exportZip']); Route::get('books', [EntityControllers\BookApiController::class, 'list']); Route::post('books', [EntityControllers\BookApiController::class, 'create']); Route::get('books/{id}', [EntityControllers\BookApiController::class, 'read']); Route::put('books/{id}', [EntityControllers\BookApiController::class, 'update']); Route::delete('books/{id}', [EntityControllers\BookApiController::class, 'delete']); +Route::get('books/{id}/export/html', [ExportControllers\BookExportApiController::class, 'exportHtml']); +Route::get('books/{id}/export/pdf', [ExportControllers\BookExportApiController::class, 'exportPdf']); +Route::get('books/{id}/export/plaintext', [ExportControllers\BookExportApiController::class, 'exportPlainText']); +Route::get('books/{id}/export/markdown', [ExportControllers\BookExportApiController::class, 'exportMarkdown']); +Route::get('books/{id}/export/zip', [ExportControllers\BookExportApiController::class, 'exportZip']); -Route::get('books/{id}/export/html', [EntityControllers\BookExportApiController::class, 'exportHtml']); -Route::get('books/{id}/export/pdf', [EntityControllers\BookExportApiController::class, 'exportPdf']); -Route::get('books/{id}/export/plaintext', [EntityControllers\BookExportApiController::class, 'exportPlainText']); -Route::get('books/{id}/export/markdown', [EntityControllers\BookExportApiController::class, 'exportMarkdown']); +Route::get('shelves', [EntityControllers\BookshelfApiController::class, 'list']); +Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']); +Route::get('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'read']); +Route::put('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'update']); +Route::delete('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'delete']); -Route::get('chapters', [EntityControllers\ChapterApiController::class, 'list']); -Route::post('chapters', [EntityControllers\ChapterApiController::class, 'create']); -Route::get('chapters/{id}', [EntityControllers\ChapterApiController::class, 'read']); -Route::put('chapters/{id}', [EntityControllers\ChapterApiController::class, 'update']); -Route::delete('chapters/{id}', [EntityControllers\ChapterApiController::class, 'delete']); +// Additional Model Routes, in alphabetical order -Route::get('chapters/{id}/export/html', [EntityControllers\ChapterExportApiController::class, 'exportHtml']); -Route::get('chapters/{id}/export/pdf', [EntityControllers\ChapterExportApiController::class, 'exportPdf']); -Route::get('chapters/{id}/export/plaintext', [EntityControllers\ChapterExportApiController::class, 'exportPlainText']); -Route::get('chapters/{id}/export/markdown', [EntityControllers\ChapterExportApiController::class, 'exportMarkdown']); +Route::get('attachments', [AttachmentApiController::class, 'list']); +Route::post('attachments', [AttachmentApiController::class, 'create']); +Route::get('attachments/{id}', [AttachmentApiController::class, 'read']); +Route::put('attachments/{id}', [AttachmentApiController::class, 'update']); +Route::delete('attachments/{id}', [AttachmentApiController::class, 'delete']); -Route::get('pages', [EntityControllers\PageApiController::class, 'list']); -Route::post('pages', [EntityControllers\PageApiController::class, 'create']); -Route::get('pages/{id}', [EntityControllers\PageApiController::class, 'read']); -Route::put('pages/{id}', [EntityControllers\PageApiController::class, 'update']); -Route::delete('pages/{id}', [EntityControllers\PageApiController::class, 'delete']); +Route::get('audit-log', [ActivityControllers\AuditLogApiController::class, 'list']); + +Route::get('comments', [ActivityControllers\CommentApiController::class, 'list']); +Route::post('comments', [ActivityControllers\CommentApiController::class, 'create']); +Route::get('comments/{id}', [ActivityControllers\CommentApiController::class, 'read']); +Route::put('comments/{id}', [ActivityControllers\CommentApiController::class, 'update']); +Route::delete('comments/{id}', [ActivityControllers\CommentApiController::class, 'delete']); -Route::get('pages/{id}/export/html', [EntityControllers\PageExportApiController::class, 'exportHtml']); -Route::get('pages/{id}/export/pdf', [EntityControllers\PageExportApiController::class, 'exportPdf']); -Route::get('pages/{id}/export/plaintext', [EntityControllers\PageExportApiController::class, 'exportPlainText']); -Route::get('pages/{id}/export/markdown', [EntityControllers\PageExportApiController::class, 'exportMarkdown']); +Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'read']); +Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']); + +Route::get('docs.json', [ApiDocsController::class, 'json']); Route::get('image-gallery', [ImageGalleryApiController::class, 'list']); Route::post('image-gallery', [ImageGalleryApiController::class, 'create']); +Route::get('image-gallery/url/data', [ImageGalleryApiController::class, 'readDataForUrl']); Route::get('image-gallery/{id}', [ImageGalleryApiController::class, 'read']); +Route::get('image-gallery/{id}/data', [ImageGalleryApiController::class, 'readData']); Route::put('image-gallery/{id}', [ImageGalleryApiController::class, 'update']); Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete']); -Route::get('search', [SearchApiController::class, 'all']); - -Route::get('shelves', [EntityControllers\BookshelfApiController::class, 'list']); -Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']); -Route::get('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'read']); -Route::put('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'update']); -Route::delete('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'delete']); +Route::get('imports', [ExportControllers\ImportApiController::class, 'list']); +Route::post('imports', [ExportControllers\ImportApiController::class, 'create']); +Route::get('imports/{id}', [ExportControllers\ImportApiController::class, 'read']); +Route::post('imports/{id}', [ExportControllers\ImportApiController::class, 'run']); +Route::delete('imports/{id}', [ExportControllers\ImportApiController::class, 'delete']); -Route::get('users', [UserApiController::class, 'list']); -Route::post('users', [UserApiController::class, 'create']); -Route::get('users/{id}', [UserApiController::class, 'read']); -Route::put('users/{id}', [UserApiController::class, 'update']); -Route::delete('users/{id}', [UserApiController::class, 'delete']); +Route::get('recycle-bin', [EntityControllers\RecycleBinApiController::class, 'list']); +Route::put('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'restore']); +Route::delete('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'destroy']); Route::get('roles', [RoleApiController::class, 'list']); Route::post('roles', [RoleApiController::class, 'create']); @@ -83,9 +106,15 @@ Route::put('roles/{id}', [RoleApiController::class, 'update']); Route::delete('roles/{id}', [RoleApiController::class, 'delete']); -Route::get('recycle-bin', [EntityControllers\RecycleBinApiController::class, 'list']); -Route::put('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'restore']); -Route::delete('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'destroy']); +Route::get('search', [SearchApiController::class, 'all']); -Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'read']); -Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']); +Route::get('system', [SystemApiController::class, 'read']); + +Route::get('tags/names', [TagApiController::class, 'listNames']); +Route::get('tags/values-for-name', [TagApiController::class, 'listValues']); + +Route::get('users', [UserApiController::class, 'list']); +Route::post('users', [UserApiController::class, 'create']); +Route::get('users/{id}', [UserApiController::class, 'read']); +Route::put('users/{id}', [UserApiController::class, 'update']); +Route::delete('users/{id}', [UserApiController::class, 'delete']); diff --git a/routes/web.php b/routes/web.php index 4620cd08bc3..3e3b5aef37d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,22 +5,29 @@ use BookStack\Api\ApiDocsController; use BookStack\Api\UserApiTokenController; use BookStack\App\HomeController; +use BookStack\App\MetaController; use BookStack\Entities\Controllers as EntityControllers; +use BookStack\Exports\Controllers as ExportControllers; use BookStack\Http\Middleware\VerifyCsrfToken; use BookStack\Permissions\PermissionsController; use BookStack\References\ReferenceController; use BookStack\Search\SearchController; use BookStack\Settings as SettingControllers; +use BookStack\Sorting as SortingControllers; +use BookStack\Theming\ThemeController; use BookStack\Uploads\Controllers as UploadControllers; use BookStack\Users\Controllers as UserControllers; use Illuminate\Session\Middleware\StartSession; use Illuminate\Support\Facades\Route; use Illuminate\View\Middleware\ShareErrorsFromSession; +// Status & Meta routes Route::get('/status', [SettingControllers\StatusController::class, 'show']); -Route::get('/robots.txt', [HomeController::class, 'robots']); -Route::get('/favicon.ico', [HomeController::class, 'favicon']); -Route::get('/manifest.json', [HomeController::class, 'pwaManifest']); +Route::get('/robots.txt', [MetaController::class, 'robots']); +Route::get('/favicon.ico', [MetaController::class, 'favicon']); +Route::get('/manifest.json', [MetaController::class, 'pwaManifest']); +Route::get('/licenses', [MetaController::class, 'licenses']); +Route::get('/opensearch.xml', [MetaController::class, 'opensearch']); // Authenticated routes... Route::middleware('auth')->group(function () { @@ -60,7 +67,7 @@ Route::get('/books/{slug}/edit', [EntityControllers\BookController::class, 'edit']); Route::put('/books/{slug}', [EntityControllers\BookController::class, 'update']); Route::delete('/books/{id}', [EntityControllers\BookController::class, 'destroy']); - Route::get('/books/{slug}/sort-item', [EntityControllers\BookSortController::class, 'showItem']); + Route::get('/books/{slug}/sort-item', [SortingControllers\BookSortController::class, 'showItem']); Route::get('/books/{slug}', [EntityControllers\BookController::class, 'show']); Route::get('/books/{bookSlug}/permissions', [PermissionsController::class, 'showForBook']); Route::put('/books/{bookSlug}/permissions', [PermissionsController::class, 'updateForBook']); @@ -68,14 +75,14 @@ Route::get('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'showCopy']); Route::post('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'copy']); Route::post('/books/{bookSlug}/convert-to-shelf', [EntityControllers\BookController::class, 'convertToShelf']); - Route::get('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'show']); - Route::put('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'update']); + Route::get('/books/{bookSlug}/sort', [SortingControllers\BookSortController::class, 'show']); + Route::put('/books/{bookSlug}/sort', [SortingControllers\BookSortController::class, 'update']); Route::get('/books/{slug}/references', [ReferenceController::class, 'book']); - Route::get('/books/{bookSlug}/export/html', [EntityControllers\BookExportController::class, 'html']); - Route::get('/books/{bookSlug}/export/pdf', [EntityControllers\BookExportController::class, 'pdf']); - Route::get('/books/{bookSlug}/export/markdown', [EntityControllers\BookExportController::class, 'markdown']); - Route::get('/books/{bookSlug}/export/zip', [EntityControllers\BookExportController::class, 'zip']); - Route::get('/books/{bookSlug}/export/plaintext', [EntityControllers\BookExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']); + Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']); + Route::get('/books/{bookSlug}/export/markdown', [ExportControllers\BookExportController::class, 'markdown']); + Route::get('/books/{bookSlug}/export/zip', [ExportControllers\BookExportController::class, 'zip']); + Route::get('/books/{bookSlug}/export/plaintext', [ExportControllers\BookExportController::class, 'plainText']); // Pages Route::get('/books/{bookSlug}/create-page', [EntityControllers\PageController::class, 'create']); @@ -83,10 +90,11 @@ Route::get('/books/{bookSlug}/draft/{pageId}', [EntityControllers\PageController::class, 'editDraft']); Route::post('/books/{bookSlug}/draft/{pageId}', [EntityControllers\PageController::class, 'store']); Route::get('/books/{bookSlug}/page/{pageSlug}', [EntityControllers\PageController::class, 'show']); - Route::get('/books/{bookSlug}/page/{pageSlug}/export/pdf', [EntityControllers\PageExportController::class, 'pdf']); - Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [EntityControllers\PageExportController::class, 'html']); - Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [EntityControllers\PageExportController::class, 'markdown']); - Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [EntityControllers\PageExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/page/{pageSlug}/export/pdf', [ExportControllers\PageExportController::class, 'pdf']); + Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [ExportControllers\PageExportController::class, 'html']); + Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [ExportControllers\PageExportController::class, 'markdown']); + Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [ExportControllers\PageExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/page/{pageSlug}/export/zip', [ExportControllers\PageExportController::class, 'zip']); Route::get('/books/{bookSlug}/page/{pageSlug}/edit', [EntityControllers\PageController::class, 'edit']); Route::get('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'showMove']); Route::put('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'move']); @@ -123,10 +131,11 @@ Route::get('/books/{bookSlug}/chapter/{chapterSlug}/edit', [EntityControllers\ChapterController::class, 'edit']); Route::post('/books/{bookSlug}/chapter/{chapterSlug}/convert-to-book', [EntityControllers\ChapterController::class, 'convertToBook']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'showForChapter']); - Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [EntityControllers\ChapterExportController::class, 'pdf']); - Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [EntityControllers\ChapterExportController::class, 'html']); - Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [EntityControllers\ChapterExportController::class, 'markdown']); - Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [EntityControllers\ChapterExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [ExportControllers\ChapterExportController::class, 'pdf']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ExportControllers\ChapterExportController::class, 'html']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ExportControllers\ChapterExportController::class, 'markdown']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ExportControllers\ChapterExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/zip', [ExportControllers\ChapterExportController::class, 'zip']); Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'updateForChapter']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/references', [ReferenceController::class, 'chapter']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [EntityControllers\ChapterController::class, 'showDelete']); @@ -170,6 +179,8 @@ // Comments Route::post('/comment/{pageId}', [ActivityControllers\CommentController::class, 'savePageComment']); + Route::put('/comment/{id}/archive', [ActivityControllers\CommentController::class, 'archive']); + Route::put('/comment/{id}/unarchive', [ActivityControllers\CommentController::class, 'unarchive']); Route::put('/comment/{id}', [ActivityControllers\CommentController::class, 'update']); Route::delete('/comment/{id}', [ActivityControllers\CommentController::class, 'destroy']); @@ -187,6 +198,7 @@ // User Search Route::get('/search/users/select', [UserControllers\UserSearchController::class, 'forSelect']); + Route::get('/search/users/mention', [UserControllers\UserSearchController::class, 'forMentions']); // Template System Route::get('/templates', [EntityControllers\PageTemplateController::class, 'list']); @@ -200,6 +212,13 @@ // Watching Route::put('/watching/update', [ActivityControllers\WatchController::class, 'update']); + // Importing + Route::get('/import', [ExportControllers\ImportController::class, 'start']); + Route::post('/import', [ExportControllers\ImportController::class, 'upload']); + Route::get('/import/{id}', [ExportControllers\ImportController::class, 'show']); + Route::post('/import/{id}', [ExportControllers\ImportController::class, 'run']); + Route::delete('/import/{id}', [ExportControllers\ImportController::class, 'delete']); + // Other Pages Route::get('/', [HomeController::class, 'index']); Route::get('/home', [HomeController::class, 'index']); @@ -232,6 +251,7 @@ Route::get('/settings/users/{id}', [UserControllers\UserController::class, 'edit']); Route::put('/settings/users/{id}', [UserControllers\UserController::class, 'update']); Route::delete('/settings/users/{id}', [UserControllers\UserController::class, 'destroy']); + Route::delete('/settings/users/{id}/mfa', [UserControllers\UserController::class, 'resetMfa']); // User Account Route::get('/my-account', [UserControllers\UserAccountController::class, 'redirect']); @@ -279,6 +299,13 @@ Route::get('/settings/webhooks/{id}/delete', [ActivityControllers\WebhookController::class, 'delete']); Route::delete('/settings/webhooks/{id}', [ActivityControllers\WebhookController::class, 'destroy']); + // Sort Rules + Route::get('/settings/sorting/rules/new', [SortingControllers\SortRuleController::class, 'create']); + Route::post('/settings/sorting/rules', [SortingControllers\SortRuleController::class, 'store']); + Route::get('/settings/sorting/rules/{id}', [SortingControllers\SortRuleController::class, 'edit']); + Route::put('/settings/sorting/rules/{id}', [SortingControllers\SortRuleController::class, 'update']); + Route::delete('/settings/sorting/rules/{id}', [SortingControllers\SortRuleController::class, 'destroy']); + // Settings Route::get('/settings', [SettingControllers\SettingController::class, 'index'])->name('settings'); Route::get('/settings/{category}', [SettingControllers\SettingController::class, 'category'])->name('settings.category'); @@ -315,8 +342,8 @@ Route::get('/register/confirm/awaiting', [AccessControllers\ConfirmEmailController::class, 'showAwaiting']); Route::post('/register/confirm/resend', [AccessControllers\ConfirmEmailController::class, 'resend']); Route::get('/register/confirm/{token}', [AccessControllers\ConfirmEmailController::class, 'showAcceptForm']); -Route::post('/register/confirm/accept', [AccessControllers\ConfirmEmailController::class, 'confirm']); -Route::post('/register', [AccessControllers\RegisterController::class, 'postRegister']); +Route::post('/register/confirm/accept', [AccessControllers\ConfirmEmailController::class, 'confirm'])->middleware('throttle:public'); +Route::post('/register', [AccessControllers\RegisterController::class, 'postRegister'])->middleware('throttle:public'); // SAML routes Route::post('/saml2/login', [AccessControllers\Saml2Controller::class, 'login']); @@ -336,18 +363,23 @@ Route::post('/oidc/logout', [AccessControllers\OidcController::class, 'logout']); // User invitation routes -Route::get('/register/invite/{token}', [AccessControllers\UserInviteController::class, 'showSetPassword']); -Route::post('/register/invite/{token}', [AccessControllers\UserInviteController::class, 'setPassword']); +Route::get('/register/invite/{token}', [AccessControllers\UserInviteController::class, 'showSetPassword'])->middleware('throttle:public'); +Route::post('/register/invite/{token}', [AccessControllers\UserInviteController::class, 'setPassword'])->middleware('throttle:public'); // Password reset link request routes Route::get('/password/email', [AccessControllers\ForgotPasswordController::class, 'showLinkRequestForm']); -Route::post('/password/email', [AccessControllers\ForgotPasswordController::class, 'sendResetLinkEmail']); +Route::post('/password/email', [AccessControllers\ForgotPasswordController::class, 'sendResetLinkEmail'])->middleware('throttle:public'); // Password reset routes Route::get('/password/reset/{token}', [AccessControllers\ResetPasswordController::class, 'showResetForm']); -Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset']); +Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public'); -// Metadata routes +// Help & Info routes +Route::view('/help/tinymce', 'help.tinymce'); Route::view('/help/wysiwyg', 'help.wysiwyg'); -Route::fallback([HomeController::class, 'notFound'])->name('fallback'); +// Theme Routes +Route::get('/theme/{theme}/{path}', [ThemeController::class, 'publicFile']) + ->where('path', '.*$'); + +Route::fallback([MetaController::class, 'notFound'])->name('fallback'); diff --git a/storage/fonts/.gitignore b/storage/fonts/.gitignore index c96a04f008e..cb0b47dace2 100755 --- a/storage/fonts/.gitignore +++ b/storage/fonts/.gitignore @@ -1,2 +1,6 @@ +# Font cache files have once been stored directly in this folder +# therefore its important the contents non-ignored by git +# are chosen selectively * -!.gitignore \ No newline at end of file +!.gitignore +!dompdf/ \ No newline at end of file diff --git a/storage/fonts/dompdf/.gitignore b/storage/fonts/dompdf/.gitignore new file mode 100644 index 00000000000..23ef65311b4 --- /dev/null +++ b/storage/fonts/dompdf/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!cache/ \ No newline at end of file diff --git a/storage/fonts/dompdf/cache/.gitignore b/storage/fonts/dompdf/cache/.gitignore new file mode 100644 index 00000000000..c96a04f008e --- /dev/null +++ b/storage/fonts/dompdf/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore index 05c4471f2b5..8d89041780c 100755 --- a/storage/framework/.gitignore +++ b/storage/framework/.gitignore @@ -7,3 +7,4 @@ routes.php routes.scanned.php schedule-* services.json +purifier/ diff --git a/tests/Activity/AuditLogApiTest.php b/tests/Activity/AuditLogApiTest.php new file mode 100644 index 00000000000..2212ae5991a --- /dev/null +++ b/tests/Activity/AuditLogApiTest.php @@ -0,0 +1,60 @@ +users->editor(); + + $assertPermissionErrorOnCall = function () use ($editor) { + $resp = $this->actingAsForApi($editor)->getJson('/api/audit-log'); + $resp->assertStatus(403); + $resp->assertJson($this->permissionErrorResponse()); + }; + + $assertPermissionErrorOnCall(); + $this->permissions->grantUserRolePermissions($editor, ['users-manage']); + $assertPermissionErrorOnCall(); + $this->permissions->removeUserRolePermissions($editor, ['users-manage']); + $this->permissions->grantUserRolePermissions($editor, ['settings-manage']); + $assertPermissionErrorOnCall(); + + $this->permissions->grantUserRolePermissions($editor, ['settings-manage', 'users-manage']); + $resp = $this->actingAsForApi($editor)->getJson('/api/audit-log'); + $resp->assertOk(); + } + + public function test_index_endpoint_returns_expected_data() + { + $page = $this->entities->page(); + $admin = $this->users->admin(); + $this->actingAsForApi($admin); + Activity::add(ActivityType::PAGE_UPDATE, $page); + + $resp = $this->get("/api/audit-log?filter[loggable_id]={$page->id}"); + $resp->assertJson(['data' => [ + [ + 'type' => 'page_update', + 'detail' => "({$page->id}) {$page->name}", + 'user_id' => $admin->id, + 'loggable_id' => $page->id, + 'loggable_type' => 'page', + 'ip' => '127.0.0.1', + 'user' => [ + 'id' => $admin->id, + 'name' => $admin->name, + 'slug' => $admin->slug, + ], + ] + ]]); + } +} diff --git a/tests/Actions/AuditLogTest.php b/tests/Activity/AuditLogTest.php similarity index 91% rename from tests/Actions/AuditLogTest.php rename to tests/Activity/AuditLogTest.php index 5e355ca096b..a6ba6be9f62 100644 --- a/tests/Actions/AuditLogTest.php +++ b/tests/Activity/AuditLogTest.php @@ -1,6 +1,6 @@ assertSeeText("[ID: {$viewer->id}] Deleted User"); } + public function test_deleted_user_shows_if_user_created_date_is_later_than_activity() + { + $viewer = $this->users->viewer(); + $this->actingAs($viewer); + $page = $this->entities->page(); + $this->activityService->add(ActivityType::PAGE_CREATE, $page); + $viewer->created_at = Carbon::now()->addDay(); + $viewer->save(); + + $this->actingAs($this->users->admin()); + + $resp = $this->get('settings/audit'); + $resp->assertSeeText("[ID: {$viewer->id}] Deleted User"); + $resp->assertDontSee($viewer->name); + } + public function test_filters_by_key() { $this->actingAs($this->users->admin()); @@ -156,7 +172,7 @@ public function test_ip_address_logged_and_visible() 'type' => ActivityType::PAGE_UPDATE, 'ip' => '192.123.45.1', 'user_id' => $editor->id, - 'entity_id' => $page->id, + 'loggable_id' => $page->id, ]); $resp = $this->asAdmin()->get('/settings/audit'); @@ -207,7 +223,7 @@ public function test_ip_address_not_logged_in_demo_mode() 'type' => ActivityType::PAGE_UPDATE, 'ip' => '127.0.0.1', 'user_id' => $editor->id, - 'entity_id' => $page->id, + 'loggable_id' => $page->id, ]); } @@ -229,7 +245,7 @@ public function test_ip_address_respects_precision_setting() 'type' => ActivityType::PAGE_UPDATE, 'ip' => '192.123.x.x', 'user_id' => $editor->id, - 'entity_id' => $page->id, + 'loggable_id' => $page->id, ]); } } diff --git a/tests/Activity/CommentDisplayTest.php b/tests/Activity/CommentDisplayTest.php new file mode 100644 index 00000000000..798ea16eef4 --- /dev/null +++ b/tests/Activity/CommentDisplayTest.php @@ -0,0 +1,129 @@ +asAdmin(); + $page = $this->entities->page(); + + $this->postJson("/comment/$page->id", ['html' => '

    My new comment

    ']); + $this->postJson("/comment/$page->id", ['html' => '

    My new comment

    ']); + + $respHtml = $this->withHtml($this->get($page->getUrl())); + $respHtml->assertElementCount('.comment-branch', 3); + $respHtml->assertElementNotExists('.comment-branch .comment-branch'); + + $comment = $page->comments()->first(); + $resp = $this->postJson("/comment/$page->id", [ + 'html' => '

    My nested comment

    ', 'parent_id' => $comment->local_id + ]); + $resp->assertStatus(200); + + $respHtml = $this->withHtml($this->get($page->getUrl())); + $respHtml->assertElementCount('.comment-branch', 4); + $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment'); + } + + public function test_comments_are_visible_in_the_page_editor() + { + $page = $this->entities->page(); + + $this->asAdmin()->postJson("/comment/$page->id", ['html' => '

    My great comment to see in the editor

    ']); + + $respHtml = $this->withHtml($this->get($page->getUrl('/edit'))); + $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor'); + } + + public function test_comment_creator_name_truncated() + { + [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']); + $page = $this->entities->page(); + + $comment = Comment::factory()->make(); + $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes()); + + $pageResp = $this->asAdmin()->get($page->getUrl()); + $pageResp->assertSee('Wolfeschlegels…'); + } + + public function test_comment_editor_js_loaded_with_create_or_edit_permissions() + { + $editor = $this->users->editor(); + $page = $this->entities->page(); + + $resp = $this->actingAs($editor)->get($page->getUrl()); + $resp->assertSee('window.editor_translations', false); + $resp->assertSee('component="entity-selector"', false); + + $this->permissions->removeUserRolePermissions($editor, ['comment-create-all']); + $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']); + + $resp = $this->actingAs($editor)->get($page->getUrl()); + $resp->assertDontSee('window.editor_translations', false); + $resp->assertDontSee('component="entity-selector"', false); + + Comment::factory()->create([ + 'created_by' => $editor->id, + 'commentable_type' => 'page', + 'commentable_id' => $page->id, + ]); + + $resp = $this->actingAs($editor)->get($page->getUrl()); + $resp->assertSee('window.editor_translations', false); + $resp->assertSee('component="entity-selector"', false); + } + + public function test_comment_displays_relative_times() + { + $page = $this->entities->page(); + $comment = Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => $page->getMorphClass()]); + $comment->created_at = now()->subWeek(); + $comment->updated_at = now()->subDay(); + $comment->save(); + + $pageResp = $this->asAdmin()->get($page->getUrl()); + $html = $this->withHtml($pageResp); + + // Create date shows relative time as text to user + $html->assertElementContains('.comment-box', 'commented 1 week ago'); + // Updated indicator has full time as title + $html->assertElementContains('.comment-box span[title^="Updated ' . $comment->updated_at->format('Y-m-d') . '"]', 'Updated'); + } + + public function test_comment_displays_reference_if_set() + { + $page = $this->entities->page(); + $comment = Comment::factory()->make([ + 'content_ref' => 'bkmrk-a:abc:4-1', + 'local_id' => 10, + ]); + $page->comments()->save($comment); + + $html = $this->withHtml($this->asEditor()->get($page->getUrl())); + $html->assertElementExists('#comment10 .comment-reference-indicator-wrap a'); + } + + public function test_archived_comments_are_shown_in_their_own_container() + { + $page = $this->entities->page(); + $comment = Comment::factory()->make(['local_id' => 44]); + $page->comments()->save($comment); + + $html = $this->withHtml($this->asEditor()->get($page->getUrl())); + $html->assertElementExists('#comment-tab-panel-active #comment44'); + $html->assertElementNotExists('#comment-tab-panel-archived .comment-box'); + + $comment->archived = true; + $comment->save(); + + $html = $this->withHtml($this->asEditor()->get($page->getUrl())); + $html->assertElementExists('#comment-tab-panel-archived #comment44.comment-box'); + $html->assertElementNotExists('#comment-tab-panel-active #comment44'); + } +} diff --git a/tests/Activity/CommentMentionTest.php b/tests/Activity/CommentMentionTest.php new file mode 100644 index 00000000000..b9701b11577 --- /dev/null +++ b/tests/Activity/CommentMentionTest.php @@ -0,0 +1,129 @@ +users->viewer(); + $this->permissions->grantUserRolePermissions($userToMention, [Permission::ReceiveNotifications]); + $editor = $this->users->editor(); + $page = $this->entities->pageWithinChapter(); + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$page->id}", [ + 'html' => '

    Hello @user

    ' + ])->assertOk(); + + $notifications->assertSentTo($userToMention, function (CommentMentionNotification $notification) use ($userToMention, $editor, $page) { + $mail = $notification->toMail($userToMention); + $mailContent = html_entity_decode(strip_tags($mail->render()), ENT_QUOTES); + $subjectPrefix = 'You have been mentioned in a comment on page: ' . mb_substr($page->name, 0, 20); + return str_starts_with($mail->subject, $subjectPrefix) + && str_contains($mailContent, 'View Comment') + && str_contains($mailContent, 'Page Name: ' . $page->name) + && str_contains($mailContent, 'Page Path: ' . $page->book->getShortName(24) . ' > ' . $page->chapter->getShortName(24)) + && str_contains($mailContent, 'Commenter: ' . $editor->name) + && str_contains($mailContent, 'Comment: Hello @user'); + }); + } + + public function test_mentions_are_not_notified_if_mentioned_by_same_user() + { + $editor = $this->users->editor(); + $this->permissions->grantUserRolePermissions($editor, [Permission::ReceiveNotifications]); + $page = $this->entities->page(); + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$page->id}", [ + 'html' => '

    Hello

    ' + ])->assertOk(); + + $notifications->assertNothingSent(); + } + + public function test_mentions_are_logged_to_the_database_even_if_not_notified() + { + $editor = $this->users->editor(); + $otherUser = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($editor, [Permission::ReceiveNotifications]); + $page = $this->entities->page(); + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$page->id}", [ + 'html' => '

    Hello and

    ' + ])->assertOk(); + + $notifications->assertNothingSent(); + + $comment = $page->comments()->latest()->first(); + $this->assertDatabaseHas('mention_history', [ + 'mentionable_id' => $comment->id, + 'mentionable_type' => 'comment', + 'from_user_id' => $editor->id, + 'to_user_id' => $otherUser->id, + ]); + $this->assertDatabaseHas('mention_history', [ + 'mentionable_id' => $comment->id, + 'mentionable_type' => 'comment', + 'from_user_id' => $editor->id, + 'to_user_id' => $editor->id, + ]); + } + + public function test_comment_updates_will_send_notifications_only_if_mention_is_new() + { + $userToMention = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($userToMention, [Permission::ReceiveNotifications]); + $editor = $this->users->editor(); + $this->permissions->grantUserRolePermissions($editor, [Permission::CommentUpdateOwn]); + $page = $this->entities->page(); + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$page->id}", [ + 'html' => '

    Hello there

    ' + ])->assertOk(); + $comment = $page->comments()->latest()->first(); + + $notifications->assertNothingSent(); + + $this->put("/comment/{$comment->id}", [ + 'html' => '

    Hello

    ' + ])->assertOk(); + + $notifications->assertSentTo($userToMention, CommentMentionNotification::class); + $notifications->assertCount(1); + + $this->put("/comment/{$comment->id}", [ + 'html' => '

    Hello again

    ' + ])->assertOk(); + + $notifications->assertCount(1); + } + + public function test_notification_limited_to_those_with_view_permissions() + { + $userA = $this->users->newUser(); + $userB = $this->users->newUser(); + $this->permissions->grantUserRolePermissions($userA, [Permission::ReceiveNotifications]); + $this->permissions->grantUserRolePermissions($userB, [Permission::ReceiveNotifications]); + $notifications = Notification::fake(); + $page = $this->entities->page(); + + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $userA->roles()->first()); + + $this->asAdmin()->post("/comment/{$page->id}", [ + 'html' => '

    Hello and

    ' + ])->assertOk(); + + $notifications->assertCount(1); + $notifications->assertSentTo($userA, CommentMentionNotification::class); + } +} diff --git a/tests/Entity/CommentSettingTest.php b/tests/Activity/CommentSettingTest.php similarity index 96% rename from tests/Entity/CommentSettingTest.php rename to tests/Activity/CommentSettingTest.php index 7de4574414f..f8210114c46 100644 --- a/tests/Entity/CommentSettingTest.php +++ b/tests/Activity/CommentSettingTest.php @@ -1,6 +1,6 @@ asAdmin(); + $page = $this->entities->page(); + + Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => 'page', 'local_id' => 2]); + $comment = Comment::factory()->make(['parent_id' => 2]); + $resp = $this->postJson("/comment/$page->id", $comment->getAttributes()); + + $resp->assertStatus(200); + $resp->assertSee($comment->html, false); + + $pageResp = $this->get($page->getUrl()); + $pageResp->assertSee($comment->html, false); + + $this->assertDatabaseHas('comments', [ + 'local_id' => 3, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'parent_id' => 2, + ]); + + $this->assertActivityExists(ActivityType::COMMENT_CREATE); + } + public function test_add_comment_stores_content_reference_only_if_format_valid() + { + $validityByRefs = [ + 'bkmrk-my-title:4589284922:4-3' => true, + 'bkmrk-my-title:4589284922:' => true, + 'bkmrk-my-title:4589284922:abc' => false, + 'my-title:4589284922:' => false, + 'bkmrk-my-title-4589284922:' => false, + ]; + + $page = $this->entities->page(); + + foreach ($validityByRefs as $ref => $valid) { + $this->asAdmin()->postJson("/comment/$page->id", [ + 'html' => '

    My comment

    ', + 'parent_id' => null, + 'content_ref' => $ref, + ]); + + if ($valid) { + $this->assertDatabaseHas('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]); + } else { + $this->assertDatabaseMissing('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]); + } + } + } + + public function test_comment_edit() + { + $this->asAdmin(); + $page = $this->entities->page(); + + $comment = Comment::factory()->make(); + $this->postJson("/comment/$page->id", $comment->getAttributes()); + + $comment = $page->comments()->first(); + $newHtml = '

    updated text content

    '; + $resp = $this->putJson("/comment/$comment->id", [ + 'html' => $newHtml, + ]); + + $resp->assertStatus(200); + $resp->assertSee($newHtml, false); + $resp->assertDontSee($comment->html, false); + + $this->assertDatabaseHas('comments', [ + 'html' => $newHtml, + 'commentable_id' => $page->id, + ]); + + $this->assertActivityExists(ActivityType::COMMENT_UPDATE); + } + + public function test_comment_delete() + { + $this->asAdmin(); + $page = $this->entities->page(); + + $comment = Comment::factory()->make(); + $this->postJson("/comment/$page->id", $comment->getAttributes()); + + $comment = $page->comments()->first(); + + $resp = $this->delete("/comment/$comment->id"); + $resp->assertStatus(200); + + $this->assertDatabaseMissing('comments', [ + 'id' => $comment->id, + ]); + + $this->assertActivityExists(ActivityType::COMMENT_DELETE); + } + + public function test_comment_delete_requires_view_permission_to_page() + { + $editor = $this->users->editor(); + $this->permissions->grantUserRolePermissions($editor, ['comment-delete-all']); + $page = $this->entities->page(); + $this->actingAs($editor); + + $commentData = Comment::factory()->make(); + $this->postJson("/comment/$page->id", $commentData->getAttributes()); + $comment = $page->comments()->first(); + $this->permissions->disableEntityInheritedPermissions($page); + + $resp = $this->deleteJson("/comment/$comment->id"); + $resp->assertStatus(404); + $this->assertDatabaseHas('comments', ['id' => $comment->id]); + } + + public function test_comment_archive_and_unarchive() + { + $this->asAdmin(); + $page = $this->entities->page(); + + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $comment->refresh(); + + $this->put("/comment/$comment->id/archive"); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'archived' => true, + ]); + + $this->assertActivityExists(ActivityType::COMMENT_UPDATE); + + $this->put("/comment/$comment->id/unarchive"); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'archived' => false, + ]); + + $this->assertActivityExists(ActivityType::COMMENT_UPDATE); + } + + public function test_archive_endpoints_require_delete_or_edit_permissions() + { + $viewer = $this->users->viewer(); + $page = $this->entities->page(); + + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $comment->refresh(); + + $endpoints = ["/comment/$comment->id/archive", "/comment/$comment->id/unarchive"]; + + foreach ($endpoints as $endpoint) { + $resp = $this->actingAs($viewer)->put($endpoint); + $this->assertPermissionError($resp); + } + + $this->permissions->grantUserRolePermissions($viewer, ['comment-delete-all']); + + foreach ($endpoints as $endpoint) { + $resp = $this->actingAs($viewer)->put($endpoint); + $resp->assertOk(); + } + + $this->permissions->removeUserRolePermissions($viewer, ['comment-delete-all']); + $this->permissions->grantUserRolePermissions($viewer, ['comment-update-all']); + + foreach ($endpoints as $endpoint) { + $resp = $this->actingAs($viewer)->put($endpoint); + $resp->assertOk(); + } + } + + public function test_non_top_level_comments_cant_be_archived_or_unarchived() + { + $this->asAdmin(); + $page = $this->entities->page(); + + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $subComment = Comment::factory()->make(['parent_id' => $comment->id]); + $page->comments()->save($subComment); + $subComment->refresh(); + + $resp = $this->putJson("/comment/$subComment->id/archive"); + $resp->assertStatus(400); + + $this->assertDatabaseHas('comments', [ + 'id' => $subComment->id, + 'archived' => false, + ]); + + $resp = $this->putJson("/comment/$subComment->id/unarchive"); + $resp->assertStatus(400); + } + + public function test_scripts_cannot_be_injected_via_comment_html() + { + $page = $this->entities->page(); + + $script = '

    My lovely comment

    '; + $this->asAdmin()->postJson("/comment/$page->id", [ + 'html' => $script, + ]); + + $pageView = $this->get($page->getUrl()); + $pageView->assertDontSee($script, false); + $pageView->assertDontSee('sneakyscript', false); + $pageView->assertSee('

    My lovely comment

    ', false); + + $comment = $page->comments()->first(); + $this->putJson("/comment/$comment->id", [ + 'html' => $script . '

    updated

    ', + ]); + + $pageView = $this->get($page->getUrl()); + $pageView->assertDontSee($script, false); + $pageView->assertDontSee('sneakyscript', false); + $pageView->assertSee('

    My lovely comment

    updated

    '); + } + + public function test_scripts_are_removed_even_if_already_in_db() + { + $page = $this->entities->page(); + Comment::factory()->create([ + 'html' => '

    scriptincommentest

    ', + 'commentable_type' => 'page', 'commentable_id' => $page + ]); + + $resp = $this->asAdmin()->get($page->getUrl()); + $resp->assertSee('scriptincommentest', false); + $resp->assertDontSee('superbadscript', false); + $resp->assertDontSee('superbadonclick', false); + } + + public function test_comment_html_is_limited() + { + $page = $this->entities->page(); + $input = '

    Test

    Contenta

    Hello
    there

    '; + $expected = '

    Contenta

    '; + + $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); + $resp->assertOk(); + $this->assertDatabaseHas('comments', [ + 'commentable_type' => 'page', + 'commentable_id' => $page->id, + 'html' => $expected, + ]); + + $comment = $page->comments()->first(); + $resp = $this->put("/comment/{$comment->id}", ['html' => $input]); + $resp->assertOk(); + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => $expected, + ]); + } + + public function test_comment_html_spans_are_cleaned() + { + $page = $this->entities->page(); + $input = '

    Hello do you have biscuits?

    '; + $expected = '

    Hello do you have biscuits?

    '; + + $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); + $resp->assertOk(); + $this->assertDatabaseHas('comments', [ + 'commentable_type' => 'page', + 'commentable_id' => $page->id, + 'html' => $expected, + ]); + + $comment = $page->comments()->first(); + $resp = $this->put("/comment/{$comment->id}", ['html' => $input]); + $resp->assertOk(); + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => $expected, + ]); + } +} diff --git a/tests/Activity/CommentsApiTest.php b/tests/Activity/CommentsApiTest.php new file mode 100644 index 00000000000..51009da3952 --- /dev/null +++ b/tests/Activity/CommentsApiTest.php @@ -0,0 +1,250 @@ +users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll, Permission::CommentUpdateAll]); + + $page = $this->entities->page(); + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $this->actingAsForApi($user); + + $actions = [ + ['GET', '/api/comments'], + ['GET', "/api/comments/{$comment->id}"], + ['POST', "/api/comments"], + ['PUT', "/api/comments/{$comment->id}"], + ['DELETE', "/api/comments/{$comment->id}"], + ]; + + foreach ($actions as [$method, $endpoint]) { + $resp = $this->call($method, $endpoint); + $this->assertNotPermissionError($resp); + } + + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $this->getJson("/api/comments")->assertSee(['id' => $comment->id]); + + $this->permissions->removeUserRolePermissions($user, [ + Permission::CommentDeleteAll, Permission::CommentDeleteOwn, + Permission::CommentUpdateAll, Permission::CommentUpdateOwn, + Permission::CommentCreateAll + ]); + + $this->assertPermissionError($this->json('delete', "/api/comments/{$comment->id}")); + $this->assertPermissionError($this->json('put', "/api/comments/{$comment->id}")); + $this->assertPermissionError($this->json('post', "/api/comments")); + $this->assertNotPermissionError($this->json('get', "/api/comments/{$comment->id}")); + + $this->permissions->disableEntityInheritedPermissions($page); + $this->json('get', "/api/comments/{$comment->id}")->assertStatus(404); + $this->getJson("/api/comments")->assertDontSee(['id' => $comment->id]); + } + + public function test_index() + { + $page = $this->entities->page(); + Comment::query()->delete(); + + $comments = Comment::factory()->count(10)->make(); + $page->comments()->saveMany($comments); + + $firstComment = $comments->first(); + $resp = $this->actingAsApiEditor()->getJson('/api/comments'); + $resp->assertJson([ + 'data' => [ + [ + 'id' => $firstComment->id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'parent_id' => null, + 'local_id' => $firstComment->local_id, + ], + ], + ]); + $resp->assertJsonCount(10, 'data'); + $resp->assertJson(['total' => 10]); + + $filtered = $this->getJson("/api/comments?filter[id]={$firstComment->id}"); + $filtered->assertJsonCount(1, 'data'); + $filtered->assertJson(['total' => 1]); + } + + public function test_create() + { + $page = $this->entities->page(); + + $resp = $this->actingAsApiEditor()->postJson('/api/comments', [ + 'page_id' => $page->id, + 'html' => '

    My wonderful comment

    ', + 'content_ref' => 'test-content-ref', + ]); + $resp->assertOk(); + $id = $resp->json('id'); + + $this->assertDatabaseHas('comments', [ + 'id' => $id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

    My wonderful comment

    ', + ]); + + $comment = Comment::query()->findOrFail($id); + $this->assertIsInt($comment->local_id); + + $reply = $this->actingAsApiEditor()->postJson('/api/comments', [ + 'page_id' => $page->id, + 'html' => '

    My wonderful reply

    ', + 'content_ref' => 'test-content-ref', + 'reply_to' => $comment->local_id, + ]); + $reply->assertOk(); + + $this->assertDatabaseHas('comments', [ + 'id' => $reply->json('id'), + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

    My wonderful reply

    ', + 'parent_id' => $comment->local_id, + ]); + } + + public function test_read() + { + $page = $this->entities->page(); + $user = $this->users->viewer(); + $comment = Comment::factory()->make([ + 'html' => '

    A lovely comment

    ', + 'created_by' => $user->id, + 'updated_by' => $user->id, + ]); + $page->comments()->save($comment); + $comment->refresh(); + $reply = Comment::factory()->make([ + 'parent_id' => $comment->local_id, + 'html' => '

    A lovelyreply

    ', + ]); + $page->comments()->save($reply); + + $resp = $this->actingAsApiEditor()->getJson("/api/comments/{$comment->id}"); + $resp->assertJson([ + 'id' => $comment->id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

    A lovely comment

    ', + 'archived' => false, + 'created_by' => [ + 'id' => $user->id, + 'name' => $user->name, + ], + 'updated_by' => [ + 'id' => $user->id, + 'name' => $user->name, + ], + 'replies' => [ + [ + 'id' => $reply->id, + 'html' => '

    A lovelyreply

    ' + ] + ] + ]); + } + + public function test_update() + { + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]); + $comment = Comment::factory()->make([ + 'html' => '

    A lovely comment

    ', + 'created_by' => $this->users->viewer()->id, + 'updated_by' => $this->users->viewer()->id, + 'parent_id' => null, + ]); + $page->comments()->save($comment); + + $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [ + 'html' => '

    A lovely updated comment

    ', + ])->assertOk(); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

    A lovely updated comment

    ', + 'archived' => 0, + ]); + + $this->putJson("/api/comments/{$comment->id}", [ + 'archived' => true, + ]); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

    A lovely updated comment

    ', + 'archived' => 1, + ]); + + $this->putJson("/api/comments/{$comment->id}", [ + 'archived' => false, + 'html' => '

    A lovely updated again comment

    ', + ]); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

    A lovely updated again comment

    ', + 'archived' => 0, + ]); + } + + public function test_update_cannot_archive_replies() + { + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]); + $comment = Comment::factory()->make([ + 'html' => '

    A lovely comment

    ', + 'created_by' => $this->users->viewer()->id, + 'updated_by' => $this->users->viewer()->id, + 'parent_id' => 90, + ]); + $page->comments()->save($comment); + + $resp = $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [ + 'archived' => true, + ]); + + $this->assertEquals($this->errorResponse('Only top-level comments can be archived.', 400), $resp->json()); + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'archived' => 0, + ]); + } + + public function test_destroy() + { + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll]); + $comment = Comment::factory()->make([ + 'html' => '

    A lovely comment

    ', + ]); + $page->comments()->save($comment); + + $this->actingAsForApi($user)->deleteJson("/api/comments/{$comment->id}")->assertStatus(204); + $this->assertDatabaseMissing('comments', [ + 'id' => $comment->id, + ]); + } +} diff --git a/tests/Activity/MentionParserTest.php b/tests/Activity/MentionParserTest.php new file mode 100644 index 00000000000..08bfc10d2a8 --- /dev/null +++ b/tests/Activity/MentionParserTest.php @@ -0,0 +1,43 @@ +Hello @User

    '; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([5], $result); + + // Test multiple mentions + $html = '

    @Alice and @Bob

    '; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([1, 2], $result); + + // Test filtering out invalid IDs (zero and negative) + $html = '

    @Invalid @Negative @Valid

    '; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([3], $result); + + // Test non-mention links are ignored + $html = '

    Normal Link @User

    '; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([7], $result); + + // Test empty HTML + $result = $parser->parseUserIdsFromHtml(''); + $this->assertEquals([], $result); + + // Test duplicate user IDs + $html = '

    @User mentioned @User again

    '; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([4], $result); + } +} diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php index 38935bbf5c9..dc78e894253 100644 --- a/tests/Activity/WatchTest.php +++ b/tests/Activity/WatchTest.php @@ -13,6 +13,8 @@ use BookStack\Activity\WatchLevels; use BookStack\Entities\Models\Entity; use BookStack\Settings\UserNotificationPreferences; +use Illuminate\Contracts\Notifications\Dispatcher; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Notification; use Tests\TestCase; @@ -196,7 +198,7 @@ public function test_notify_own_page_comments() $notifications = Notification::fake(); $this->asAdmin()->post("/comment/{$entities['page']->id}", [ - 'text' => 'My new comment' + 'html' => '

    My new comment

    ' ]); $notifications->assertSentTo($editor, CommentCreationNotification::class); } @@ -216,13 +218,13 @@ public function test_notify_comment_replies() $notifications = Notification::fake(); - $this->actingAs($editor)->post("/comment/{$entities['page']->id}", [ - 'text' => 'My new comment' + $resp = $this->actingAs($editor)->post("/comment/{$entities['page']->id}", [ + 'html' => '

    My new comment

    ' ]); - $comment = $entities['page']->comments()->orderBy('id', 'desc')->first(); + $comment = $entities['page']->comments()->reorder('id', 'desc')->first(); $this->asAdmin()->post("/comment/{$entities['page']->id}", [ - 'text' => 'My new comment response', + 'html' => '

    My new comment response

    ', 'parent_id' => $comment->local_id, ]); $notifications->assertSentTo($editor, CommentCreationNotification::class); @@ -257,7 +259,7 @@ public function test_notify_watch_parent_book_comments() // Comment post $this->actingAs($admin)->post("/comment/{$entities['page']->id}", [ - 'text' => 'My new comment response', + 'html' => '

    My new comment response

    ', ]); $notifications->assertSentTo($editor, function (CommentCreationNotification $notification) use ($editor, $admin, $entities) { @@ -325,6 +327,24 @@ public function test_notify_watch_parent_book_new() }); } + public function test_notify_watch_page_ignore_when_no_page_owner() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $entities['page']->owned_by = null; + $entities['page']->save(); + + $watches = new UserEntityWatchOptions($editor, $entities['page']); + $watches->updateLevelByValue(WatchLevels::IGNORE); + + $notifications = Notification::fake(); + $this->asAdmin(); + + $this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']); + + $notifications->assertNothingSent(); + } + public function test_notifications_sent_in_right_language() { $editor = $this->users->editor(); @@ -338,8 +358,8 @@ public function test_notifications_sent_in_right_language() ActivityType::PAGE_CREATE => $entities['page'], ActivityType::PAGE_UPDATE => $entities['page'], ActivityType::COMMENT_CREATE => Comment::factory()->make([ - 'entity_id' => $entities['page']->id, - 'entity_type' => $entities['page']->getMorphClass(), + 'commentable_id' => $entities['page']->id, + 'commentable_type' => $entities['page']->getMorphClass(), ]), ]; @@ -365,6 +385,29 @@ public function test_notifications_sent_in_right_language() } } + public function test_failed_notifications_dont_block_and_log_errors() + { + $logger = $this->withTestLogger(); + $editor = $this->users->editor(); + $admin = $this->users->admin(); + $page = $this->entities->page(); + $book = $page->book; + $activityLogger = app()->make(ActivityLogger::class); + + $watches = new UserEntityWatchOptions($editor, $book); + $watches->updateLevelByValue(WatchLevels::UPDATES); + + $mockDispatcher = $this->mock(Dispatcher::class); + $mockDispatcher->shouldReceive('send')->once() + ->andThrow(\Exception::class, 'Failed to connect to mail server'); + + $this->actingAs($admin); + + $activityLogger->add(ActivityType::PAGE_UPDATE, $page); + + $this->assertTrue($logger->hasErrorThatContains("Failed to send email notification to user [id:{$editor->id}] with error: Failed to connect to mail server")); + } + public function test_notifications_not_sent_if_lacking_view_permission_for_related_item() { $notifications = Notification::fake(); @@ -376,7 +419,7 @@ public function test_notifications_not_sent_if_lacking_view_permission_for_relat $this->permissions->disableEntityInheritedPermissions($page); $this->asAdmin()->post("/comment/{$page->id}", [ - 'text' => 'My new comment response', + 'html' => '

    My new comment response

    ', ])->assertOk(); $notifications->assertNothingSentTo($editor); diff --git a/tests/Actions/WebhookCallTest.php b/tests/Activity/WebhookCallTest.php similarity index 99% rename from tests/Actions/WebhookCallTest.php rename to tests/Activity/WebhookCallTest.php index 16986ba2e97..162e05ec505 100644 --- a/tests/Actions/WebhookCallTest.php +++ b/tests/Activity/WebhookCallTest.php @@ -1,6 +1,6 @@ actingAs($viewer, 'standard'); - $resp = $this->get($this->endpoint); + $this->startSession(); + $resp = $this->withCredentials()->get($this->endpoint); $resp->assertStatus(200); } @@ -75,6 +76,7 @@ public function test_api_access_permission_required_to_access_api_with_session_a { $editor = $this->users->editor(); $this->actingAs($editor, 'standard'); + $this->startSession(); $resp = $this->get($this->endpoint); $resp->assertStatus(200); @@ -112,6 +114,28 @@ public function test_access_prevented_for_guest_users_with_api_permission_while_ $resp->assertStatus(200); } + public function test_only_get_requests_are_supported_with_session_auth() + { + $user = $this->users->admin(); + $this->actingAs($user, 'standard'); + $this->startSession(); + + $uriByMethods = [ + 'POST' => '/books', + 'PUT' => '/books/1', + 'DELETE' => '/books/1', + 'HEAD' => '/books', + ]; + + foreach ($uriByMethods as $method => $uri) { + $resp = $this->withCredentials()->json($method, "/api{$uri}"); + $resp->assertStatus(403); + if ($method !== 'HEAD') { + $resp->assertJson($this->errorResponse('Only GET requests are allowed when using the API with cookie-based authentication', 403)); + } + } + } + public function test_token_expiry_checked() { $editor = $this->users->editor(); diff --git a/tests/Api/ApiDocsTest.php b/tests/Api/ApiDocsTest.php index a1603e0ef82..bdf753e87f7 100644 --- a/tests/Api/ApiDocsTest.php +++ b/tests/Api/ApiDocsTest.php @@ -22,7 +22,7 @@ public function test_docs_page_returns_view_with_docs_content() $resp->assertStatus(200); $resp->assertSee(url('/api/docs.json')); $resp->assertSee('Show a JSON view of the API docs data.'); - $resp->assertHeader('Content-Type', 'text/html; charset=UTF-8'); + $resp->assertHeader('Content-Type', 'text/html; charset=utf-8'); } public function test_docs_json_endpoint_returns_json() diff --git a/tests/Api/ApiErrorTest.php b/tests/Api/ApiErrorTest.php new file mode 100644 index 00000000000..d24d8fa12cf --- /dev/null +++ b/tests/Api/ApiErrorTest.php @@ -0,0 +1,53 @@ +partialMock(SystemApiController::class); + $mockController->shouldReceive('read')->andThrow(\InvalidArgumentException::class, 'Potentially sensitive data', 500); + + $resp = $this->actingAsApiEditor()->get('/api/system'); + $resp->assertStatus(500); + $resp->assertDontSee('Potentially sensitive data', false); + $resp->assertJsonPath('error.message', 'An error occurred'); + + config(['app.debug' => true]); + + $resp = $this->actingAsApiEditor()->get('/api/system'); + $resp->assertStatus(500); + $resp->assertJsonPath('error.message', 'Potentially sensitive data'); + } + + public function test_exception_message_when_model_not_found() + { + $resp = $this->actingAsApiEditor()->get('/api/books/123456789'); + $resp->assertStatus(404); + $resp->assertSee('The requested resource could not be found.', false); + } + + public function test_pretty_exception_messages_are_provided_in_non_debug_mode() + { + $mockController = $this->partialMock(SystemApiController::class); + $exception = new PrettyException('Mr Error is here!'); + $exception->setSubtitle('Oh no!'); + $exception->setDetails('Something has really gone wrong'); + $mockController->shouldReceive('read')->andThrow($exception); + + $resp = $this->actingAsApiEditor()->get('/api/system'); + $resp->assertStatus(500); + $resp->assertJson([ + 'error' => [ + 'message' => 'Mr Error is here!. Oh no!. Something has really gone wrong.' + ] + ]); + } +} diff --git a/tests/Api/AttachmentsApiTest.php b/tests/Api/AttachmentsApiTest.php index b03f280ac67..b2346587924 100644 --- a/tests/Api/AttachmentsApiTest.php +++ b/tests/Api/AttachmentsApiTest.php @@ -12,7 +12,7 @@ class AttachmentsApiTest extends TestCase { use TestsApi; - protected $baseEndpoint = '/api/attachments'; + protected string $baseEndpoint = '/api/attachments'; public function test_index_endpoint_returns_expected_book() { @@ -302,6 +302,23 @@ public function test_update_link_attachment_to_file() } public function test_update_file_attachment_to_link() + { + $this->actingAsApiAdmin(); + $page = $this->entities->page(); + $attachment = $this->createAttachmentForPage($page); + + $resp = $this->putJson("{$this->baseEndpoint}/{$attachment->id}", [ + 'link' => 'https://example.com/donkey', + ]); + + $resp->assertStatus(200); + $this->assertDatabaseHas('attachments', [ + 'id' => $attachment->id, + 'path' => 'https://example.com/donkey', + ]); + } + + public function test_update_does_not_require_name() { $this->actingAsApiAdmin(); $page = $this->entities->page(); diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index b31bd7d37ee..74f558f381b 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -3,8 +3,8 @@ namespace Tests\Api; use BookStack\Entities\Models\Book; +use BookStack\Entities\Repos\BaseRepo; use Carbon\Carbon; -use Illuminate\Support\Facades\DB; use Tests\TestCase; class BooksApiTest extends TestCase @@ -24,6 +24,31 @@ public function test_index_endpoint_returns_expected_book() 'id' => $firstBook->id, 'name' => $firstBook->name, 'slug' => $firstBook->slug, + 'owned_by' => $firstBook->owned_by, + 'created_by' => $firstBook->created_by, + 'updated_by' => $firstBook->updated_by, + 'cover' => null, + ], + ]]); + } + + public function test_index_endpoint_includes_cover_if_set() + { + $this->actingAsApiEditor(); + $book = $this->entities->book(); + + $baseRepo = $this->app->make(BaseRepo::class); + $image = $this->files->uploadedImage('book_cover'); + $baseRepo->updateCoverImage($book, $image); + + $resp = $this->getJson($this->baseEndpoint . '?filter[id]=' . $book->id); + $resp->assertJson(['data' => [ + [ + 'id' => $book->id, + 'cover' => [ + 'id' => $book->coverInfo()->getImage()->id, + 'url' => $book->coverInfo()->getImage()->url, + ], ], ]]); } @@ -68,7 +93,7 @@ public function test_create_endpoint_with_html() ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('books', $expectedDetails); + $this->assertDatabaseHasEntityData('book', $expectedDetails); } public function test_book_name_needed_to_create() @@ -127,23 +152,71 @@ public function test_read_endpoint_includes_chapter_and_page_contents() $directChildCount = $book->directPages()->count() + $book->chapters()->count(); $resp->assertStatus(200); $resp->assertJsonCount($directChildCount, 'contents'); + + $contents = $resp->json('contents'); + $respChapter = array_values(array_filter($contents, fn ($item) => ($item['id'] === $chapter->id && $item['type'] === 'chapter')))[0]; + $this->assertArrayMapIncludes([ + 'id' => $chapter->id, + 'type' => 'chapter', + 'name' => $chapter->name, + 'slug' => $chapter->slug, + ], $respChapter); + + $respPage = array_values(array_filter($respChapter['pages'], fn ($item) => ($item['id'] === $chapterPage->id)))[0]; + + $this->assertArrayMapIncludes([ + 'id' => $chapterPage->id, + 'name' => $chapterPage->name, + 'slug' => $chapterPage->slug, + ], $respPage); + } + + public function test_read_endpoint_contents_nested_pages_has_permissions_applied() + { + $this->actingAsApiEditor(); + + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $chapterPage = $chapter->pages()->first(); + $customName = 'MyNonVisiblePageWithinAChapter'; + $chapterPage->name = $customName; + $chapterPage->save(); + + $this->permissions->disableEntityInheritedPermissions($chapterPage); + + $resp = $this->getJson($this->baseEndpoint . "/{$book->id}"); + $resp->assertJsonMissing(['name' => $customName]); + } + + public function test_read_endpoint_lists_visible_shelves_the_book_is_assigned_to() + { + $this->actingAsApiEditor(); + $shelf = $this->entities->shelf(); + $otherShelf = $this->entities->shelf(); + $book = $this->entities->book(); + $book->shelves()->detach(); + + $book->shelves()->attach($shelf); + $book->shelves()->attach($otherShelf); + + $this->assertEquals(2, $book->shelves()->count()); + + $this->permissions->disableEntityInheritedPermissions($otherShelf); + + $resp = $this->getJson("{$this->baseEndpoint}/{$book->id}"); + $resp->assertOk(); + $resp->assertJsonCount(1, 'shelves'); $resp->assertJson([ - 'contents' => [ + 'shelves' => [ [ - 'type' => 'chapter', - 'id' => $chapter->id, - 'name' => $chapter->name, - 'slug' => $chapter->slug, - 'pages' => [ - [ - 'id' => $chapterPage->id, - 'name' => $chapterPage->name, - 'slug' => $chapterPage->slug, - ] - ] + 'id' => $shelf->id, + 'name' => $shelf->name, + 'slug' => $shelf->slug, ] ] ]); + $resp->assertJsonMissingPath('shelves.0.description'); + $resp->assertJsonMissingPath('shelves.0.pivot'); } public function test_update_endpoint() @@ -181,14 +254,14 @@ public function test_update_endpoint_with_html() $resp = $this->putJson($this->baseEndpoint . "/{$book->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('books', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API'])); + $this->assertDatabaseHasEntityData('book', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API'])); } public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $book = $this->entities->book(); - DB::table('books')->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]); + Book::query()->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -204,7 +277,7 @@ public function test_update_cover_image_control() $this->actingAsApiEditor(); /** @var Book $book */ $book = $this->entities->book(); - $this->assertNull($book->cover); + $this->assertNull($book->coverInfo()->getImage()); $file = $this->files->uploadedImage('image.png'); // Ensure cover image can be set via API @@ -214,7 +287,7 @@ public function test_update_cover_image_control() $book->refresh(); $resp->assertStatus(200); - $this->assertNotNull($book->cover); + $this->assertNotNull($book->coverInfo()->getImage()); // Ensure further updates without image do not clear cover image $resp = $this->put($this->baseEndpoint . "/{$book->id}", [ @@ -223,7 +296,7 @@ public function test_update_cover_image_control() $book->refresh(); $resp->assertStatus(200); - $this->assertNotNull($book->cover); + $this->assertNotNull($book->coverInfo()->getImage()); // Ensure update with null image property clears image $resp = $this->put($this->baseEndpoint . "/{$book->id}", [ @@ -232,7 +305,7 @@ public function test_update_cover_image_control() $book->refresh(); $resp->assertStatus(200); - $this->assertNull($book->cover); + $this->assertNull($book->coverInfo()->getImage()); } public function test_delete_endpoint() @@ -244,62 +317,4 @@ public function test_delete_endpoint() $resp->assertStatus(204); $this->assertActivityExists('book_delete'); } - - public function test_export_html_endpoint() - { - $this->actingAsApiEditor(); - $book = $this->entities->book(); - - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/html"); - $resp->assertStatus(200); - $resp->assertSee($book->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); - } - - public function test_export_plain_text_endpoint() - { - $this->actingAsApiEditor(); - $book = $this->entities->book(); - - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/plaintext"); - $resp->assertStatus(200); - $resp->assertSee($book->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); - } - - public function test_export_pdf_endpoint() - { - $this->actingAsApiEditor(); - $book = $this->entities->book(); - - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/pdf"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); - } - - public function test_export_markdown_endpoint() - { - $this->actingAsApiEditor(); - $book = Book::visible()->has('pages')->has('chapters')->first(); - - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/markdown"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.md"'); - $resp->assertSee('# ' . $book->name); - $resp->assertSee('# ' . $book->pages()->first()->name); - $resp->assertSee('# ' . $book->chapters()->first()->name); - } - - public function test_cant_export_when_not_have_permission() - { - $types = ['html', 'plaintext', 'pdf', 'markdown']; - $this->actingAsApiEditor(); - $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); - - $book = $this->entities->book(); - foreach ($types as $type) { - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/{$type}"); - $this->assertPermissionError($resp); - } - } } diff --git a/tests/Api/ChaptersApiTest.php b/tests/Api/ChaptersApiTest.php index 81a91887794..953b3a0f5b4 100644 --- a/tests/Api/ChaptersApiTest.php +++ b/tests/Api/ChaptersApiTest.php @@ -22,11 +22,15 @@ public function test_index_endpoint_returns_expected_chapter() $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); $resp->assertJson(['data' => [ [ - 'id' => $firstChapter->id, - 'name' => $firstChapter->name, - 'slug' => $firstChapter->slug, - 'book_id' => $firstChapter->book->id, - 'priority' => $firstChapter->priority, + 'id' => $firstChapter->id, + 'name' => $firstChapter->name, + 'slug' => $firstChapter->slug, + 'book_id' => $firstChapter->book->id, + 'priority' => $firstChapter->priority, + 'book_slug' => $firstChapter->book->slug, + 'owned_by' => $firstChapter->owned_by, + 'created_by' => $firstChapter->created_by, + 'updated_by' => $firstChapter->updated_by, ], ]]); } @@ -35,6 +39,7 @@ public function test_create_endpoint() { $this->actingAsApiEditor(); $book = $this->entities->book(); + $templatePage = $this->entities->templatePage(); $details = [ 'name' => 'My API chapter', 'description' => 'A chapter created via the API', @@ -46,6 +51,7 @@ public function test_create_endpoint() ], ], 'priority' => 15, + 'default_template_id' => $templatePage->id, ]; $resp = $this->postJson($this->baseEndpoint, $details); @@ -85,7 +91,7 @@ public function test_create_endpoint_with_html() 'description' => 'A chapter created via the API', ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('chapters', $expectedDetails); + $this->assertDatabaseHasEntityData('chapter', $expectedDetails); } public function test_chapter_name_needed_to_create() @@ -130,6 +136,7 @@ public function test_read_endpoint() $resp->assertJson([ 'id' => $chapter->id, 'slug' => $chapter->slug, + 'book_slug' => $chapter->book->slug, 'created_by' => [ 'name' => $chapter->createdBy->name, ], @@ -145,9 +152,21 @@ public function test_read_endpoint() 'id' => $page->id, 'slug' => $page->slug, 'name' => $page->name, + 'owned_by' => $page->owned_by, + 'created_by' => $page->created_by, + 'updated_by' => $page->updated_by, + 'book_id' => $page->book->id, + 'chapter_id' => $chapter->id, + 'priority' => $page->priority, + 'book_slug' => $chapter->book->slug, + 'draft' => $page->draft, + 'template' => $page->template, + 'editor' => $page->editor, ], ], + 'default_template_id' => null, ]); + $resp->assertJsonMissingPath('book'); $resp->assertJsonCount($chapter->pages()->count(), 'pages'); } @@ -155,6 +174,7 @@ public function test_update_endpoint() { $this->actingAsApiEditor(); $chapter = $this->entities->chapter(); + $templatePage = $this->entities->templatePage(); $details = [ 'name' => 'My updated API chapter', 'description' => 'A chapter updated via the API', @@ -165,6 +185,7 @@ public function test_update_endpoint() ], ], 'priority' => 15, + 'default_template_id' => $templatePage->id, ]; $resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details); @@ -192,7 +213,7 @@ public function test_update_endpoint_with_html() $resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('chapters', array_merge($details, [ + $this->assertDatabaseHasEntityData('chapter', array_merge($details, [ 'id' => $chapter->id, 'description' => 'A chapter updated via the API' ])); } @@ -201,7 +222,7 @@ public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $chapter = $this->entities->chapter(); - DB::table('chapters')->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $chapter->newQuery()->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -223,15 +244,15 @@ public function test_update_with_book_id_moves_chapter() $resp->assertOk(); $chapter->refresh(); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'book_id' => $newBook->id]); - $this->assertDatabaseHas('pages', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'book_id' => $newBook->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]); } public function test_update_with_new_book_id_requires_delete_permission() { $editor = $this->users->editor(); $this->permissions->removeUserRolePermissions($editor, ['chapter-delete-all', 'chapter-delete-own']); - $this->actingAs($editor); + $this->actingAsForApi($editor); $chapter = $this->entities->chapterHasPages(); $newBook = Book::query()->where('id', '!=', $chapter->book_id)->first(); @@ -248,61 +269,4 @@ public function test_delete_endpoint() $resp->assertStatus(204); $this->assertActivityExists('chapter_delete'); } - - public function test_export_html_endpoint() - { - $this->actingAsApiEditor(); - $chapter = $this->entities->chapter(); - - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/html"); - $resp->assertStatus(200); - $resp->assertSee($chapter->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); - } - - public function test_export_plain_text_endpoint() - { - $this->actingAsApiEditor(); - $chapter = $this->entities->chapter(); - - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/plaintext"); - $resp->assertStatus(200); - $resp->assertSee($chapter->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); - } - - public function test_export_pdf_endpoint() - { - $this->actingAsApiEditor(); - $chapter = $this->entities->chapter(); - - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/pdf"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); - } - - public function test_export_markdown_endpoint() - { - $this->actingAsApiEditor(); - $chapter = Chapter::visible()->has('pages')->first(); - - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/markdown"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.md"'); - $resp->assertSee('# ' . $chapter->name); - $resp->assertSee('# ' . $chapter->pages()->first()->name); - } - - public function test_cant_export_when_not_have_permission() - { - $types = ['html', 'plaintext', 'pdf', 'markdown']; - $this->actingAsApiEditor(); - $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); - - $chapter = Chapter::visible()->has('pages')->first(); - foreach ($types as $type) { - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/{$type}"); - $this->assertPermissionError($resp); - } - } } diff --git a/tests/Api/ContentPermissionsApiTest.php b/tests/Api/ContentPermissionsApiTest.php index a62abacc75e..6a26b403245 100644 --- a/tests/Api/ContentPermissionsApiTest.php +++ b/tests/Api/ContentPermissionsApiTest.php @@ -39,7 +39,7 @@ public function test_read_endpoint_shows_expected_detail() $page = $this->entities->page(); $owner = $this->users->newUser(); $role = $this->users->createRole(); - $this->permissions->addEntityPermission($page, ['view', 'delete'], $role); + $this->permissions->setEntityPermissionsForRole($page, ['view', 'delete'], $role); $this->permissions->changeEntityOwner($page, $owner); $this->permissions->setFallbackPermissions($page, ['update', 'create']); @@ -209,7 +209,7 @@ public function test_update_can_set_fallback_permissions() public function test_update_can_clear_roles_permissions() { $page = $this->entities->page(); - $this->permissions->addEntityPermission($page, ['view'], $this->users->createRole()); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $this->users->createRole()); $page->owned_by = null; $page->save(); @@ -280,7 +280,7 @@ public function test_update_can_both_provide_owner_and_fallback_permissions() ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', ['id' => $page->id, 'owned_by' => $user->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'owned_by' => $user->id]); $this->assertDatabaseHas('entity_permissions', [ 'entity_id' => $page->id, 'entity_type' => 'page', diff --git a/tests/Api/ExportsApiTest.php b/tests/Api/ExportsApiTest.php new file mode 100644 index 00000000000..7951e04d272 --- /dev/null +++ b/tests/Api/ExportsApiTest.php @@ -0,0 +1,209 @@ +actingAsApiEditor(); + $book = $this->entities->book(); + + $resp = $this->get("/api/books/{$book->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.html'); + } + + public function test_book_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $book = $this->entities->book(); + + $resp = $this->get("/api/books/{$book->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.txt'); + } + + public function test_book_pdf_endpoint() + { + $this->actingAsApiEditor(); + $book = $this->entities->book(); + + $resp = $this->get("/api/books/{$book->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.pdf'); + } + + public function test_book_markdown_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->has('pages')->has('chapters')->first(); + + $resp = $this->get("/api/books/{$book->id}/export/markdown"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.md'); + $resp->assertSee('# ' . $book->name); + $resp->assertSee('# ' . $book->pages()->first()->name); + $resp->assertSee('# ' . $book->chapters()->first()->name); + } + + public function test_book_zip_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->has('pages')->has('chapters')->first(); + + $resp = $this->get("/api/books/{$book->id}/export/zip"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.zip'); + + $zip = ZipTestHelper::extractFromZipResponse($resp); + $this->assertArrayHasKey('book', $zip->data); + } + + public function test_chapter_html_endpoint() + { + $this->actingAsApiEditor(); + $chapter = $this->entities->chapter(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.html'); + } + + public function test_chapter_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $chapter = $this->entities->chapter(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.txt'); + } + + public function test_chapter_pdf_endpoint() + { + $this->actingAsApiEditor(); + $chapter = $this->entities->chapter(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.pdf'); + } + + public function test_chapter_markdown_endpoint() + { + $this->actingAsApiEditor(); + $chapter = Chapter::visible()->has('pages')->first(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/markdown"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.md'); + $resp->assertSee('# ' . $chapter->name); + $resp->assertSee('# ' . $chapter->pages()->first()->name); + } + + public function test_chapter_zip_endpoint() + { + $this->actingAsApiEditor(); + $chapter = Chapter::visible()->has('pages')->first(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/zip"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.zip'); + + $zip = ZipTestHelper::extractFromZipResponse($resp); + $this->assertArrayHasKey('chapter', $zip->data); + } + + public function test_page_html_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.html'); + } + + public function test_page_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.txt'); + } + + public function test_page_pdf_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.pdf'); + } + + public function test_page_markdown_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/markdown"); + $resp->assertStatus(200); + $resp->assertSee('# ' . $page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.md'); + } + + public function test_page_zip_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/zip"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.zip'); + + $zip = ZipTestHelper::extractFromZipResponse($resp); + $this->assertArrayHasKey('page', $zip->data); + } + + public function test_cant_export_when_not_have_permission() + { + $types = ['html', 'plaintext', 'pdf', 'markdown', 'zip']; + $this->actingAsApiEditor(); + $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); + + $book = $this->entities->book(); + foreach ($types as $type) { + $resp = $this->get("/api/books/{$book->id}/export/{$type}"); + $this->assertPermissionError($resp); + } + + $chapter = Chapter::visible()->has('pages')->first(); + foreach ($types as $type) { + $resp = $this->get("/api/chapters/{$chapter->id}/export/{$type}"); + $this->assertPermissionError($resp); + } + + $page = $this->entities->page(); + foreach ($types as $type) { + $resp = $this->get("/api/pages/{$page->id}/export/{$type}"); + $this->assertPermissionError($resp); + } + } +} diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php index 6670931074e..8944dcffd33 100644 --- a/tests/Api/ImageGalleryApiTest.php +++ b/tests/Api/ImageGalleryApiTest.php @@ -154,6 +154,34 @@ public function test_create_fails_if_uploaded_to_not_visible_or_not_exists() $resp->assertStatus(404); } + public function test_create_requires_update_permission_for_the_target_page() + { + $editor = $this->users->editor(); + $this->actingAsForApi($editor); + + $makeRequest = function (int $uploadedTo) { + return $this->call('POST', $this->baseEndpoint, [ + 'type' => 'gallery', + 'uploaded_to' => $uploadedTo, + 'name' => 'My awesome image!', + ], [], [ + 'image' => $this->files->uploadedImage('my-cool-image.png'), + ]); + }; + + $page = $this->entities->page(); + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $editor->roles()->first()); + + $resp = $makeRequest($page->id); + $resp->assertStatus(403); + + $this->permissions->setEntityPermissionsForRole($page, ['view', 'update'], $editor->roles()->first()); + + $resp = $makeRequest($page->id); + $resp->assertStatus(200); + } + public function test_create_has_restricted_types() { $this->actingAsApiEditor(); @@ -275,6 +303,69 @@ public function test_read_endpoint_does_not_show_if_no_permissions_for_related_p $resp->assertStatus(404); } + public function test_read_data_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->get("{$this->baseEndpoint}/{$image->id}/data"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Type', 'image/png'); + + $respData = $resp->streamedContent(); + $this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData); + } + + public function test_read_data_endpoint_permission_controlled() + { + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + $image = Image::findOrFail($data['response']->id); + + $this->get("{$this->baseEndpoint}/{$image->id}/data")->assertOk(); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->get("{$this->baseEndpoint}/{$image->id}/data"); + $resp->assertStatus(404); + } + + public function test_read_url_data_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + + $url = url($data['response']->path); + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(200); + $resp->assertHeader('Content-Type', 'image/png'); + + $respData = $resp->streamedContent(); + $this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData); + } + + public function test_read_url_data_endpoint_permission_controlled_when_local_secure_restricted_storage_is_used() + { + config()->set('filesystems.images', 'local_secure_restricted'); + + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + + $url = url($data['response']->path); + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(200); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(404); + } + public function test_update_endpoint() { $this->actingAsApiAdmin(); @@ -332,6 +423,32 @@ public function test_update_endpoint_requires_image_update_permission() $resp->assertStatus(200); } + public function test_update_endpoint_only_works_on_gallery_and_drawio_images() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $statusByImageType = [ + 'gallery' => 200, + 'drawio' => 200, + 'cover_book' => 404, + 'user' => 404, + 'system' => 404, + ]; + + foreach ($statusByImageType as $type => $status) { + $image->type = $type; + $image->save(); + + $resp = $this->putJson($this->baseEndpoint . "/{$image->id}", [ + 'name' => "My updated {$type} image!", + ]); + $resp->assertStatus($status); + } + } + public function test_delete_endpoint() { $this->actingAsApiAdmin(); @@ -363,4 +480,19 @@ public function test_delete_endpoint_requires_image_delete_permission() $resp = $this->deleteJson($this->baseEndpoint . "/{$image->id}"); $resp->assertStatus(204); } + + public function test_delete_limited_to_visible_images() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + + $image = Image::query()->findOrFail($data['response']->id); + + $this->entities->destroy($imagePage); + + $resp = $this->deleteJson($this->baseEndpoint . "/{$image->id}"); + + $resp->assertStatus(404); + } } diff --git a/tests/Api/ImportsApiTest.php b/tests/Api/ImportsApiTest.php new file mode 100644 index 00000000000..7026868c913 --- /dev/null +++ b/tests/Api/ImportsApiTest.php @@ -0,0 +1,175 @@ +entities->book(); + $zip = ZipTestHelper::zipUploadFromData([ + 'page' => [ + 'name' => 'My API import page', + 'tags' => [ + [ + 'name' => 'My api tag', + 'value' => 'api test value' + ] + ], + ], + ]); + + $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]); + $resp->assertStatus(200); + + $importId = $resp->json('id'); + $import = Import::query()->findOrFail($importId); + $this->assertEquals('page', $import->type); + + $resp = $this->post($this->baseEndpoint . "/{$import->id}", [ + 'parent_type' => 'book', + 'parent_id' => $book->id, + ]); + $resp->assertJson([ + 'name' => 'My API import page', + 'book_id' => $book->id, + ]); + $resp->assertJsonMissingPath('book'); + + $page = Page::query()->where('name', '=', 'My API import page')->first(); + $this->assertEquals('My api tag', $page->tags()->first()->name); + } + + public function test_create_validation_error(): void + { + $zip = ZipTestHelper::zipUploadFromData([ + 'page' => [ + 'tags' => [ + [ + 'name' => 'My api tag', + 'value' => 'api test value' + ] + ], + ], + ]); + + $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]); + $resp->assertStatus(422); + $message = $resp->json('message'); + + $this->assertStringContainsString('ZIP upload failed with the following validation errors:', $message); + $this->assertStringContainsString('[page.name] The name field is required.', $message); + } + + public function test_list(): void + { + $imports = Import::factory()->count(10)->create(); + + $resp = $this->actingAsApiAdmin()->get($this->baseEndpoint); + $resp->assertJsonCount(10, 'data'); + $resp->assertJsonPath('total', 10); + + $firstImport = $imports->first(); + $resp = $this->actingAsApiAdmin()->get($this->baseEndpoint . '?filter[id]=' . $firstImport->id); + $resp->assertJsonCount(1, 'data'); + $resp->assertJsonPath('data.0.id', $firstImport->id); + $resp->assertJsonPath('data.0.name', $firstImport->name); + $resp->assertJsonPath('data.0.size', $firstImport->size); + $resp->assertJsonPath('data.0.type', $firstImport->type); + } + + public function test_list_visibility_limited(): void + { + $user = $this->users->editor(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $resp = $this->actingAsForApi($user)->get($this->baseEndpoint); + $resp->assertJsonCount(1, 'data'); + $resp->assertJsonPath('data.0.name', 'MySuperUserImport'); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $resp = $this->actingAsForApi($user)->get($this->baseEndpoint); + $resp->assertJsonCount(2, 'data'); + $resp->assertJsonPath('data.1.name', 'MySuperAdminImport'); + } + + public function test_read(): void + { + $zip = ZipTestHelper::zipUploadFromData([ + 'book' => [ + 'name' => 'My API import book', + 'pages' => [ + [ + 'name' => 'My import page', + 'tags' => [ + [ + 'name' => 'My api tag', + 'value' => 'api test value' + ] + ] + ] + ], + ], + ]); + + $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]); + $resp->assertStatus(200); + + $resp = $this->get($this->baseEndpoint . "/{$resp->json('id')}"); + $resp->assertStatus(200); + + $resp->assertJsonPath('details.name', 'My API import book'); + $resp->assertJsonPath('details.pages.0.name', 'My import page'); + $resp->assertJsonPath('details.pages.0.tags.0.name', 'My api tag'); + $resp->assertJsonMissingPath('metadata'); + } + + public function test_delete(): void + { + $import = Import::factory()->create(); + + $resp = $this->actingAsApiAdmin()->delete($this->baseEndpoint . "/{$import->id}"); + $resp->assertStatus(204); + } + + public function test_content_import_permissions_needed(): void + { + $user = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($user, ['access-api']); + $this->actingAsForApi($user); + $requests = [ + ['GET', $this->baseEndpoint], + ['POST', $this->baseEndpoint], + ['GET', $this->baseEndpoint . "/1"], + ['POST', $this->baseEndpoint . "/1"], + ['DELETE', $this->baseEndpoint . "/1"], + ]; + + foreach ($requests as $request) { + [$method, $endpoint] = $request; + $resp = $this->json($method, $endpoint); + $resp->assertStatus(403); + } + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + foreach ($requests as $request) { + [$method, $endpoint] = $request; + $resp = $this->call($method, $endpoint); + $this->assertNotEquals(403, $resp->status(), "A {$method} request to {$endpoint} returned 403"); + } + } +} diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php index 0d084472d1a..d71b6c9881d 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -2,6 +2,7 @@ namespace Tests\Api; +use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use Carbon\Carbon; @@ -27,6 +28,10 @@ public function test_index_endpoint_returns_expected_page() 'slug' => $firstPage->slug, 'book_id' => $firstPage->book->id, 'priority' => $firstPage->priority, + 'owned_by' => $firstPage->owned_by, + 'created_by' => $firstPage->created_by, + 'updated_by' => $firstPage->updated_by, + 'revision_count' => $firstPage->revision_count, ], ]]); } @@ -195,6 +200,31 @@ public function test_read_endpoint_returns_not_found() $this->assertSame(404, $resp->json('error')['code']); } + public function test_read_endpoint_includes_page_comments_tree_structure() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + $relation = ['commentable_type' => 'page', 'commentable_id' => $page->id]; + $active = Comment::factory()->create([...$relation, 'html' => '

    My active comment

    ']); + Comment::factory()->count(5)->create([...$relation, 'parent_id' => $active->local_id]); + $archived = Comment::factory()->create([...$relation, 'archived' => true]); + Comment::factory()->count(2)->create([...$relation, 'parent_id' => $archived->local_id]); + + $resp = $this->getJson("{$this->baseEndpoint}/{$page->id}"); + $resp->assertOk(); + + $resp->assertJsonCount(1, 'comments.active'); + $resp->assertJsonCount(1, 'comments.archived'); + $resp->assertJsonCount(5, 'comments.active.0.children'); + $resp->assertJsonCount(2, 'comments.archived.0.children'); + + $resp->assertJsonFragment([ + 'id' => $active->id, + 'local_id' => $active->local_id, + 'html' => '

    My active comment

    ', + ]); + } + public function test_update_endpoint() { $this->actingAsApiEditor(); @@ -282,7 +312,7 @@ public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $page = $this->entities->page(); - DB::table('pages')->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $page->newQuery()->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -304,60 +334,4 @@ public function test_delete_endpoint() $resp->assertStatus(204); $this->assertActivityExists('page_delete', $page); } - - public function test_export_html_endpoint() - { - $this->actingAsApiEditor(); - $page = $this->entities->page(); - - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/html"); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); - } - - public function test_export_plain_text_endpoint() - { - $this->actingAsApiEditor(); - $page = $this->entities->page(); - - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/plaintext"); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); - } - - public function test_export_pdf_endpoint() - { - $this->actingAsApiEditor(); - $page = $this->entities->page(); - - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/pdf"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); - } - - public function test_export_markdown_endpoint() - { - $this->actingAsApiEditor(); - $page = $this->entities->page(); - - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/markdown"); - $resp->assertStatus(200); - $resp->assertSee('# ' . $page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); - } - - public function test_cant_export_when_not_have_permission() - { - $types = ['html', 'plaintext', 'pdf', 'markdown']; - $this->actingAsApiEditor(); - $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); - - $page = $this->entities->page(); - foreach ($types as $type) { - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/{$type}"); - $this->assertPermissionError($resp); - } - } } diff --git a/tests/Api/RecycleBinApiTest.php b/tests/Api/RecycleBinApiTest.php index d174838c27d..9e645fe215b 100644 --- a/tests/Api/RecycleBinApiTest.php +++ b/tests/Api/RecycleBinApiTest.php @@ -23,7 +23,7 @@ public function test_settings_manage_permission_needed_for_all_endpoints() { $editor = $this->users->editor(); $this->permissions->grantUserRolePermissions($editor, ['settings-manage']); - $this->actingAs($editor); + $this->actingAsForApi($editor); foreach ($this->endpointMap as [$method, $uri]) { $resp = $this->json($method, $uri); @@ -36,7 +36,7 @@ public function test_restrictions_manage_all_permission_needed_for_all_endpoints { $editor = $this->users->editor(); $this->permissions->grantUserRolePermissions($editor, ['restrictions-manage-all']); - $this->actingAs($editor); + $this->actingAsForApi($editor); foreach ($this->endpointMap as [$method, $uri]) { $resp = $this->json($method, $uri); @@ -53,6 +53,7 @@ public function test_index_endpoint_returns_expected_page() $book = $this->entities->book(); $this->actingAs($admin)->delete($page->getUrl()); $this->delete($book->getUrl()); + $this->actingAsForApi($admin); $deletions = Deletion::query()->orderBy('id')->get(); @@ -89,7 +90,7 @@ public function test_index_endpoint_returns_children_count() $deletion = Deletion::query()->orderBy('id')->first(); - $resp = $this->getJson($this->baseEndpoint); + $resp = $this->actingAsForApi($admin)->getJson($this->baseEndpoint); $expectedData = [ [ @@ -115,6 +116,7 @@ public function test_index_endpoint_returns_parent() $this->actingAs($admin)->delete($page->getUrl()); $deletion = Deletion::query()->orderBy('id')->first(); + $this->actingAsForApi($admin); $resp = $this->getJson($this->baseEndpoint); $expectedData = [ @@ -141,10 +143,11 @@ public function test_restore_endpoint() $page = $this->entities->page(); $this->asAdmin()->delete($page->getUrl()); $page->refresh(); + $this->actingAsApiAdmin(); $deletion = Deletion::query()->orderBy('id')->first(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); @@ -154,7 +157,7 @@ public function test_restore_endpoint() 'restore_count' => 1, ]); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => null, ]); @@ -165,10 +168,11 @@ public function test_destroy_endpoint() $page = $this->entities->page(); $this->asAdmin()->delete($page->getUrl()); $page->refresh(); + $this->actingAsApiAdmin(); $deletion = Deletion::query()->orderBy('id')->first(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); @@ -178,6 +182,6 @@ public function test_destroy_endpoint() 'delete_count' => 1, ]); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); + $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']); } } diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index 2a186e8d632..5d0ce53ec1c 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -13,7 +13,7 @@ class SearchApiTest extends TestCase { use TestsApi; - protected $baseEndpoint = '/api/search'; + protected string $baseEndpoint = '/api/search'; public function test_all_endpoint_returns_search_filtered_results_with_query() { @@ -45,7 +45,7 @@ public function test_all_endpoint_returns_entity_url() $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue'); $resp->assertJsonFragment([ 'type' => 'page', - 'url' => $page->getUrl(), + 'url' => $page->getUrl(), ]); } @@ -57,10 +57,10 @@ public function test_all_endpoint_returns_items_with_preview_html() $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue'); $resp->assertJsonFragment([ - 'type' => 'book', - 'url' => $book->getUrl(), + 'type' => 'book', + 'url' => $book->getUrl(), 'preview_html' => [ - 'name' => 'name with superuniquevalue within', + 'name' => 'name with superuniquevalue within', 'content' => 'Description with superuniquevalue within', ], ]); @@ -74,4 +74,48 @@ public function test_all_endpoint_requires_query_parameter() $resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue'); $resp->assertOk(); } + + public function test_all_endpoint_includes_parent_details_where_visible() + { + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $book = $page->book; + + $page->update(['name' => 'name with superextrauniquevalue within']); + $page->indexForSearch(); + + $editor = $this->users->editor(); + $this->actingAsApiEditor(); + $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertJsonFragment([ + 'id' => $page->id, + 'type' => 'page', + 'book' => [ + 'id' => $book->id, + 'name' => $book->name, + 'slug' => $book->slug, + ], + 'chapter' => [ + 'id' => $chapter->id, + 'name' => $chapter->name, + 'slug' => $chapter->slug, + ], + ]); + + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->setEntityPermissions($page, ['view'], [$editor->roles()->first()]); + + $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertOk(); + $resp->assertJsonPath('data.0.id', $page->id); + $resp->assertJsonPath('data.0.book.name', $book->name); + $resp->assertJsonMissingPath('data.0.chapter'); + + $this->permissions->disableEntityInheritedPermissions($book); + + $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertOk(); + $resp->assertJsonPath('data.0.id', $page->id); + $resp->assertJsonMissingPath('data.0.book.name'); + } } diff --git a/tests/Api/ShelvesApiTest.php b/tests/Api/ShelvesApiTest.php index f1b8ed98553..34ce0e4e5b2 100644 --- a/tests/Api/ShelvesApiTest.php +++ b/tests/Api/ShelvesApiTest.php @@ -4,6 +4,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; +use BookStack\Entities\Repos\BaseRepo; use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -25,6 +26,31 @@ public function test_index_endpoint_returns_expected_shelf() 'id' => $firstBookshelf->id, 'name' => $firstBookshelf->name, 'slug' => $firstBookshelf->slug, + 'owned_by' => $firstBookshelf->owned_by, + 'created_by' => $firstBookshelf->created_by, + 'updated_by' => $firstBookshelf->updated_by, + 'cover' => null, + ], + ]]); + } + + public function test_index_endpoint_includes_cover_if_set() + { + $this->actingAsApiEditor(); + $shelf = $this->entities->shelf(); + + $baseRepo = $this->app->make(BaseRepo::class); + $image = $this->files->uploadedImage('shelf_cover'); + $baseRepo->updateCoverImage($shelf, $image); + + $resp = $this->getJson($this->baseEndpoint . '?filter[id]=' . $shelf->id); + $resp->assertJson(['data' => [ + [ + 'id' => $shelf->id, + 'cover' => [ + 'id' => $shelf->coverInfo()->getImage()->id, + 'url' => $shelf->coverInfo()->getImage()->url, + ], ], ]]); } @@ -76,7 +102,7 @@ public function test_create_endpoint_with_html() ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('bookshelves', $expectedDetails); + $this->assertDatabaseHasEntityData('bookshelf', $expectedDetails); } public function test_shelf_name_needed_to_create() @@ -155,14 +181,14 @@ public function test_update_endpoint_with_html() $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('bookshelves', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API'])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API'])); } public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $shelf = Bookshelf::visible()->first(); - DB::table('bookshelves')->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $shelf->newQuery()->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -196,7 +222,7 @@ public function test_update_cover_image_control() $this->actingAsApiEditor(); /** @var Book $shelf */ $shelf = Bookshelf::visible()->first(); - $this->assertNull($shelf->cover); + $this->assertNull($shelf->coverInfo()->getImage()); $file = $this->files->uploadedImage('image.png'); // Ensure cover image can be set via API @@ -206,7 +232,7 @@ public function test_update_cover_image_control() $shelf->refresh(); $resp->assertStatus(200); - $this->assertNotNull($shelf->cover); + $this->assertNotNull($shelf->coverInfo()->getImage()); // Ensure further updates without image do not clear cover image $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [ @@ -215,7 +241,7 @@ public function test_update_cover_image_control() $shelf->refresh(); $resp->assertStatus(200); - $this->assertNotNull($shelf->cover); + $this->assertNotNull($shelf->coverInfo()->getImage()); // Ensure update with null image property clears image $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [ @@ -224,7 +250,7 @@ public function test_update_cover_image_control() $shelf->refresh(); $resp->assertStatus(200); - $this->assertNull($shelf->cover); + $this->assertNull($shelf->coverInfo()->getImage()); } public function test_delete_endpoint() diff --git a/tests/Api/SystemApiTest.php b/tests/Api/SystemApiTest.php new file mode 100644 index 00000000000..e915286e888 --- /dev/null +++ b/tests/Api/SystemApiTest.php @@ -0,0 +1,22 @@ +actingAsApiEditor()->get('/api/system'); + $data = $resp->json(); + + $this->assertStringStartsWith('v', $data['version']); + $this->assertEquals(setting('instance-id'), $data['instance_id']); + $this->assertEquals(setting('app-name'), $data['app_name']); + $this->assertEquals(url('/logo.png'), $data['app_logo']); + $this->assertEquals(url('/'), $data['base_url']); + } +} diff --git a/tests/Api/TagsApiTest.php b/tests/Api/TagsApiTest.php new file mode 100644 index 00000000000..e39d72ce10c --- /dev/null +++ b/tests/Api/TagsApiTest.php @@ -0,0 +1,108 @@ + 'MyGreatApiTag', 'value' => 'cat']; + $pagesToTag = Page::query()->take(10)->get(); + $booksToTag = Book::query()->take(3)->get(); + $chaptersToTag = Chapter::query()->take(5)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag($tagInfo))); + $booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag($tagInfo))); + $chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag($tagInfo))); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/names?filter[name]=MyGreatApiTag'); + $resp->assertStatus(200); + $resp->assertJson([ + 'data' => [ + [ + 'name' => 'MyGreatApiTag', + 'values' => 1, + 'usages' => 18, + 'page_count' => 10, + 'book_count' => 3, + 'chapter_count' => 5, + 'shelf_count' => 0, + ] + ], + 'total' => 1, + ]); + } + + public function test_list_names_is_limited_by_permission_visibility(): void + { + $pagesToTag = Page::query()->take(10)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyGreatApiTag', 'value' => 'cat' . $page->id]))); + + $this->permissions->disableEntityInheritedPermissions($pagesToTag[3]); + $this->permissions->disableEntityInheritedPermissions($pagesToTag[6]); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/names?filter[name]=MyGreatApiTag'); + $resp->assertStatus(200); + $resp->assertJson([ + 'data' => [ + [ + 'name' => 'MyGreatApiTag', + 'values' => 8, + 'usages' => 8, + 'page_count' => 8, + 'book_count' => 0, + 'chapter_count' => 0, + 'shelf_count' => 0, + ] + ], + 'total' => 1, + ]); + } + + public function test_list_values_returns_values_for_set_tag() + { + $pagesToTag = Page::query()->take(10)->get(); + $booksToTag = Book::query()->take(3)->get(); + $chaptersToTag = Chapter::query()->take(5)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-page' . $page->id]))); + $booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-book' . $book->id]))); + $chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-chapter' . $chapter->id]))); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/values-for-name?name=MyValueApiTag'); + + $resp->assertStatus(200); + $resp->assertJson(['total' => 18]); + $resp->assertJsonFragment([ + [ + 'name' => 'MyValueApiTag', + 'value' => 'tag-page' . $pagesToTag[0]->id, + 'usages' => 1, + 'page_count' => 1, + 'book_count' => 0, + 'chapter_count' => 0, + 'shelf_count' => 0, + ] + ]); + } + + public function test_list_values_is_limited_by_permission_visibility(): void + { + $pagesToTag = Page::query()->take(10)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyGreatApiTag', 'value' => 'cat' . $page->id]))); + + $this->permissions->disableEntityInheritedPermissions($pagesToTag[3]); + $this->permissions->disableEntityInheritedPermissions($pagesToTag[6]); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/values-for-name?name=MyGreatApiTag'); + $resp->assertStatus(200); + $resp->assertJson(['total' => 8]); + $resp->assertJsonMissing(['value' => 'cat' . $pagesToTag[3]->id]); + } +} diff --git a/tests/Api/UsersApiTest.php b/tests/Api/UsersApiTest.php index a0c67d0d281..e7b9df6aae9 100644 --- a/tests/Api/UsersApiTest.php +++ b/tests/Api/UsersApiTest.php @@ -80,7 +80,7 @@ public function test_index_endpoint_has_correct_created_and_last_activity_dates( /** @var ActivityModel $activity */ $activity = ActivityModel::query()->where('user_id', '=', $user->id)->latest()->first(); - $resp = $this->asAdmin()->getJson($this->baseEndpoint . '?filter[id]=3'); + $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?filter[id]=3'); $resp->assertJson(['data' => [ [ 'id' => $user->id, diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index 0164978d85d..4cd793fdff3 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -3,6 +3,8 @@ namespace Tests\Auth; use BookStack\Access\Mfa\MfaSession; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Hash; use Illuminate\Testing\TestResponse; use Tests\TestCase; @@ -69,10 +71,18 @@ public function test_login_intended_redirect_does_not_redirect_to_external_pages config()->set('app.url', 'http://localhost'); $this->setSettings(['app-public' => true]); - $this->get('/login', ['referer' => 'https://example.com']); - $login = $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']); - - $login->assertRedirect('http://localhost'); + $testCases = [ + 'https://example.com', + 'http://localhost.example.com', + 'http://localhost:ab@example.com', + ]; + + foreach ($testCases as $testCase) { + $this->get('/login', ['Referer' => $testCase]); + $login = $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']); + $login->assertRedirect('http://localhost'); + auth()->logout(); + } } public function test_login_intended_redirect_does_not_factor_mfa_routes() @@ -144,6 +154,63 @@ public function test_login_attempts_are_rate_limited() $resp->assertSee('Too many login attempts. Please try again in'); } + public function test_login_specifically_disabled_for_guest_account() + { + $guest = $this->users->guest(); + + $resp = $this->post('/login', ['email' => $guest->email, 'password' => 'password']); + $resp->assertRedirect('/login'); + $resp = $this->followRedirects($resp); + $resp->assertSee('These credentials do not match our records.'); + + // Test login even with password somehow set + $guest->password = Hash::make('password'); + $guest->save(); + + $resp = $this->post('/login', ['email' => $guest->email, 'password' => 'password']); + $resp->assertRedirect('/login'); + $resp = $this->followRedirects($resp); + $resp->assertSee('These credentials do not match our records.'); + } + + public function test_failed_login_attempt_has_noise_added_and_have_similar_times_between_known_and_unknown_users() + { + $this->markTestSkipped('Time consuming test'); + + $user = $this->users->editor(); + $user->password = bcrypt('password'); + $user->save(); + // Warmup + $this->post('/login', ['email' => $user->email, 'password' => 'passwordtesting']); + + // For known user attempts + $durations = []; + for ($i = 0; $i < 25; $i++) { + $knownStart = microtime(true); + $this->post('/login', ['email' => $user->email, 'password' => 'passwordtesting']); + $durations[] = (microtime(true) - $knownStart) * 1000; + Cache::clear(); // Clear the cache to avoid hitting rate limits + } + $range = max($durations) - min($durations); + $this->assertGreaterThan(125, $range); + $knownAvg = array_sum($durations) / count($durations); + + // For unknown user attempts + $durations = []; + for ($i = 0; $i < 25; $i++) { + $unknownStart = microtime(true); + $this->post('/login', ['email' => 'unknown@example.com', 'password' => 'passwordtesting']); + $durations[] = (microtime(true) - $unknownStart) * 1000; + Cache::clear(); // Clear the cache to avoid hitting rate limits + } + $range = max($durations) - min($durations); + $this->assertGreaterThan(125, $range); + $unknownAvg = array_sum($durations) / count($durations); + + $knownDiff = abs($knownAvg - $unknownAvg); + $this->assertLessThan(25, $knownDiff); + } + /** * Perform a login. */ diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index 34900ce6f70..5adbe11a1e9 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -4,6 +4,7 @@ use BookStack\Access\Ldap; use BookStack\Access\LdapService; +use BookStack\Exceptions\LdapException; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; use Illuminate\Testing\TestResponse; @@ -32,9 +33,10 @@ protected function setUp(): void 'services.ldap.id_attribute' => 'uid', 'services.ldap.user_to_groups' => false, 'services.ldap.version' => '3', - 'services.ldap.user_filter' => '(&(uid=${user}))', + 'services.ldap.user_filter' => '(&(uid={user}))', 'services.ldap.follow_referrals' => false, 'services.ldap.tls_insecure' => false, + 'services.ldap.tls_ca_cert' => false, 'services.ldap.thumbnail_attribute' => null, ]); $this->mockLdap = $this->mock(Ldap::class); @@ -74,7 +76,7 @@ protected function mockUserLogin(?string $email = null): TestResponse /** * Set LDAP method mocks for things we commonly call without altering. */ - protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $options = 2, int $binds = 4, int $escapes = 2, int $explodes = 0) + protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $options = 2, int $binds = 4, int $escapes = 2, int $explodes = 0, int $groups = 0) { $this->mockLdap->shouldReceive('connect')->times($connects)->andReturn($this->resourceId); $this->mockLdap->shouldReceive('setVersion')->times($versions); @@ -82,6 +84,13 @@ protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $op $this->mockLdap->shouldReceive('bind')->times($binds)->andReturn(true); $this->mockEscapes($escapes); $this->mockExplodes($explodes); + $this->mockGroupLookups($groups); + } + + protected function mockGroupLookups(int $times = 1): void + { + $this->mockLdap->shouldReceive('read')->times($times)->andReturn(['count' => 0]); + $this->mockLdap->shouldReceive('getEntries')->times($times)->andReturn(['count' => 0]); } public function test_login() @@ -157,6 +166,26 @@ public function test_login_works_when_no_uid_provided_by_ldap_server() $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $ldapDn]); } + public function test_login_works_when_ldap_server_does_not_provide_a_cn_value() + { + $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn'); + + $this->commonLdapMocks(1, 1, 1, 2, 1); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) + ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) + ->andReturn(['count' => 1, 0 => [ + 'dn' => $ldapDn, + 'mail' => [$this->mockUser->email], + ]]); + + $resp = $this->mockUserLogin(); + $resp->assertRedirect('/'); + $this->assertDatabaseHas('users', [ + 'name' => 'test-user', + 'email' => $this->mockUser->email, + ]); + } + public function test_a_custom_uid_attribute_can_be_specified_and_is_used_properly() { config()->set(['services.ldap.id_attribute' => 'my_custom_id']); @@ -178,6 +207,68 @@ public function test_a_custom_uid_attribute_can_be_specified_and_is_used_properl $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => 'cooluser456']); } + public function test_login_uses_exact_match_for_external_auth_id_values() + { + config()->set(['services.ldap.id_attribute' => 'my_custom_id']); + // External auth id the same as we expect below but different casing + User::query()->forceCreate([ + 'email' => 'otheruser@example.com', + 'external_auth_id' => 'CoolUser456', + 'email_confirmed' => true, + 'name' => 'Barry Scott', + ]); + + $this->commonLdapMocks(1, 1, 1, 2, 1); + $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn'); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) + ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) + ->andReturn(['count' => 1, 0 => [ + 'cn' => [$this->mockUser->name], + 'dn' => $ldapDn, + 'my_custom_id' => ['cooluser456'], + 'mail' => [$this->mockUser->email], + ]]); + + $resp = $this->mockUserLogin(); + $resp->assertRedirect('/'); + + $this->assertDatabaseHas('users', ['email' => $this->mockUser->email]); + $this->assertEquals($this->mockUser->email, user()->email); + $this->assertEquals('cooluser456', user()->external_auth_id); + } + + public function test_user_filter_default_placeholder_format() + { + config()->set('services.ldap.user_filter', '(&(uid={user}))'); + $this->mockUser->name = 'barryldapuser'; + $expectedFilter = '(&(uid=\62\61\72\72\79\6c\64\61\70\75\73\65\72))'; + + $this->commonLdapMocks(1, 1, 1, 1, 1); + $this->mockLdap->shouldReceive('searchAndGetEntries') + ->once() + ->with($this->resourceId, config('services.ldap.base_dn'), $expectedFilter, \Mockery::type('array')) + ->andReturn(['count' => 0, 0 => []]); + + $resp = $this->mockUserLogin(); + $resp->assertRedirect('/login'); + } + + public function test_user_filter_old_placeholder_format() + { + config()->set('services.ldap.user_filter', '(&(username=${user}))'); + $this->mockUser->name = 'barryldapuser'; + $expectedFilter = '(&(username=\62\61\72\72\79\6c\64\61\70\75\73\65\72))'; + + $this->commonLdapMocks(1, 1, 1, 1, 1); + $this->mockLdap->shouldReceive('searchAndGetEntries') + ->once() + ->with($this->resourceId, config('services.ldap.base_dn'), $expectedFilter, \Mockery::type('array')) + ->andReturn(['count' => 0, 0 => []]); + + $resp = $this->mockUserLogin(); + $resp->assertRedirect('/login'); + } + public function test_initial_incorrect_credentials() { $this->commonLdapMocks(1, 1, 1, 0, 1); @@ -273,8 +364,8 @@ public function test_login_maps_roles_and_retains_existing_roles() 'services.ldap.remove_from_groups' => false, ]); - $this->commonLdapMocks(1, 1, 4, 5, 4, 6); - $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) + $this->commonLdapMocks(1, 1, 4, 5, 2, 2, 2); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], @@ -318,8 +409,8 @@ public function test_login_maps_roles_and_removes_old_roles_if_set() 'services.ldap.remove_from_groups' => true, ]); - $this->commonLdapMocks(1, 1, 3, 4, 3, 2); - $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3) + $this->commonLdapMocks(1, 1, 3, 4, 2, 1, 1); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], @@ -360,22 +451,26 @@ public function test_dump_user_groups_shows_group_related_details_as_json() 'dn' => 'dc=test,' . config('services.ldap.base_dn'), 'mail' => [$this->mockUser->email], ]]; - $this->commonLdapMocks(1, 1, 4, 5, 4, 2); - $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) + $this->commonLdapMocks(1, 1, 4, 5, 2, 2, 0); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn($userResp, ['count' => 1, - 0 => [ - 'dn' => 'dc=test,' . config('services.ldap.base_dn'), + 0 => [ + 'dn' => 'dc=test,' . config('services.ldap.base_dn'), 'memberof' => [ 'count' => 1, - 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com', + 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com', ], ], - ], [ + ]); + + $this->mockLdap->shouldReceive('read')->times(2); + $this->mockLdap->shouldReceive('getEntries')->times(2) + ->andReturn([ 'count' => 1, - 0 => [ - 'dn' => 'cn=ldaptester,ou=groups,dc=example,dc=com', - 'memberof' => [ + 0 => [ + 'dn' => 'cn=ldaptester,ou=groups,dc=example,dc=com', + 'memberof' => [ 'count' => 1, 0 => 'cn=monsters,ou=groups,dc=example,dc=com', ], @@ -392,15 +487,60 @@ public function test_dump_user_groups_shows_group_related_details_as_json() ], ], 'parsed_direct_user_groups' => [ - 'ldaptester', + 'cn=ldaptester,ou=groups,dc=example,dc=com', ], 'parsed_recursive_user_groups' => [ + 'cn=ldaptester,ou=groups,dc=example,dc=com', + 'cn=monsters,ou=groups,dc=example,dc=com', + ], + 'parsed_resulting_group_names' => [ 'ldaptester', 'monsters', ], ]); } + public function test_recursive_group_search_queries_via_full_dn() + { + app('config')->set([ + 'services.ldap.user_to_groups' => true, + 'services.ldap.group_attribute' => 'memberOf', + ]); + + $userResp = ['count' => 1, 0 => [ + 'uid' => [$this->mockUser->name], + 'cn' => [$this->mockUser->name], + 'dn' => 'dc=test,' . config('services.ldap.base_dn'), + 'mail' => [$this->mockUser->email], + ]]; + $groupResp = ['count' => 1, + 0 => [ + 'dn' => 'dc=test,' . config('services.ldap.base_dn'), + 'memberof' => [ + 'count' => 1, + 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com', + ], + ], + ]; + + $this->commonLdapMocks(1, 1, 3, 4, 2, 1); + + $escapedName = ldap_escape($this->mockUser->name); + $this->mockLdap->shouldReceive('searchAndGetEntries')->twice() + ->with($this->resourceId, config('services.ldap.base_dn'), "(&(uid={$escapedName}))", \Mockery::type('array')) + ->andReturn($userResp, $groupResp); + + $this->mockLdap->shouldReceive('read')->times(1) + ->with($this->resourceId, 'cn=ldaptester,ou=groups,dc=example,dc=com', '(objectClass=*)', ['memberof']) + ->andReturn(['count' => 0]); + $this->mockLdap->shouldReceive('getEntries')->times(1) + ->with($this->resourceId, ['count' => 0]) + ->andReturn(['count' => 0]); + + $resp = $this->mockUserLogin(); + $resp->assertRedirect('/'); + } + public function test_external_auth_id_visible_in_roles_page_when_ldap_active() { $role = Role::factory()->create(['display_name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']); @@ -419,8 +559,8 @@ public function test_login_maps_roles_using_external_auth_ids_if_set() 'services.ldap.remove_from_groups' => true, ]); - $this->commonLdapMocks(1, 1, 3, 4, 3, 2); - $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3) + $this->commonLdapMocks(1, 1, 3, 4, 2, 1, 1); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], @@ -460,8 +600,8 @@ public function test_login_group_mapping_does_not_conflict_with_default_role() 'services.ldap.remove_from_groups' => true, ]); - $this->commonLdapMocks(1, 1, 4, 5, 4, 6); - $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) + $this->commonLdapMocks(1, 1, 4, 5, 2, 2, 2); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], @@ -513,6 +653,33 @@ public function test_login_uses_specified_display_name_attribute() $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => 'displayNameAttribute']); } + public function test_login_uses_multiple_display_properties_if_defined() + { + app('config')->set([ + 'services.ldap.display_name_attribute' => 'firstname|middlename|noname|lastname', + ]); + + $this->commonLdapMocks(1, 1, 1, 2, 1); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) + ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) + ->andReturn(['count' => 1, 0 => [ + 'uid' => [$this->mockUser->name], + 'cn' => [$this->mockUser->name], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), + 'firstname' => ['Barry'], + 'middlename' => ['Elliott'], + 'lastname' => ['Chuckle'], + 'mail' => [$this->mockUser->email], + ]]); + + $this->mockUserLogin(); + + $this->assertDatabaseHas('users', [ + 'email' => $this->mockUser->email, + 'name' => 'Barry Elliott Chuckle', + ]); + } + public function test_login_uses_default_display_name_attribute_if_specified_not_present() { app('config')->set([ @@ -700,9 +867,9 @@ public function test_login_with_email_confirmation_required_maps_groups_but_show 'services.ldap.remove_from_groups' => true, ]); - $this->commonLdapMocks(1, 1, 6, 8, 6, 4); + $this->commonLdapMocks(1, 1, 6, 8, 4, 2, 2); $this->mockLdap->shouldReceive('searchAndGetEntries') - ->times(6) + ->times(4) ->andReturn(['count' => 1, 0 => [ 'uid' => [$user->name], 'cn' => [$user->name], @@ -767,4 +934,34 @@ public function test_thumbnail_attribute_used_as_user_avatar_if_configured() $this->assertNotNull($user->avatar); $this->assertEquals('8c90748342f19b195b9c6b4eff742ded', md5_file(public_path($user->avatar->path))); } + + public function test_tls_ca_cert_option_throws_if_set_to_invalid_location() + { + $path = 'non_found_' . time(); + config()->set(['services.ldap.tls_ca_cert' => $path]); + + $this->commonLdapMocks(0, 0, 0, 0, 0); + + $this->assertThrows(function () { + $this->withoutExceptionHandling()->mockUserLogin(); + }, LdapException::class, "Provided path [{$path}] for LDAP TLS CA certs could not be resolved to an existing location"); + } + + public function test_tls_ca_cert_option_used_if_set_to_a_folder() + { + $path = $this->files->testFilePath(''); + config()->set(['services.ldap.tls_ca_cert' => $path]); + + $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTDIR, rtrim($path, '/'))->andReturn(true); + $this->runFailedAuthLogin(); + } + + public function test_tls_ca_cert_option_used_if_set_to_a_file() + { + $path = $this->files->testFilePath('test-file.txt'); + config()->set(['services.ldap.tls_ca_cert' => $path]); + + $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTFILE, $path)->andReturn(true); + $this->runFailedAuthLogin(); + } } diff --git a/tests/Auth/MfaConfigurationTest.php b/tests/Auth/MfaConfigurationTest.php index 1f359b41a10..5184bf9843c 100644 --- a/tests/Auth/MfaConfigurationTest.php +++ b/tests/Auth/MfaConfigurationTest.php @@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use Illuminate\Support\Facades\Hash; use PragmaRX\Google2FA\Google2FA; use Tests\TestCase; @@ -166,6 +167,36 @@ public function test_remove_mfa_method() $this->assertEquals(0, $admin->mfaValues()->count()); } + public function test_mfa_required_if_set_on_role() + { + $user = $this->users->viewer(); + $user->password = Hash::make('password'); + $user->save(); + /** @var Role $role */ + $role = $user->roles()->first(); + $role->mfa_enforced = true; + $role->save(); + + $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']); + $this->assertFalse(auth()->check()); + $resp->assertRedirect('/mfa/verify'); + } + + public function test_mfa_required_if_mfa_option_configured() + { + $user = $this->users->viewer(); + $user->password = Hash::make('password'); + $user->save(); + $user->mfaValues()->create([ + 'method' => MfaValue::METHOD_TOTP, + 'value' => 'test', + ]); + + $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']); + $this->assertFalse(auth()->check()); + $resp->assertRedirect('/mfa/verify'); + } + public function test_totp_setup_url_shows_correct_user_when_setup_forced_upon_login() { $admin = $this->users->admin(); diff --git a/tests/Auth/MfaVerificationTest.php b/tests/Auth/MfaVerificationTest.php index 2fa272e331c..967be1c6a61 100644 --- a/tests/Auth/MfaVerificationTest.php +++ b/tests/Auth/MfaVerificationTest.php @@ -57,6 +57,36 @@ public function test_totp_verification_fails_on_missing_invalid_code() $this->assertNull(auth()->user()); } + public function test_totp_form_has_autofill_configured() + { + [$user, $secret, $loginResp] = $this->startTotpLogin(); + $html = $this->withHtml($this->get('/mfa/verify')); + + $html->assertElementExists('form[autocomplete="off"][action$="/verify"]'); + $html->assertElementExists('input[autocomplete="one-time-code"][name="code"]'); + } + + public function test_totp_verification_is_rate_limited() + { + [$user, $secret, $loginResp] = $this->startTotpLogin(); + $loginService = $this->app->make(LoginService::class); + + $resp = $this->get('/mfa/verify'); + for ($i = 0; $i < 5; $i++) { + $this->post('/mfa/totp/verify', [ + 'code' => '123456', + ])->assertRedirect('/mfa/verify'); + $this->assertNotNull($loginService->getLastLoginAttemptUser()); + } + + $resp = $this->post('/mfa/totp/verify', [ + 'code' => '123456', + ]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Too many multi-factor verification attempts. Please try again in 60 seconds.'); + $this->assertNull($loginService->getLastLoginAttemptUser()); + } + public function test_backup_code_verification() { [$user, $codes, $loginResp] = $this->startBackupCodeLogin(); @@ -138,6 +168,36 @@ public function test_backup_code_verification_shows_warning_when_limited_codes_r $resp->assertSeeText('You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.'); } + public function test_backup_code_verification_is_rate_limited() + { + [$user, $codes, $loginResp] = $this->startBackupCodeLogin(['abc12-def45', 'abc12-def46']); + $loginService = $this->app->make(LoginService::class); + + $resp = $this->get('/mfa/verify'); + for ($i = 0; $i < 5; $i++) { + $this->post('/mfa/backup_codes/verify', [ + 'code' => '123456abcd', + ])->assertRedirect('/mfa/verify'); + $this->assertNotNull($loginService->getLastLoginAttemptUser()); + } + + $resp = $this->post('/mfa/backup_codes/verify', [ + 'code' => '123456abcd', + ]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Too many multi-factor verification attempts. Please try again in 60 seconds.'); + $this->assertNull($loginService->getLastLoginAttemptUser()); + } + + public function test_backup_code_form_has_autofill_configured() + { + [$user, $codes, $loginResp] = $this->startBackupCodeLogin(); + $html = $this->withHtml($this->get('/mfa/verify')); + + $html->assertElementExists('form[autocomplete="off"][action$="/verify"]'); + $html->assertElementExists('input[autocomplete="one-time-code"][name="code"]'); + } + public function test_both_mfa_options_available_if_set_on_profile() { $user = $this->users->editor(); diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index dbf26f1bd30..0fcfdc5ff87 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -5,6 +5,7 @@ use BookStack\Activity\ActivityType; use BookStack\Facades\Theme; use BookStack\Theming\ThemeEvents; +use BookStack\Uploads\UserAvatars; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; use GuzzleHttp\Psr7\Response; @@ -37,9 +38,11 @@ protected function setUp(): void 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), 'oidc.authorization_endpoint' => 'https://oidc.local/auth', 'oidc.token_endpoint' => 'https://oidc.local/token', + 'oidc.userinfo_endpoint' => 'https://oidc.local/userinfo', 'oidc.discover' => false, 'oidc.dump_user_details' => false, 'oidc.additional_scopes' => '', + 'odic.fetch_avatar' => false, 'oidc.user_to_groups' => false, 'oidc.groups_claim' => 'group', 'oidc.remove_from_groups' => false, @@ -135,7 +138,7 @@ public function test_login_success_flow() { // Start auth $this->post('/oidc/login'); - $state = session()->get('oidc_state'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; $transactions = $this->mockHttpClient([$this->getMockAuthorizationResponse([ 'email' => 'benny@example.com', @@ -187,6 +190,35 @@ public function test_callback_fails_if_no_state_present_or_matching() $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); } + public function test_callback_works_even_if_other_request_made_by_session() + { + $this->mockHttpClient([$this->getMockAuthorizationResponse([ + 'email' => 'benny@example.com', + 'sub' => 'benny1010101', + ])]); + + $this->post('/oidc/login'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; + + $this->get('/'); + + $resp = $this->get("/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state={$state}"); + $resp->assertRedirect('/'); + } + + public function test_callback_fails_if_state_timestamp_is_too_old() + { + $this->post('/oidc/login'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; + session()->put('oidc_state', (time() - 60 * 4) . ':' . $state); + + $this->get('/'); + + $resp = $this->get("/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state={$state}"); + $resp->assertRedirect('/login'); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + } + public function test_dump_user_details_option_outputs_as_expected() { config()->set('oidc.dump_user_details', true); @@ -208,6 +240,8 @@ public function test_dump_user_details_option_outputs_as_expected() public function test_auth_fails_if_no_email_exists_in_user_data() { + config()->set('oidc.userinfo_endpoint', null); + $this->runLogin([ 'email' => '', 'sub' => 'benny505', @@ -270,10 +304,38 @@ public function test_auth_login_with_invalid_token_fails() ]); $resp = $this->followRedirects($resp); - $resp->assertSeeText('ID token validate failed with error: Missing token subject value'); + $resp->assertSeeText('ID token validation failed with error: Missing token subject value'); $this->assertFalse(auth()->check()); } + public function test_auth_fails_if_endpoints_start_with_https() + { + $endpointConfigKeys = [ + 'oidc.token_endpoint' => 'tokenEndpoint', + 'oidc.authorization_endpoint' => 'authorizationEndpoint', + 'oidc.userinfo_endpoint' => 'userinfoEndpoint', + ]; + + foreach ($endpointConfigKeys as $endpointConfigKey => $endpointName) { + $logger = $this->withTestLogger(); + $original = config()->get($endpointConfigKey); + $new = str_replace('https://', 'http://', $original); + config()->set($endpointConfigKey, $new); + + $this->withoutExceptionHandling(); + $err = null; + try { + $resp = $this->runLogin(); + $resp->assertRedirect('/login'); + } catch (\Exception $exception) { + $err = $exception; + } + $this->assertEquals("Endpoint value for \"{$endpointName}\" must start with https://", $err->getMessage()); + + config()->set($endpointConfigKey, $original); + } + } + public function test_auth_login_with_autodiscovery() { $this->withAutodiscovery(); @@ -409,7 +471,28 @@ public function test_auth_uses_configured_external_id_claim_option() $this->assertEquals('xXBennyTheGeezXx', $user->external_auth_id); } - public function test_auth_uses_mulitple_display_name_claims_if_configured() + public function test_auth_uses_external_id_as_exact_value() + { + // External auth id the same as we expect below but different casing + User::query()->forceCreate([ + 'email' => 'otheruser@example.com', + 'external_auth_id' => 'Benni202', + 'email_confirmed' => true, + 'name' => 'Barry Scott', + ]); + + $resp = $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benni202', + ]); + $resp->assertRedirect('/'); + + $this->assertDatabaseHas('users', ['email' => 'benny@example.com']); + $this->assertEquals('benny@example.com', user()->email); + $this->assertEquals('benni202', user()->external_auth_id); + } + + public function test_auth_uses_multiple_display_name_claims_if_configured() { config()->set(['oidc.display_name_claims' => 'first_name|last_name']); @@ -426,6 +509,105 @@ public function test_auth_uses_mulitple_display_name_claims_if_configured() ]); } + public function test_user_avatar_fetched_from_picture_on_first_login_if_enabled() + { + config()->set(['oidc.fetch_avatar' => true]); + + $this->runLogin([ + 'email' => 'avatar@example.com', + 'picture' => 'https://example.com/my-avatar.jpg', + ], [ + new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData()) + ]); + + $user = User::query()->where('email', '=', 'avatar@example.com')->first(); + $this->assertNotNull($user); + + $this->assertTrue($user->avatar()->exists()); + } + + public function test_user_avatar_fetched_for_existing_user_when_no_avatar_already_assigned() + { + config()->set(['oidc.fetch_avatar' => true]); + $editor = $this->users->editor(); + $editor->external_auth_id = 'benny509'; + $editor->save(); + + $this->assertFalse($editor->avatar()->exists()); + + $this->runLogin([ + 'picture' => 'https://example.com/my-avatar.jpg', + 'sub' => 'benny509', + ], [ + new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData()) + ]); + + $editor->refresh(); + $this->assertTrue($editor->avatar()->exists()); + } + + public function test_user_avatar_not_fetched_if_image_data_format_unknown() + { + config()->set(['oidc.fetch_avatar' => true]); + + $this->runLogin([ + 'email' => 'avatar-format@example.com', + 'picture' => 'https://example.com/my-avatar.jpg', + ], [ + new Response(200, ['Content-Type' => 'image/jpeg'], str_repeat('abc123', 5)) + ]); + + $user = User::query()->where('email', '=', 'avatar-format@example.com')->first(); + $this->assertNotNull($user); + + $this->assertFalse($user->avatar()->exists()); + } + + public function test_user_avatar_not_fetched_when_avatar_already_assigned() + { + config()->set(['oidc.fetch_avatar' => true]); + $editor = $this->users->editor(); + $editor->external_auth_id = 'benny509'; + $editor->save(); + + $avatars = $this->app->make(UserAvatars::class); + $originalImageData = $this->files->pngImageData(); + $avatars->assignToUserFromExistingData($editor, $originalImageData, 'png'); + + $this->runLogin([ + 'picture' => 'https://example.com/my-avatar.jpg', + 'sub' => 'benny509', + ], [ + new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData()) + ]); + + $editor->refresh(); + $newAvatarData = file_get_contents($this->files->relativeToFullPath($editor->avatar->path)); + $this->assertEquals($originalImageData, $newAvatarData); + } + + public function test_user_avatar_fetch_follows_up_to_three_redirects() + { + config()->set(['oidc.fetch_avatar' => true]); + + $logger = $this->withTestLogger(); + + $this->runLogin([ + 'email' => 'avatar@example.com', + 'picture' => 'https://example.com/my-avatar.jpg', + ], [ + new Response(302, ['Location' => 'https://example.com/a']), + new Response(302, ['Location' => 'https://example.com/b']), + new Response(302, ['Location' => 'https://example.com/c']), + new Response(302, ['Location' => 'https://example.com/d']), + ]); + + $user = User::query()->where('email', '=', 'avatar@example.com')->first(); + $this->assertFalse($user->avatar()->exists()); + + $this->assertStringContainsString('"Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: https://example.com/c"', $logger->getRecords()[0]->formatted); + } + public function test_login_group_sync() { config()->set([ @@ -594,10 +776,16 @@ public function test_logout_redirect_contains_id_token_hint_if_existing() { config()->set(['oidc.end_session_endpoint' => 'https://example.com/logout']); - $this->runLogin(); + // Fix times so our token is predictable + $claimOverrides = [ + 'iat' => time(), + 'exp' => time() + 720, + 'auth_time' => time() + ]; + $this->runLogin($claimOverrides); $resp = $this->asEditor()->post('/oidc/logout'); - $query = 'id_token_hint=' . urlencode(OidcJwtHelper::idToken()) . '&post_logout_redirect_uri=' . urlencode(url('/')); + $query = 'id_token_hint=' . urlencode(OidcJwtHelper::idToken($claimOverrides)) . '&post_logout_redirect_uri=' . urlencode(url('/')); $resp->assertRedirect('https://example.com/logout?' . $query); } @@ -655,22 +843,242 @@ public function test_oidc_id_token_pre_validate_theme_event_with_return() ]); } - protected function withAutodiscovery() + public function test_oidc_auth_pre_redirect_theme_event_with_return() + { + $args = []; + $callback = function (...$eventArgs) use (&$args) { + $args = $eventArgs; + return 'https://cats.example.com?beans=true'; + }; + Theme::listen(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $callback); + + $resp = $this->post('/oidc/login'); + $resp->assertRedirect('https://cats.example.com?beans=true'); + + $this->assertCount(1, $args); + $this->assertStringStartsWith('https://oidc.local/auth', $args[0]); + } + + public function test_oidc_auth_pre_redirect_theme_event_with_no_return() + { + $callback = function ($redirectUrl) { + $redirectUrl = 'cat'; + }; + Theme::listen(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $callback); + + $resp = $this->post('/oidc/login'); + $redirect = $resp->headers->get('Location'); + $this->assertStringStartsWith('https://oidc.local/auth?', $redirect); + } + + public function test_pkce_used_on_authorize_and_access() + { + // Start auth + $resp = $this->post('/oidc/login'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; + + $pkceCode = session()->get('oidc_pkce_code'); + $this->assertGreaterThan(30, strlen($pkceCode)); + + $expectedCodeChallenge = trim(strtr(base64_encode(hash('sha256', $pkceCode, true)), '+/', '-_'), '='); + $redirect = $resp->headers->get('Location'); + $redirectParams = []; + parse_str(parse_url($redirect, PHP_URL_QUERY), $redirectParams); + $this->assertEquals($expectedCodeChallenge, $redirectParams['code_challenge']); + $this->assertEquals('S256', $redirectParams['code_challenge_method']); + + $transactions = $this->mockHttpClient([$this->getMockAuthorizationResponse([ + 'email' => 'benny@example.com', + 'sub' => 'benny1010101', + ])]); + + $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + $tokenRequest = $transactions->latestRequest(); + $bodyParams = []; + parse_str($tokenRequest->getBody(), $bodyParams); + $this->assertEquals($pkceCode, $bodyParams['code_verifier']); + } + + public function test_userinfo_endpoint_used_if_missing_claims_in_id_token() + { + config()->set('oidc.display_name_claims', 'first_name|last_name'); + $this->post('/oidc/login'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; + + $client = $this->mockHttpClient([ + $this->getMockAuthorizationResponse(['name' => null]), + new Response(200, [ + 'Content-Type' => 'application/json', + ], json_encode([ + 'sub' => OidcJwtHelper::defaultPayload()['sub'], + 'first_name' => 'Barry', + 'last_name' => 'Userinfo', + ])) + ]); + + $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + $resp->assertRedirect('/'); + $this->assertEquals(2, $client->requestCount()); + + $userinfoRequest = $client->requestAt(1); + $this->assertEquals('GET', $userinfoRequest->getMethod()); + $this->assertEquals('https://oidc.local/userinfo', (string) $userinfoRequest->getUri()); + + $this->assertEquals('Barry Userinfo', user()->name); + } + + public function test_userinfo_endpoint_fetch_with_different_sub_throws_error() + { + $userinfoResponseData = ['sub' => 'dcba4321']; + $userinfoResponse = new Response(200, ['Content-Type' => 'application/json'], json_encode($userinfoResponseData)); + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: Subject value provided in the userinfo endpoint does not match the provided ID token value'); + } + + public function test_userinfo_endpoint_fetch_returning_no_sub_throws_error() + { + $userinfoResponseData = ['name' => 'testing']; + $userinfoResponse = new Response(200, ['Content-Type' => 'application/json'], json_encode($userinfoResponseData)); + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: No valid subject value found in userinfo data'); + } + + public function test_userinfo_endpoint_fetch_can_parsed_nested_groups() + { + config()->set([ + 'oidc.user_to_groups' => true, + 'oidc.groups_claim' => 'my.nested.groups.attr', + 'oidc.remove_from_groups' => false, + ]); + + $roleA = Role::factory()->create(['display_name' => 'Ducks']); + $userinfoResponseData = [ + 'sub' => OidcJwtHelper::defaultPayload()['sub'], + 'my' => ['nested' => ['groups' => ['attr' => ['Ducks', 'Donkeys']]]] + ]; + $userinfoResponse = new Response(200, ['Content-Type' => 'application/json'], json_encode($userinfoResponseData)); + $resp = $this->runLogin(['groups' => null], [$userinfoResponse]); + $resp->assertRedirect('/'); + + $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first(); + $this->assertTrue($user->hasRole($roleA->id)); + } + + public function test_userinfo_endpoint_response_with_complex_json_content_type_handled() + { + $userinfoResponseData = [ + 'sub' => OidcJwtHelper::defaultPayload()['sub'], + 'name' => 'Barry', + ]; + $userinfoResponse = new Response(200, ['Content-Type' => 'Application/Json ; charset=utf-8'], json_encode($userinfoResponseData)); + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/'); + + $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first(); + $this->assertEquals('Barry', $user->name); + } + + public function test_userinfo_endpoint_jwks_response_handled() + { + $userinfoResponseData = OidcJwtHelper::idToken(['name' => 'Barry Jwks']); + $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], $userinfoResponseData); + + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/'); + + $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first(); + $this->assertEquals('Barry Jwks', $user->name); + } + + public function test_userinfo_endpoint_jwks_response_returning_no_sub_throws() + { + $userinfoResponseData = OidcJwtHelper::idToken(['sub' => null]); + $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], $userinfoResponseData); + + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: No valid subject value found in userinfo data'); + } + + public function test_userinfo_endpoint_jwks_response_returning_non_matching_sub_throws() + { + $userinfoResponseData = OidcJwtHelper::idToken(['sub' => 'zzz123']); + $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], $userinfoResponseData); + + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: Subject value provided in the userinfo endpoint does not match the provided ID token value'); + } + + public function test_userinfo_endpoint_jwks_response_with_invalid_signature_throws() + { + $userinfoResponseData = OidcJwtHelper::idToken(); + $exploded = explode('.', $userinfoResponseData); + $exploded[2] = base64_encode(base64_decode($exploded[2]) . 'ABC'); + $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], implode('.', $exploded)); + + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: Token signature could not be validated using the provided keys'); + } + + public function test_userinfo_endpoint_jwks_response_with_invalid_signature_alg_throws() + { + $userinfoResponseData = OidcJwtHelper::idToken([], ['alg' => 'ZZ512']); + $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], $userinfoResponseData); + + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: Only RS256 signature validation is supported. Token reports using ZZ512'); + } + + public function test_userinfo_endpoint_response_with_invalid_content_type_throws() + { + $userinfoResponse = new Response(200, ['Content-Type' => 'application/beans'], json_encode(OidcJwtHelper::defaultPayload())); + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: No valid subject value found in userinfo data'); + } + + public function test_userinfo_endpoint_not_called_if_empty_groups_array_provided_in_id_token() + { + config()->set([ + 'oidc.user_to_groups' => true, + 'oidc.groups_claim' => 'groups', + 'oidc.remove_from_groups' => false, + ]); + + $this->post('/oidc/login'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; + $client = $this->mockHttpClient([$this->getMockAuthorizationResponse([ + 'groups' => [], + ])]); + + $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + $resp->assertRedirect('/'); + $this->assertEquals(1, $client->requestCount()); + $this->assertTrue(auth()->check()); + } + + protected function withAutodiscovery(): void { config()->set([ 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), 'oidc.discover' => true, 'oidc.authorization_endpoint' => null, 'oidc.token_endpoint' => null, + 'oidc.userinfo_endpoint' => null, 'oidc.jwt_public_key' => null, ]); } - protected function runLogin($claimOverrides = []): TestResponse + protected function runLogin($claimOverrides = [], $additionalHttpResponses = []): TestResponse { $this->post('/oidc/login'); - $state = session()->get('oidc_state'); - $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]); + $state = explode(':', session()->get('oidc_state'), 2)[1] ?? ''; + $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides), ...$additionalHttpResponses]); return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); } @@ -684,6 +1092,7 @@ protected function getAutoDiscoveryResponse($responseOverrides = []): Response ], json_encode(array_merge([ 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token', 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize', + 'userinfo_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/userinfo', 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', 'issuer' => OidcJwtHelper::defaultIssuer(), 'end_session_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/logout', diff --git a/tests/Auth/RegistrationTest.php b/tests/Auth/RegistrationTest.php index ff1a9d66b11..e0d7c262682 100644 --- a/tests/Auth/RegistrationTest.php +++ b/tests/Auth/RegistrationTest.php @@ -25,6 +25,9 @@ public function test_confirmed_registration() $resp->assertRedirect('/register/confirm'); $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]); + $resp = $this->get('/register/confirm'); + $resp->assertSee('Thanks for registering!'); + // Ensure notification sent /** @var User $dbUser */ $dbUser = User::query()->where('email', '=', $user->email)->first(); @@ -184,4 +187,131 @@ public function test_registration_validation() $resp->assertSee('The email must be a valid email address.'); $resp->assertSee('The password must be at least 8 characters.'); } + + public function test_registration_input_filtered_to_validated_input() + { + $this->setSettings(['registration-enabled' => 'true']); + $roleIds = Role::all()->pluck('id')->toArray(); + + $resp = $this->post('/register', [ + 'name' => 'Barry', + 'email' => 'barry@example.com', + 'password' => 'superpassword', + 'password_confirmation' => 'superpassword', + 'external_auth_id' => 'ext5691284', + 'roles' => $roleIds, + ]); + + $resp->assertRedirect('/'); + + /** @var User $user */ + $user = auth()->user(); + $this->assertNotNull($user); + $this->assertFalse($user->isGuest()); + $this->assertEmpty($user->external_auth_id); + $this->assertEquals(0, $user->roles()->count()); + } + + public function test_registration_simple_honeypot_active() + { + $this->setSettings(['registration-enabled' => 'true']); + + $resp = $this->get('/register'); + $this->withHtml($resp)->assertElementExists('form input[name="username"]'); + + $resp = $this->post('/register', [ + 'name' => 'Barry', + 'email' => 'barrybot@example.com', + 'password' => 'barryIsTheBestBot', + 'username' => 'MyUsername' + ]); + $resp->assertRedirect('/register'); + + $resp = $this->followRedirects($resp); + $this->withHtml($resp)->assertElementExists('form input[name="username"].text-neg'); + } + + public function test_registration_endpoint_throttled() + { + $this->setSettings(['registration-enabled' => 'true']); + + for ($i = 0; $i < 11; $i++) { + $response = $this->post('/register/', [ + 'name' => "Barry{$i}", + 'email' => "barry{$i}@example.com", + 'password' => "barryIsTheBest{$i}", + ]); + auth()->logout(); + } + + $response->assertStatus(429); + } + + public function test_registration_confirmation_throttled() + { + $this->setSettings(['registration-enabled' => 'true']); + + for ($i = 0; $i < 11; $i++) { + $response = $this->post('/register/confirm/accept', [ + 'token' => "token{$i}", + ]); + } + + $response->assertStatus(429); + } + + public function test_registration_confirmation_resend() + { + Notification::fake(); + $this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'true']); + $user = User::factory()->make(); + + $resp = $this->post('/register', $user->only('name', 'email', 'password')); + $resp->assertRedirect('/register/confirm'); + $dbUser = User::query()->where('email', '=', $user->email)->first(); + + $resp = $this->post('/login', ['email' => $user->email, 'password' => $user->password]); + $resp->assertRedirect('/register/confirm/awaiting'); + + $resp = $this->post('/register/confirm/resend'); + $resp->assertRedirect('/register/confirm'); + Notification::assertSentToTimes($dbUser, ConfirmEmailNotification::class, 2); + } + + public function test_registration_confirmation_expired_resend() + { + Notification::fake(); + $this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'true']); + $user = User::factory()->make(); + + $resp = $this->post('/register', $user->only('name', 'email', 'password')); + $resp->assertRedirect('/register/confirm'); + $dbUser = User::query()->where('email', '=', $user->email)->first(); + + $resp = $this->post('/login', ['email' => $user->email, 'password' => $user->password]); + $resp->assertRedirect('/register/confirm/awaiting'); + + $emailConfirmation = DB::table('email_confirmations')->where('user_id', '=', $dbUser->id)->first(); + $this->travel(2)->days(); + + $resp = $this->post("/register/confirm/accept", [ + 'token' => $emailConfirmation->token, + ]); + $resp->assertRedirect('/register/confirm'); + $this->assertSessionError('The confirmation token has expired, A new confirmation email has been sent.'); + + Notification::assertSentToTimes($dbUser, ConfirmEmailNotification::class, 2); + } + + public function test_registration_confirmation_awaiting_and_resend_returns_to_log_if_no_login_attempt_user_found() + { + $this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'true']); + + $this->get('/register/confirm/awaiting')->assertRedirect('/login'); + $this->assertSessionError('A user for this action could not be found.'); + $this->flushSession(); + + $this->post('/register/confirm/resend')->assertRedirect('/login'); + $this->assertSessionError('A user for this action could not be found.'); + } } diff --git a/tests/Auth/ResetPasswordTest.php b/tests/Auth/ResetPasswordTest.php index e60ac5643ac..026f8c5ba3f 100644 --- a/tests/Auth/ResetPasswordTest.php +++ b/tests/Auth/ResetPasswordTest.php @@ -4,11 +4,19 @@ use BookStack\Access\Notifications\ResetPasswordNotification; use BookStack\Users\Models\User; +use Carbon\CarbonInterval; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Sleep; use Tests\TestCase; class ResetPasswordTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + Sleep::fake(); + } + public function test_reset_flow() { Notification::fake(); @@ -75,6 +83,17 @@ public function test_reset_flow_shows_success_message_even_if_wrong_password_to_ ->assertSee('The password reset token is invalid for this email address.'); } + public function test_reset_request_with_not_found_user_still_has_delay() + { + $this->followingRedirects()->post('/password/email', [ + 'email' => 'barrynotfoundrandomuser@example.com', + ]); + + Sleep::assertSlept(function (CarbonInterval $duration): bool { + return $duration->totalMilliseconds > 999; + }, 1); + } + public function test_reset_page_shows_sign_links() { $this->setSettings(['registration-enabled' => 'true']); @@ -95,7 +114,30 @@ public function test_reset_request_is_throttled() $resp = $this->followingRedirects()->post('/password/email', [ 'email' => $editor->email, ]); - Notification::assertTimesSent(1, ResetPasswordNotification::class); + Notification::assertSentTimes(ResetPasswordNotification::class, 1); $resp->assertSee('A password reset link will be sent to ' . $editor->email . ' if that email address is found in the system.'); } + + public function test_reset_request_with_not_found_user_is_throttled() + { + for ($i = 0; $i < 11; $i++) { + $response = $this->post('/password/email', [ + 'email' => 'barrynotfoundrandomuser@example.com', + ]); + } + + $response->assertStatus(429); + } + + public function test_reset_call_is_throttled() + { + for ($i = 0; $i < 11; $i++) { + $response = $this->post('/password/reset', [ + 'email' => "arandomuser{$i}@example.com", + 'token' => "randomtoken{$i}", + ]); + } + + $response->assertStatus(429); + } } diff --git a/tests/Auth/Saml2Test.php b/tests/Auth/Saml2Test.php index 3de6238edc8..fbaffceb38e 100644 --- a/tests/Auth/Saml2Test.php +++ b/tests/Auth/Saml2Test.php @@ -36,7 +36,7 @@ protected function setUp(): void public function test_metadata_endpoint_displays_xml_as_expected() { $req = $this->get('/saml2/metadata'); - $req->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + $req->assertHeader('Content-Type', 'text/xml; charset=utf-8'); $req->assertSee('md:EntityDescriptor'); $req->assertSee(url('/saml2/acs')); } @@ -51,7 +51,7 @@ public function test_metadata_endpoint_loads_when_autoloading_with_bad_url_set() $req = $this->get('/saml2/metadata'); $req->assertOk(); - $req->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + $req->assertHeader('Content-Type', 'text/xml; charset=utf-8'); $req->assertSee('md:EntityDescriptor'); } @@ -419,6 +419,29 @@ public function test_login_where_existing_non_saml_user_shows_warning() $acsPost->assertSee('A user with the email user@example.com already exists but with different credentials'); } + public function test_login_uses_exact_match_for_external_auth_values() + { + $this->post('/saml2/login'); + config()->set(['saml2.onelogin.strict' => false]); + + // Make the user pre-existing in DB with auth_id of different casing + User::query()->forceCreate([ + 'email' => 'otheruser@example.com', + 'external_auth_id' => 'UsEr', + 'email_confirmed' => true, + 'name' => 'Barry Scott', + ]); + + $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); + + $this->assertTrue($this->isAuthenticated()); + $this->assertDatabaseHas('users', [ + 'email' => 'user@example.com', + ]); + $this->assertEquals('user@example.com', user()->email); + $this->assertEquals('user', user()->external_auth_id); + } + public function test_login_request_contains_expected_default_authncontext() { $authReq = $this->getAuthnRequest(); diff --git a/tests/Auth/UserInviteTest.php b/tests/Auth/UserInviteTest.php index a9dee0007f5..434de6aa6e9 100644 --- a/tests/Auth/UserInviteTest.php +++ b/tests/Auth/UserInviteTest.php @@ -137,4 +137,24 @@ public function test_token_expires_after_two_weeks() $setPasswordPageResp->assertRedirect('/password/email'); $setPasswordPageResp->assertSessionHas('error', 'This invitation link has expired. You can instead try to reset your account password.'); } + + public function test_set_password_view_is_throttled() + { + for ($i = 0; $i < 11; $i++) { + $response = $this->get("/register/invite/tokenhere{$i}"); + } + + $response->assertStatus(429); + } + + public function test_set_password_post_is_throttled() + { + for ($i = 0; $i < 11; $i++) { + $response = $this->post("/register/invite/tokenhere{$i}", [ + 'password' => 'my test password', + ]); + } + + $response->assertStatus(429); + } } diff --git a/tests/Commands/AssignSortRuleCommandTest.php b/tests/Commands/AssignSortRuleCommandTest.php new file mode 100644 index 00000000000..934196471a7 --- /dev/null +++ b/tests/Commands/AssignSortRuleCommandTest.php @@ -0,0 +1,112 @@ +createMany(10); + + $commandRun = $this->artisan('bookstack:assign-sort-rule') + ->expectsOutputToContain('Sort rule ID required!') + ->assertExitCode(1); + + foreach ($sortRules as $sortRule) { + $commandRun->expectsOutputToContain("{$sortRule->id}: {$sortRule->name}"); + } + } + + public function test_run_without_options_advises_help() + { + $this->artisan("bookstack:assign-sort-rule 100") + ->expectsOutput("No option provided to specify target. Run with the -h option to see all available options.") + ->assertExitCode(1); + } + + public function test_run_without_valid_sort_advises_help() + { + $this->artisan("bookstack:assign-sort-rule 100342 --all-books") + ->expectsOutput("Sort rule of provided id 100342 not found!") + ->assertExitCode(1); + } + + public function test_confirmation_required() + { + $sortRule = SortRule::factory()->create(); + + $this->artisan("bookstack:assign-sort-rule {$sortRule->id} --all-books") + ->expectsConfirmation('Are you sure you want to continue?', 'no') + ->assertExitCode(1); + + $booksWithSort = Book::query()->whereNotNull('sort_rule_id')->count(); + $this->assertEquals(0, $booksWithSort); + } + + public function test_assign_to_all_books() + { + $sortRule = SortRule::factory()->create(); + $booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count(); + $this->assertGreaterThan(0, $booksWithoutSort); + + $this->artisan("bookstack:assign-sort-rule {$sortRule->id} --all-books") + ->expectsOutputToContain("This will apply sort rule [{$sortRule->id}: {$sortRule->name}] to {$booksWithoutSort} book(s)") + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)") + ->assertExitCode(0); + + $booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count(); + $this->assertEquals(0, $booksWithoutSort); + } + + public function test_assign_to_all_books_without_sort() + { + $totalBooks = Book::query()->count(); + $book = $this->entities->book(); + $sortRuleA = SortRule::factory()->create(); + $sortRuleB = SortRule::factory()->create(); + $book->sort_rule_id = $sortRuleA->id; + $book->save(); + + $booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count(); + $this->assertEquals($totalBooks, $booksWithoutSort + 1); + + $this->artisan("bookstack:assign-sort-rule {$sortRuleB->id} --books-without-sort") + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)") + ->assertExitCode(0); + + $booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count(); + $this->assertEquals(0, $booksWithoutSort); + $this->assertEquals($totalBooks, $sortRuleB->books()->count() + 1); + } + + public function test_assign_to_all_books_with_sort() + { + $book = $this->entities->book(); + $sortRuleA = SortRule::factory()->create(); + $sortRuleB = SortRule::factory()->create(); + $book->sort_rule_id = $sortRuleA->id; + $book->save(); + + $this->artisan("bookstack:assign-sort-rule {$sortRuleB->id} --books-with-sort={$sortRuleA->id}") + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Sort applied to 1 book(s)") + ->assertExitCode(0); + + $book->refresh(); + $this->assertEquals($sortRuleB->id, $book->sort_rule_id); + $this->assertEquals(1, $sortRuleB->books()->count()); + } + + public function test_assign_to_all_books_with_sort_id_is_validated() + { + $this->artisan("bookstack:assign-sort-rule 50 --books-with-sort=beans") + ->expectsOutputToContain("Provided --books-with-sort option value is invalid") + ->assertExitCode(1); + } +} diff --git a/tests/Commands/ClearActivityCommandTest.php b/tests/Commands/ClearActivityCommandTest.php index 410a39aa870..a3d7456b68e 100644 --- a/tests/Commands/ClearActivityCommandTest.php +++ b/tests/Commands/ClearActivityCommandTest.php @@ -18,7 +18,7 @@ public function test_clear_activity_command() $this->assertDatabaseHas('activities', [ 'type' => 'page_update', - 'entity_id' => $page->id, + 'loggable_id' => $page->id, 'user_id' => $this->users->editor()->id, ]); diff --git a/tests/Commands/CopyShelfPermissionsCommandTest.php b/tests/Commands/CopyShelfPermissionsCommandTest.php index 5c21a2e341c..d5f9677a229 100644 --- a/tests/Commands/CopyShelfPermissionsCommandTest.php +++ b/tests/Commands/CopyShelfPermissionsCommandTest.php @@ -2,6 +2,7 @@ namespace Tests\Commands; +use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use Tests\TestCase; @@ -61,4 +62,21 @@ public function test_copy_shelf_permissions_command_using_all() 'view' => true, 'update' => true, 'create' => false, 'delete' => false, ]); } + + public function test_copy_shelf_permissions_command_using_slug_without_interaction() + { + $shelf = $this->entities->shelfHasBooks(); + $editorRole = $this->users->editor()->roles()->first(); + /** @var Book $child */ + $child = $shelf->books()->first(); + $child->shelves()->where('id', '!=', $shelf->id)->delete(); + + $this->assertFalse($child->hasPermissions()); + + $this->permissions->setEntityPermissions($shelf, ['view', 'update'], [$editorRole]); + $this->artisan('bookstack:copy-shelf-permissions --all --no-interaction'); + + $child->refresh(); + $this->assertTrue($child->hasPermissions(), 'Child book should now be restricted'); + } } diff --git a/tests/Commands/CreateAdminCommandTest.php b/tests/Commands/CreateAdminCommandTest.php index 95a39c497e4..f389dd94235 100644 --- a/tests/Commands/CreateAdminCommandTest.php +++ b/tests/Commands/CreateAdminCommandTest.php @@ -2,8 +2,11 @@ namespace Tests\Commands; +use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Tests\TestCase; class CreateAdminCommandTest extends TestCase @@ -11,14 +14,14 @@ class CreateAdminCommandTest extends TestCase public function test_standard_command_usage() { $this->artisan('bookstack:create-admin', [ - '--email' => 'admintest@example.com', - '--name' => 'Admin Test', + '--email' => 'admintest@example.com', + '--name' => 'Admin Test', '--password' => 'testing-4', ])->assertExitCode(0); $this->assertDatabaseHas('users', [ 'email' => 'admintest@example.com', - 'name' => 'Admin Test', + 'name' => 'Admin Test', ]); /** @var User $user */ @@ -30,14 +33,14 @@ public function test_standard_command_usage() public function test_providing_external_auth_id() { $this->artisan('bookstack:create-admin', [ - '--email' => 'admintest@example.com', - '--name' => 'Admin Test', + '--email' => 'admintest@example.com', + '--name' => 'Admin Test', '--external-auth-id' => 'xX_admin_Xx', ])->assertExitCode(0); $this->assertDatabaseHas('users', [ - 'email' => 'admintest@example.com', - 'name' => 'Admin Test', + 'email' => 'admintest@example.com', + 'name' => 'Admin Test', 'external_auth_id' => 'xX_admin_Xx', ]); @@ -50,14 +53,178 @@ public function test_password_required_if_external_auth_id_not_given() { $this->artisan('bookstack:create-admin', [ '--email' => 'admintest@example.com', - '--name' => 'Admin Test', + '--name' => 'Admin Test', ])->expectsQuestion('Please specify a password for the new admin user (8 characters min)', 'hunter2000') ->assertExitCode(0); $this->assertDatabaseHas('users', [ 'email' => 'admintest@example.com', - 'name' => 'Admin Test', + 'name' => 'Admin Test', ]); $this->assertTrue(Auth::attempt(['email' => 'admintest@example.com', 'password' => 'hunter2000'])); } + + public function test_generate_password_option() + { + $this->withoutMockingConsoleOutput() + ->artisan('bookstack:create-admin', [ + '--email' => 'admintest@example.com', + '--name' => 'Admin Test', + '--generate-password' => true, + ]); + + $output = trim(Artisan::output()); + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{32}$/', $output); + + $user = User::query()->where('email', '=', 'admintest@example.com')->first(); + $this->assertTrue(Hash::check($output, $user->password)); + } + + public function test_initial_option_updates_default_admin() + { + $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first(); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'Admin Test', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput('The default admin user has been updated with the provided details!') + ->assertExitCode(0); + + $defaultAdmin->refresh(); + + $this->assertEquals('firstadmin@example.com', $defaultAdmin->email); + } + + public function test_initial_option_does_not_update_if_only_non_default_admin_exists() + { + $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first(); + $defaultAdmin->email = 'testadmin@example.com'; + $defaultAdmin->save(); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'Admin Test', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput('Non-default admin user already exists. Skipping creation of new admin user.') + ->assertExitCode(2); + + $defaultAdmin->refresh(); + + $this->assertEquals('testadmin@example.com', $defaultAdmin->email); + } + + public function test_initial_option_updates_creates_new_admin_if_none_exists() + { + $adminRole = Role::getSystemRole('admin'); + $adminRole->users()->delete(); + $this->assertEquals(0, $adminRole->users()->count()); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Admin account with email \"firstadmin@example.com\" successfully created!") + ->assertExitCode(0); + + $this->assertEquals(1, $adminRole->users()->count()); + $this->assertDatabaseHas('users', [ + 'email' => 'firstadmin@example.com', + 'name' => 'My initial admin', + ]); + } + + public function test_initial_rerun_does_not_error_but_skips() + { + $adminRole = Role::getSystemRole('admin'); + $adminRole->users()->delete(); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Admin account with email \"firstadmin@example.com\" successfully created!") + ->assertExitCode(0); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Non-default admin user already exists. Skipping creation of new admin user.") + ->assertExitCode(2); + } + + public function test_initial_option_creation_errors_if_email_already_exists() + { + $adminRole = Role::getSystemRole('admin'); + $adminRole->users()->delete(); + $editor = $this->users->editor(); + + $this->artisan('bookstack:create-admin', [ + '--email' => $editor->email, + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Could not create admin account.") + ->expectsOutput("An account with the email address \"{$editor->email}\" already exists.") + ->assertExitCode(1); + } + + public function test_initial_option_updating_errors_if_email_already_exists() + { + $editor = $this->users->editor(); + $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first(); + $this->assertNotNull($defaultAdmin); + + $this->artisan('bookstack:create-admin', [ + '--email' => $editor->email, + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Could not create admin account.") + ->expectsOutput("An account with the email address \"{$editor->email}\" already exists.") + ->assertExitCode(1); + } + + public function test_initial_option_does_not_require_name_or_email_to_be_passed() + { + $adminRole = Role::getSystemRole('admin'); + $adminRole->users()->delete(); + $this->assertEquals(0, $adminRole->users()->count()); + + $this->artisan('bookstack:create-admin', [ + '--generate-password' => true, + '--initial' => true, + ])->assertExitCode(0); + + $this->assertEquals(1, $adminRole->users()->count()); + $this->assertDatabaseHas('users', [ + 'email' => 'admin@example.com', + 'name' => 'Admin', + ]); + } + + public function test_initial_option_updating_existing_user_with_generate_password_only_outputs_password() + { + $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first(); + + $this->withoutMockingConsoleOutput() + ->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'Admin Test', + '--generate-password' => true, + '--initial' => true, + ]); + + $output = Artisan::output(); + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{32}$/', $output); + + $defaultAdmin->refresh(); + $this->assertEquals('firstadmin@example.com', $defaultAdmin->email); + } } diff --git a/tests/Commands/InstallModuleCommandTest.php b/tests/Commands/InstallModuleCommandTest.php new file mode 100644 index 00000000000..e96fc02c1a2 --- /dev/null +++ b/tests/Commands/InstallModuleCommandTest.php @@ -0,0 +1,391 @@ +usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + $expectedInstallPath = theme_path('modules/test-module'); + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsOutput("\nThis will install a module from: {$zip}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.") + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') + ->expectsOutput("Install location: {$expectedInstallPath}") + ->assertExitCode(0); + + $this->assertDirectoryExists($expectedInstallPath); + $this->assertFileExists($expectedInstallPath . '/bookstack-module.json'); + }); + } + + public function test_remote_module_install_with_active_theme() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + + $http = $this->mockHttpClient([ + new Response(200, ['Content-Length' => filesize($zip)], file_get_contents($zip)) + ]); + $expectedInstallPath = theme_path('modules/test-module'); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsOutput("\nThis will download a module from: example.com\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.") + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') + ->expectsOutput("Install location: {$expectedInstallPath}") + ->assertExitCode(0); + + $this->assertEquals(1, $http->requestCount()); + $request = $http->requestAt(0); + $this->assertEquals('/test-module.zip', $request->getUri()->getPath()); + + $this->assertDirectoryExists($expectedInstallPath); + $this->assertFileExists($expectedInstallPath . '/bookstack-module.json'); + }); + } + + public function test_remote_http_module_warns_and_prompts_users() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + + $http = $this->mockHttpClient([ + new Response(200, ['Content-Length' => filesize($zip)], file_get_contents($zip)) + ]); + $expectedInstallPath = theme_path('modules/test-module'); + + $this->artisan('bookstack:install-module', ['location' => 'http://example.com/test-module.zip']) + ->expectsOutput("\nThis will download a module from: example.com\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.") + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.") + ->expectsConfirmation('Are you sure you want to continue without HTTPS?', 'yes') + ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') + ->expectsOutput("Install location: {$expectedInstallPath}") + ->assertExitCode(0); + + $request = $http->requestAt(0); + $this->assertEquals('/test-module.zip', $request->getUri()->getPath()); + }); + } + + public function test_remote_module_install_follows_redirects() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + + $http = $this->mockHttpClient([ + new Response(302, ['Location' => 'https://example.com/a-test-module.zip']), + new Response(200, ['Content-Length' => filesize($zip)], file_get_contents($zip)) + ]); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->assertExitCode(0); + + $this->assertEquals(2, $http->requestCount()); + $this->assertEquals('/test-module.zip', $http->requestAt(0)->getUri()->getPath()); + $this->assertEquals('/a-test-module.zip', $http->requestAt(1)->getUri()->getPath()); + }); + } + + public function test_remote_module_install_prompts_on_following_redirects_to_different_origin() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + + $http = $this->mockHttpClient([ + new Response(302, ['Location' => 'http://example.com/a-test-module.zip']), + new Response(301, ['Location' => 'https://a.example.com:8080/a-test-module.zip']), + new Response(200, ['Content-Length' => filesize($zip)], file_get_contents($zip)) + ]); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('The download URL is redirecting to a different site: http://example.com') + ->expectsConfirmation('Do you trust downloading the module from this site?', 'yes') + ->expectsOutput('The download URL is redirecting to a different site: https://a.example.com:8080') + ->expectsConfirmation('Do you trust downloading the module from this site?', 'yes') + ->assertExitCode(0); + + $this->assertEquals(3, $http->requestCount()); + $this->assertEquals('https', $http->requestAt(0)->getUri()->getScheme()); + $this->assertEquals('http', $http->requestAt(1)->getUri()->getScheme()); + $this->assertEquals('a.example.com', $http->requestAt(2)->getUri()->getHost()); + }); + } + + public function test_remote_module_install_redirect_origin_prompt_rejection() + { + $this->usingThemeFolder(function () { + $http = $this->mockHttpClient([ + new Response(302, ['Location' => 'http://example.com/a-test-module.zip']), + new Response(301, ['Location' => 'https://a.example.com:8080/a-test-module.zip']), + ]); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('The download URL is redirecting to a different site: http://example.com') + ->expectsConfirmation('Do you trust downloading the module from this site?', 'no') + ->assertExitCode(1); + + $this->assertEquals(1, $http->requestCount()); + $this->assertEquals('https', $http->requestAt(0)->getUri()->getScheme()); + }); + } + + public function test_remote_module_install_has_redirect_limit() + { + $this->usingThemeFolder(function () { + $http = $this->mockHttpClient([ + new Response(302, ['Location' => 'https://example.com/a-test-module.zip']), + new Response(302, ['Location' => 'https://example.com/b-test-module.zip']), + new Response(302, ['Location' => 'https://example.com/c-test-module.zip']), + new Response(302, ['Location' => 'https://example.com/d-test-module.zip']), + ]); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('ERROR: Failed to download module from https://example.com/test-module.zip') + ->assertExitCode(1); + + $this->assertEquals(4, $http->requestCount()); + $this->assertEquals('/c-test-module.zip', $http->requestAt(3)->getUri()->getPath()); + }); + } + + public function test_remote_module_install_download_failures_are_announced_to_user() + { + $this->usingThemeFolder(function () { + $http = $this->mockHttpClient([ + new Response(404), + ]); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('ERROR: Failed to download module from https://example.com/test-module.zip') + ->expectsOutput('Download failed with status code 404') + ->assertExitCode(1); + $this->assertEquals(1, $http->requestCount()); + }); + } + + public function test_run_with_invalid_path_exits_early() + { + $this->artisan('bookstack:install-module', ['location' => '/not-found.zip']) + ->expectsOutput('ERROR: Module file not found at /not-found.zip') + ->assertExitCode(1); + } + + public function test_run_with_invalid_zip_has_early_exit() + { + $zip = $this->getModuleZipPath(); + file_put_contents($zip, 'invalid zip'); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput("ERROR: Cannot open ZIP file at {$zip}") + ->assertExitCode(1); + } + + public function test_run_with_large_zip_has_early_exit() + { + $zip = $this->getModuleZipPath(null, [ + 'large-file.txt' => str_repeat('a', 1024 * 1024 * 51) + ]); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput("ERROR: Module ZIP file contents are too large. Maximum size is 50MB") + ->assertExitCode(1); + } + + public function test_run_with_invalid_module_data_has_early_exit() + { + $zip = $this->getModuleZipPath([ + 'name' => 'Invalid Module', + 'description' => 'A module with invalid data', + 'version' => 'dog', + ]); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput("ERROR: Failed to read module metadata with error: Module in folder \"_temp\" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0'") + ->assertExitCode(1); + } + + public function test_module_zip_when_files_in_nested_directory() + { + $this->usingThemeFolder(function ($themeFolder) { + $zip = new ZipArchive(); + $zipFile = tempnam(sys_get_temp_dir(), 'bs-test-module'); + $zip->open($zipFile, ZipArchive::CREATE); + + $zip->addEmptyDir('mod'); + $zip->addFromString('mod/bookstack-module.json', json_encode($metadata ?? [ + 'name' => 'Test Module', + 'description' => 'A test module for BookStack', + 'version' => '1.0.0', + ])); + $zip->addFromString('mod/functions.php', 'addEmptyDir('mod/a'); + $zip->addFromString('mod/a/cat.txt', 'Meow'); + $zip->close(); + + $this->artisan('bookstack:install-module', ['location' => $zipFile]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->assertExitCode(0); + + $modulePath = glob(theme_path('modules/*'), GLOB_ONLYDIR)[0]; + $this->assertFileExists($modulePath . '/a/cat.txt'); + $contents = file_get_contents($modulePath . '/a/cat.txt'); + $this->assertEquals('Meow', $contents); + }); + } + + public function test_module_install_negates_zip_slip() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(null, [ + '../parent.txt' => str_repeat('dog', 10) + ]); + + $expectedInstallPath = theme_path('modules/test-module'); + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput("ERROR: Failed to install module with error: Failed to load extract files from module ZIP with error: Bad file path found in module ZIP file: ../parent.txt") + ->assertExitCode(1); + + $this->assertDirectoryDoesNotExist($expectedInstallPath); + }); + } + + public function test_local_module_install_without_active_theme_can_setup_theme_folder() + { + $zip = $this->getModuleZipPath(); + $expectedThemePath = base_path('themes/custom'); + File::deleteDirectory($expectedThemePath); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsConfirmation('No active theme folder found, would you like to create one?', 'yes') + ->expectsOutput("Created theme folder at {$expectedThemePath}") + ->expectsOutput("You will need to set APP_THEME=custom in your BookStack env configuration to enable this theme!") + ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') + ->assertExitCode(0); + + $this->assertDirectoryExists($expectedThemePath . '/modules/test-module'); + + File::deleteDirectory($expectedThemePath); + } + + public function test_local_module_install_with_active_theme_and_conflicting_modules_file_causes_early_exit() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + File::put(theme_path('modules'), '{}'); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput("ERROR: Cannot create a modules folder, file already exists at " . theme_path('modules')) + ->assertExitCode(1); + }); + } + + public function test_single_existing_module_with_same_name_replace() + { + $this->usingThemeFolder(function () { + $original = $this->createModuleFolderInCurrentTheme(['name' => 'Test Module', 'description' => 'cat', 'version' => '1.0.0']); + $new = $this->getModuleZipPath(['name' => 'Test Module', 'description' => '', 'version' => '2.0.0']); + + $this->artisan('bookstack:install-module', ['location' => $new]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput('The following modules already exist with the same name:') + ->expectsOutput('Test Module (test-module:v1.0.0) - cat') + ->expectsChoice('What would you like to do?', 'Replace existing module', ['Cancel module install', 'Add alongside existing module', 'Replace existing module']) + ->expectsOutput("Replacing existing module in test-module folder") + ->assertExitCode(0); + + $this->assertFileExists($original . '/bookstack-module.json'); + $metadata = json_decode(file_get_contents($original . '/bookstack-module.json'), true); + $this->assertEquals('2.0.0', $metadata['version']); + }); + } + + public function test_single_existing_module_with_same_name_cancel() + { + $this->usingThemeFolder(function () { + $original = $this->createModuleFolderInCurrentTheme(['name' => 'Test Module', 'description' => 'cat', 'version' => '1.0.0']); + $new = $this->getModuleZipPath(['name' => 'Test Module', 'description' => '', 'version' => '2.0.0']); + + $this->artisan('bookstack:install-module', ['location' => $new]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput('The following modules already exist with the same name:') + ->expectsOutput('Test Module (test-module:v1.0.0) - cat') + ->expectsChoice('What would you like to do?', 'Cancel module install', ['Cancel module install', 'Add alongside existing module', 'Replace existing module']) + ->assertExitCode(1); + + $this->assertFileExists($original . '/bookstack-module.json'); + $metadata = json_decode(file_get_contents($original . '/bookstack-module.json'), true); + $this->assertEquals('1.0.0', $metadata['version']); + }); + } + + public function test_single_existing_module_with_same_name_add() + { + $this->usingThemeFolder(function () { + $original = $this->createModuleFolderInCurrentTheme(['name' => 'Test Module', 'description' => 'cat', 'version' => '1.0.0']); + $new = $this->getModuleZipPath(['name' => 'Test Module', 'description' => '', 'version' => '2.0.0']); + + $this->artisan('bookstack:install-module', ['location' => $new]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput('The following modules already exist with the same name:') + ->expectsOutput('Test Module (test-module:v1.0.0) - cat') + ->expectsChoice('What would you like to do?', 'Add alongside existing module', ['Cancel module install', 'Add alongside existing module', 'Replace existing module']) + ->assertExitCode(0); + + $dirs = File::directories(theme_path('modules/')); + $this->assertCount(2, $dirs); + }); + } + + protected function createModuleFolderInCurrentTheme(array|null $metadata = null, array $extraFiles = []): string + { + $original = $this->getModuleZipPath($metadata, $extraFiles); + $targetPath = theme_path('modules/test-module'); + mkdir($targetPath, 0777, true); + $originalZip = new ZipArchive(); + $originalZip->open($original); + $originalZip->extractTo($targetPath); + $originalZip->close(); + + return $targetPath; + } + + protected function getModuleZipPath(array|null $metadata = null, array $extraFiles = []): string + { + $zip = new ZipArchive(); + $tmpFile = tempnam(sys_get_temp_dir(), 'bs-test-module'); + $zip->open($tmpFile, ZipArchive::CREATE); + + $zip->addFromString('bookstack-module.json', json_encode($metadata ?? [ + 'name' => 'Test Module', + 'description' => 'A test module for BookStack', + 'version' => '1.0.0', + ])); + + foreach ($extraFiles as $path => $contents) { + $zip->addFromString($path, $contents); + } + + $zip->close(); + return $tmpFile; + } +} diff --git a/tests/Commands/RegenerateCommentContentCommandTest.php b/tests/Commands/RegenerateCommentContentCommandTest.php deleted file mode 100644 index 4940d66c343..00000000000 --- a/tests/Commands/RegenerateCommentContentCommandTest.php +++ /dev/null @@ -1,31 +0,0 @@ -forceCreate([ - 'html' => 'some_old_content', - 'text' => 'some_fresh_content', - ]); - - $this->assertDatabaseHas('comments', [ - 'html' => 'some_old_content', - ]); - - $exitCode = \Artisan::call('bookstack:regenerate-comment-content'); - $this->assertTrue($exitCode === 0, 'Command executed successfully'); - - $this->assertDatabaseMissing('comments', [ - 'html' => 'some_old_content', - ]); - $this->assertDatabaseHas('comments', [ - 'html' => "

    some_fresh_content

    \n", - ]); - } -} diff --git a/tests/Commands/RegeneratePermissionsCommandTest.php b/tests/Commands/RegeneratePermissionsCommandTest.php index 75c6c1b3851..27a339fbe1f 100644 --- a/tests/Commands/RegeneratePermissionsCommandTest.php +++ b/tests/Commands/RegeneratePermissionsCommandTest.php @@ -16,7 +16,7 @@ public function test_regen_permissions_command() $page = $this->entities->page(); $editor = $this->users->editor(); $role = $editor->roles()->first(); - $this->permissions->addEntityPermission($page, ['view'], $role); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $role); JointPermission::query()->truncate(); $this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]); diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php index 62c39c2741f..356a026a849 100644 --- a/tests/Commands/UpdateUrlCommandTest.php +++ b/tests/Commands/UpdateUrlCommandTest.php @@ -19,7 +19,7 @@ public function test_command_updates_page_content() ->expectsQuestion("This will search for \"https://example.com\" in your database and replace it with \"https://cats.example.com\".\nAre you sure you want to proceed?", 'y') ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y'); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'html' => '', ]); @@ -40,7 +40,7 @@ public function test_command_updates_description_html() ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y'); foreach ($models as $model) { - $this->assertDatabaseHas($model->getTable(), [ + $this->assertDatabaseHasEntityData($model->getMorphClass(), [ 'id' => $model->id, 'description_html' => '', ]); @@ -87,6 +87,27 @@ public function test_command_updates_array_settings() $this->assertEquals('a https://cats.example.com/donkey/cat url', $settingVal[0]['name']); } + public function test_command_updates_page_revisions() + { + $page = $this->entities->page(); + + for ($i = 0; $i < 2; $i++) { + $this->entities->updatePage($page, [ + 'name' => $page->name, + 'markdown' => "[A link {$i}](https://example.com/donkey/cat)" + ]); + } + + $this->runUpdate('https://example.com', 'https://cats.example.com'); + setting()->flushCache(); + + $this->assertDatabaseHas('page_revisions', [ + 'page_id' => $page->id, + 'markdown' => '[A link 1](https://cats.example.com/donkey/cat)', + 'html' => '' . "\n" + ]); + } + protected function runUpdate(string $oldUrl, string $newUrl) { $this->artisan("bookstack:update-url {$oldUrl} {$newUrl}") diff --git a/tests/CreatesApplication.php b/tests/CreatesApplication.php index b1cefbb6584..3258d05bc92 100644 --- a/tests/CreatesApplication.php +++ b/tests/CreatesApplication.php @@ -3,15 +3,14 @@ namespace Tests; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Foundation\Application; trait CreatesApplication { /** * Creates the application. - * - * @return \Illuminate\Foundation\Application */ - public function createApplication() + public function createApplication(): Application { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); diff --git a/tests/DebugViewTest.php b/tests/DebugViewTest.php index 34de6b80297..b9a99ec725e 100644 --- a/tests/DebugViewTest.php +++ b/tests/DebugViewTest.php @@ -27,7 +27,7 @@ public function test_debug_view_shows_expected_details() $resp->assertSeeText('BookStack Version: ' . trim(file_get_contents(base_path('version')))); // Dynamic help links $this->withHtml($resp)->assertElementExists('a[href*="q=' . urlencode('BookStack An error occurred during testing') . '"]'); - $this->withHtml($resp)->assertElementExists('a[href*="?q=is%3Aissue+' . urlencode('An error occurred during testing') . '"]'); + $this->withHtml($resp)->assertElementExists('a[href*="?q=' . urlencode('An error occurred during testing') . '"]'); } public function test_debug_view_only_shows_when_debug_mode_is_enabled() diff --git a/tests/Entity/BookDefaultTemplateTest.php b/tests/Entity/BookDefaultTemplateTest.php deleted file mode 100644 index d4cd5b2c3a9..00000000000 --- a/tests/Entity/BookDefaultTemplateTest.php +++ /dev/null @@ -1,185 +0,0 @@ -entities->templatePage(); - $details = [ - 'name' => 'My book with default template', - 'default_template_id' => $templatePage->id, - ]; - - $this->asEditor()->post('/books', $details); - $this->assertDatabaseHas('books', $details); - } - - public function test_updating_book_with_default_template() - { - $book = $this->entities->book(); - $templatePage = $this->entities->templatePage(); - - $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => strval($templatePage->id)]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]); - - $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => '']); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); - } - - public function test_default_template_cannot_be_set_if_not_a_template() - { - $book = $this->entities->book(); - $page = $this->entities->page(); - $this->assertFalse($page->template); - - $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $page->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); - } - - public function test_default_template_cannot_be_set_if_not_have_access() - { - $book = $this->entities->book(); - $templatePage = $this->entities->templatePage(); - $this->permissions->disableEntityInheritedPermissions($templatePage); - - $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); - } - - public function test_inaccessible_default_template_can_be_set_if_unchanged() - { - $templatePage = $this->entities->templatePage(); - $book = $this->bookUsingDefaultTemplate($templatePage); - $this->permissions->disableEntityInheritedPermissions($templatePage); - - $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]); - } - - public function test_default_page_template_option_shows_on_book_form() - { - $templatePage = $this->entities->templatePage(); - $book = $this->bookUsingDefaultTemplate($templatePage); - - $resp = $this->asEditor()->get($book->getUrl('/edit')); - $this->withHtml($resp)->assertElementExists('input[name="default_template_id"][value="' . $templatePage->id . '"]'); - } - - public function test_default_page_template_option_only_shows_template_name_if_visible() - { - $templatePage = $this->entities->templatePage(); - $book = $this->bookUsingDefaultTemplate($templatePage); - - $resp = $this->asEditor()->get($book->getUrl('/edit')); - $this->withHtml($resp)->assertElementContains('#template-control a.text-page', "#{$templatePage->id}, {$templatePage->name}"); - - $this->permissions->disableEntityInheritedPermissions($templatePage); - - $resp = $this->asEditor()->get($book->getUrl('/edit')); - $this->withHtml($resp)->assertElementNotContains('#template-control a.text-page', "#{$templatePage->id}, {$templatePage->name}"); - $this->withHtml($resp)->assertElementContains('#template-control a.text-page', "#{$templatePage->id}"); - } - - public function test_creating_book_page_uses_default_template() - { - $templatePage = $this->entities->templatePage(); - $templatePage->forceFill(['html' => '

    My template page

    ', 'markdown' => '# My template page'])->save(); - $book = $this->bookUsingDefaultTemplate($templatePage); - - $this->asEditor()->get($book->getUrl('/create-page')); - $latestPage = $book->pages() - ->where('draft', '=', true) - ->where('template', '=', false) - ->latest()->first(); - - $this->assertEquals('

    My template page

    ', $latestPage->html); - $this->assertEquals('# My template page', $latestPage->markdown); - } - - public function test_creating_chapter_page_uses_default_template() - { - $templatePage = $this->entities->templatePage(); - $templatePage->forceFill(['html' => '

    My template page in chapter

    ', 'markdown' => '# My template page in chapter'])->save(); - $book = $this->bookUsingDefaultTemplate($templatePage); - $chapter = $book->chapters()->first(); - - $this->asEditor()->get($chapter->getUrl('/create-page')); - $latestPage = $chapter->pages() - ->where('draft', '=', true) - ->where('template', '=', false) - ->latest()->first(); - - $this->assertEquals('

    My template page in chapter

    ', $latestPage->html); - $this->assertEquals('# My template page in chapter', $latestPage->markdown); - } - - public function test_creating_book_page_as_guest_uses_default_template() - { - $templatePage = $this->entities->templatePage(); - $templatePage->forceFill(['html' => '

    My template page

    ', 'markdown' => '# My template page'])->save(); - $book = $this->bookUsingDefaultTemplate($templatePage); - $guest = $this->users->guest(); - - $this->permissions->makeAppPublic(); - $this->permissions->grantUserRolePermissions($guest, ['page-create-all', 'page-update-all']); - - $resp = $this->post($book->getUrl('/create-guest-page'), [ - 'name' => 'My guest page with template' - ]); - $latestPage = $book->pages() - ->where('draft', '=', false) - ->where('template', '=', false) - ->where('created_by', '=', $guest->id) - ->latest()->first(); - - $this->assertEquals('

    My template page

    ', $latestPage->html); - $this->assertEquals('# My template page', $latestPage->markdown); - } - - public function test_creating_book_page_does_not_use_template_if_not_visible() - { - $templatePage = $this->entities->templatePage(); - $templatePage->forceFill(['html' => '

    My template page

    ', 'markdown' => '# My template page'])->save(); - $book = $this->bookUsingDefaultTemplate($templatePage); - $this->permissions->disableEntityInheritedPermissions($templatePage); - - $this->asEditor()->get($book->getUrl('/create-page')); - $latestPage = $book->pages() - ->where('draft', '=', true) - ->where('template', '=', false) - ->latest()->first(); - - $this->assertEquals('', $latestPage->html); - $this->assertEquals('', $latestPage->markdown); - } - - public function test_template_page_delete_removes_book_template_usage() - { - $templatePage = $this->entities->templatePage(); - $book = $this->bookUsingDefaultTemplate($templatePage); - - $book->refresh(); - $this->assertEquals($templatePage->id, $book->default_template_id); - - $this->asEditor()->delete($templatePage->getUrl()); - $this->asAdmin()->post('/settings/recycle-bin/empty'); - - $book->refresh(); - $this->assertEquals(null, $book->default_template_id); - } - - protected function bookUsingDefaultTemplate(Page $page): Book - { - $book = $this->entities->book(); - $book->default_template_id = $page->id; - $book->save(); - - return $book; - } -} diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index fb9862931ae..3ba2c3e99c8 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -91,7 +91,7 @@ public function test_shelves_create() ])); $resp->assertRedirect(); $editorId = $this->users->editor()->id; - $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first(); $shelfPage = $this->get($shelf->getUrl()); @@ -117,11 +117,12 @@ public function test_shelves_create_sets_cover_image() $lastImage = Image::query()->orderByDesc('id')->firstOrFail(); $shelf = Bookshelf::query()->where('name', '=', $shelfInfo['name'])->first(); - $this->assertDatabaseHas('bookshelves', [ - 'id' => $shelf->id, + $this->assertDatabaseHas('entity_container_data', [ + 'entity_id' => $shelf->id, + 'entity_type' => 'bookshelf', 'image_id' => $lastImage->id, ]); - $this->assertEquals($lastImage->id, $shelf->cover->id); + $this->assertEquals($lastImage->id, $shelf->coverInfo()->getImage()->id); $this->assertEquals('cover_bookshelf', $lastImage->type); } @@ -247,7 +248,7 @@ public function test_shelf_edit() $this->assertSessionHas('success'); $editorId = $this->users->editor()->id; - $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); $shelfPage = $this->get($shelf->getUrl()); $shelfPage->assertSee($shelfInfo['name']); @@ -259,6 +260,35 @@ public function test_shelf_edit() $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]); } + public function test_shelf_edit_does_not_alter_books_we_dont_have_access_to() + { + $shelf = $this->entities->shelf(); + $shelf->books()->detach(); + $this->entities->book(); + $this->entities->book(); + + $newBooks = [$this->entities->book(), $this->entities->book()]; + $originalBooks = [$this->entities->book(), $this->entities->book()]; + foreach ($originalBooks as $book) { + $this->permissions->disableEntityInheritedPermissions($book); + $shelf->books()->attach($book->id); + } + + $this->asEditor()->put($shelf->getUrl(), [ + 'name' => $shelf->name, + 'books' => "{$newBooks[0]->id},{$newBooks[1]->id}", + ])->assertRedirect($shelf->getUrl()); + + $resultingBooksById = $shelf->books()->get()->keyBy('id')->toArray(); + $this->assertCount(4, $resultingBooksById); + foreach ($newBooks as $book) { + $this->assertArrayHasKey($book->id, $resultingBooksById); + } + foreach ($originalBooks as $book) { + $this->assertArrayHasKey($book->id, $resultingBooksById); + } + } + public function test_shelf_create_new_book() { $shelf = $this->entities->shelf(); diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 3740892460b..c0d4fbc63e6 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -27,7 +27,7 @@ public function test_create() $resp = $this->get('/books/my-first-book'); $resp->assertSee($book->name); - $resp->assertSee($book->description); + $resp->assertSee($book->descriptionInfo()->getPlain()); } public function test_create_uses_different_slugs_when_name_reused() @@ -154,6 +154,20 @@ public function test_delete() $this->assertNotificationContains($redirectReq, 'Book Successfully Deleted'); } + public function test_delete_with_shelf_context_returns_to_shelf_view_after_delete() + { + $shelf = $this->entities->shelfHasBooks(); + /** @var Book $book */ + $book = $shelf->books()->first(); + + $this->asEditor()->get($shelf->getUrl()); + $this->get($book->getUrl()); + $this->get($book->getUrl('/delete')); + $resp = $this->delete($book->getUrl()); + + $resp->assertRedirect($shelf->getUrl()); + } + public function test_cancel_on_create_page_leads_back_to_books_listing() { $resp = $this->asEditor()->get('/create-book'); @@ -238,36 +252,12 @@ public function test_books_view_shows_view_toggle_option() $this->assertEquals('list', setting()->getUser($editor, 'books_view_type')); } - public function test_slug_multi_byte_url_safe() - { - $book = $this->entities->newBook([ - 'name' => 'информация', - ]); - - $this->assertEquals('informaciia', $book->slug); - - $book = $this->entities->newBook([ - 'name' => '¿Qué?', - ]); - - $this->assertEquals('que', $book->slug); - } - - public function test_slug_format() - { - $book = $this->entities->newBook([ - 'name' => 'PartA / PartB / PartC', - ]); - - $this->assertEquals('parta-partb-partc', $book->slug); - } - public function test_description_limited_to_specific_html() { $book = $this->entities->book(); - $input = '

    Test

    Contenta

    Hello

    '; - $expected = '

    Contenta

    '; + $input = '

    Test

    Contenta

    Hello
    code

    '; + $expected = '

    Contentacode

    '; $this->asEditor()->put($book->getUrl(), [ 'name' => $book->name, @@ -289,107 +279,24 @@ public function test_show_view_displays_description_if_no_description_html_set() $resp->assertSee("

    My great
    \ndescription
    \n
    \nwith newlines

    ", false); } - public function test_show_view_has_copy_button() + public function test_description_with_only_br_tags_results_in_empty_p_tag_used_on_show() { + $descriptions = [ + '


    ', + '





    ', + '









    ', + ]; $book = $this->entities->book(); - $resp = $this->asEditor()->get($book->getUrl()); - - $this->withHtml($resp)->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy'); - } - - public function test_copy_view() - { - $book = $this->entities->book(); - $resp = $this->asEditor()->get($book->getUrl('/copy')); - - $resp->assertOk(); - $resp->assertSee('Copy Book'); - $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]"); - } - - public function test_copy() - { - /** @var Book $book */ - $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); - $resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + $this->asEditor(); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); + foreach ($descriptions as $descriptionTestCase) { + $book->description_html = $descriptionTestCase; + $book->save(); - $resp->assertRedirect($copy->getUrl()); - $this->assertEquals($book->getDirectChildren()->count(), $copy->getDirectChildren()->count()); - - $this->get($copy->getUrl())->assertSee($book->description_html, false); - } - - public function test_copy_does_not_copy_non_visible_content() - { - /** @var Book $book */ - $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); - - // Hide child content - /** @var BookChild $page */ - foreach ($book->getDirectChildren() as $child) { - $this->permissions->setEntityPermissions($child, [], []); + $resp = $this->get($book->getUrl()); + $html = $this->withHtml($resp); + $descriptionHtml = $html->getInnerHtml('.book-content > div.text-muted:first-child'); + $this->assertEquals('

    ', $descriptionHtml); } - - $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $this->assertEquals(0, $copy->getDirectChildren()->count()); - } - - public function test_copy_does_not_copy_pages_or_chapters_if_user_cant_create() - { - /** @var Book $book */ - $book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first(); - $viewer = $this->users->viewer(); - $this->permissions->grantUserRolePermissions($viewer, ['book-create-all']); - - $this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $this->assertEquals(0, $copy->pages()->count()); - $this->assertEquals(0, $copy->chapters()->count()); - } - - public function test_copy_clones_cover_image_if_existing() - { - $book = $this->entities->book(); - $bookRepo = $this->app->make(BookRepo::class); - $coverImageFile = $this->files->uploadedImage('cover.png'); - $bookRepo->updateCoverImage($book, $coverImageFile); - - $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $this->assertNotNull($copy->cover); - $this->assertNotEquals($book->cover->id, $copy->cover->id); - } - - public function test_copy_adds_book_to_shelves_if_edit_permissions_allows() - { - /** @var Bookshelf $shelfA */ - /** @var Bookshelf $shelfB */ - [$shelfA, $shelfB] = Bookshelf::query()->take(2)->get(); - $book = $this->entities->book(); - - $shelfA->appendBook($book); - $shelfB->appendBook($book); - - $viewer = $this->users->viewer(); - $this->permissions->grantUserRolePermissions($viewer, ['book-update-all', 'book-create-all', 'bookshelf-update-all']); - $this->permissions->setEntityPermissions($shelfB); - - - $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $this->assertTrue($copy->shelves()->where('id', '=', $shelfA->id)->exists()); - $this->assertFalse($copy->shelves()->where('id', '=', $shelfB->id)->exists()); } } diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php index 1577cee76d8..0c0ec784135 100644 --- a/tests/Entity/ChapterTest.php +++ b/tests/Entity/ChapterTest.php @@ -66,90 +66,7 @@ public function test_delete() $this->assertNotificationContains($redirectReq, 'Chapter Successfully Deleted'); } - public function test_show_view_has_copy_button() - { - $chapter = $this->entities->chapter(); - - $resp = $this->asEditor()->get($chapter->getUrl()); - $this->withHtml($resp)->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy'); - } - - public function test_copy_view() - { - $chapter = $this->entities->chapter(); - - $resp = $this->asEditor()->get($chapter->getUrl('/copy')); - $resp->assertOk(); - $resp->assertSee('Copy Chapter'); - $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]"); - $this->withHtml($resp)->assertElementExists('input[name="entity_selection"]'); - } - - public function test_copy() - { - /** @var Chapter $chapter */ - $chapter = Chapter::query()->whereHas('pages')->first(); - /** @var Book $otherBook */ - $otherBook = Book::query()->where('id', '!=', $chapter->book_id)->first(); - - $resp = $this->asEditor()->post($chapter->getUrl('/copy'), [ - 'name' => 'My copied chapter', - 'entity_selection' => 'book:' . $otherBook->id, - ]); - - /** @var Chapter $newChapter */ - $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); - - $resp->assertRedirect($newChapter->getUrl()); - $this->assertEquals($otherBook->id, $newChapter->book_id); - $this->assertEquals($chapter->pages->count(), $newChapter->pages->count()); - } - - public function test_copy_does_not_copy_non_visible_pages() - { - $chapter = $this->entities->chapterHasPages(); - // Hide pages to all non-admin roles - /** @var Page $page */ - foreach ($chapter->pages as $page) { - $this->permissions->setEntityPermissions($page, [], []); - } - - $this->asEditor()->post($chapter->getUrl('/copy'), [ - 'name' => 'My copied chapter', - ]); - - /** @var Chapter $newChapter */ - $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); - $this->assertEquals(0, $newChapter->pages()->count()); - } - - public function test_copy_does_not_copy_pages_if_user_cant_page_create() - { - $chapter = $this->entities->chapterHasPages(); - $viewer = $this->users->viewer(); - $this->permissions->grantUserRolePermissions($viewer, ['chapter-create-all']); - - // Lacking permission results in no copied pages - $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ - 'name' => 'My copied chapter', - ]); - - /** @var Chapter $newChapter */ - $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); - $this->assertEquals(0, $newChapter->pages()->count()); - - $this->permissions->grantUserRolePermissions($viewer, ['page-create-all']); - - // Having permission rules in copied pages - $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ - 'name' => 'My copied again chapter', - ]); - - /** @var Chapter $newChapter2 */ - $newChapter2 = Chapter::query()->where('name', '=', 'My copied again chapter')->first(); - $this->assertEquals($chapter->pages()->count(), $newChapter2->pages()->count()); - } public function test_sort_book_action_visible_if_permissions_allow() { diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php deleted file mode 100644 index 23fc6819741..00000000000 --- a/tests/Entity/CommentTest.php +++ /dev/null @@ -1,167 +0,0 @@ -asAdmin(); - $page = $this->entities->page(); - - $comment = Comment::factory()->make(['parent_id' => 2]); - $resp = $this->postJson("/comment/$page->id", $comment->getAttributes()); - - $resp->assertStatus(200); - $resp->assertSee($comment->text); - - $pageResp = $this->get($page->getUrl()); - $pageResp->assertSee($comment->text); - - $this->assertDatabaseHas('comments', [ - 'local_id' => 1, - 'entity_id' => $page->id, - 'entity_type' => Page::newModelInstance()->getMorphClass(), - 'text' => $comment->text, - 'parent_id' => 2, - ]); - - $this->assertActivityExists(ActivityType::COMMENT_CREATE); - } - - public function test_comment_edit() - { - $this->asAdmin(); - $page = $this->entities->page(); - - $comment = Comment::factory()->make(); - $this->postJson("/comment/$page->id", $comment->getAttributes()); - - $comment = $page->comments()->first(); - $newText = 'updated text content'; - $resp = $this->putJson("/comment/$comment->id", [ - 'text' => $newText, - ]); - - $resp->assertStatus(200); - $resp->assertSee($newText); - $resp->assertDontSee($comment->text); - - $this->assertDatabaseHas('comments', [ - 'text' => $newText, - 'entity_id' => $page->id, - ]); - - $this->assertActivityExists(ActivityType::COMMENT_UPDATE); - } - - public function test_comment_delete() - { - $this->asAdmin(); - $page = $this->entities->page(); - - $comment = Comment::factory()->make(); - $this->postJson("/comment/$page->id", $comment->getAttributes()); - - $comment = $page->comments()->first(); - - $resp = $this->delete("/comment/$comment->id"); - $resp->assertStatus(200); - - $this->assertDatabaseMissing('comments', [ - 'id' => $comment->id, - ]); - - $this->assertActivityExists(ActivityType::COMMENT_DELETE); - } - - public function test_comments_converts_markdown_input_to_html() - { - $page = $this->entities->page(); - $this->asAdmin()->postJson("/comment/$page->id", [ - 'text' => '# My Title', - ]); - - $this->assertDatabaseHas('comments', [ - 'entity_id' => $page->id, - 'entity_type' => $page->getMorphClass(), - 'text' => '# My Title', - 'html' => "

    My Title

    \n", - ]); - - $pageView = $this->get($page->getUrl()); - $pageView->assertSee('

    My Title

    ', false); - } - - public function test_html_cannot_be_injected_via_comment_content() - { - $this->asAdmin(); - $page = $this->entities->page(); - - $script = '\n\n# sometextinthecomment'; - $this->postJson("/comment/$page->id", [ - 'text' => $script, - ]); - - $pageView = $this->get($page->getUrl()); - $pageView->assertDontSee($script, false); - $pageView->assertSee('sometextinthecomment'); - - $comment = $page->comments()->first(); - $this->putJson("/comment/$comment->id", [ - 'text' => $script . 'updated', - ]); - - $pageView = $this->get($page->getUrl()); - $pageView->assertDontSee($script, false); - $pageView->assertSee('sometextinthecommentupdated'); - } - - public function test_reply_comments_are_nested() - { - $this->asAdmin(); - $page = $this->entities->page(); - - $this->postJson("/comment/$page->id", ['text' => 'My new comment']); - $this->postJson("/comment/$page->id", ['text' => 'My new comment']); - - $respHtml = $this->withHtml($this->get($page->getUrl())); - $respHtml->assertElementCount('.comment-branch', 3); - $respHtml->assertElementNotExists('.comment-branch .comment-branch'); - - $comment = $page->comments()->first(); - $resp = $this->postJson("/comment/$page->id", ['text' => 'My nested comment', 'parent_id' => $comment->local_id]); - $resp->assertStatus(200); - - $respHtml = $this->withHtml($this->get($page->getUrl())); - $respHtml->assertElementCount('.comment-branch', 4); - $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment'); - } - - public function test_comments_are_visible_in_the_page_editor() - { - $page = $this->entities->page(); - - $this->asAdmin()->postJson("/comment/$page->id", ['text' => 'My great comment to see in the editor']); - - $respHtml = $this->withHtml($this->get($page->getUrl('/edit'))); - $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor'); - } - - public function test_comment_creator_name_truncated() - { - [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']); - $page = $this->entities->page(); - - $comment = Comment::factory()->make(); - $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes()); - - $pageResp = $this->asAdmin()->get($page->getUrl()); - $pageResp->assertSee('Wolfeschlegels…'); - } -} diff --git a/tests/Entity/ConvertTest.php b/tests/Entity/ConvertTest.php index d9b1ee466cf..8658e76998b 100644 --- a/tests/Entity/ConvertTest.php +++ b/tests/Entity/ConvertTest.php @@ -35,8 +35,8 @@ public function test_convert_chapter_to_book() /** @var Book $newBook */ $newBook = Book::query()->orderBy('id', 'desc')->first(); - $this->assertDatabaseMissing('chapters', ['id' => $chapter->id]); - $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]); + $this->assertDatabaseMissing('entities', ['id' => $chapter->id, 'type' => 'chapter']); + $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]); $this->assertCount(1, $newBook->tags); $this->assertEquals('Category', $newBook->tags->first()->name); $this->assertEquals('Penguins', $newBook->tags->first()->value); @@ -100,7 +100,7 @@ public function test_book_convert_to_shelf() // Checks for new shelf $resp->assertRedirectContains('/shelves/'); - $this->assertDatabaseMissing('chapters', ['id' => $childChapter->id]); + $this->assertDatabaseMissing('entities', ['id' => $childChapter->id, 'type' => 'chapter']); $this->assertCount(1, $newShelf->tags); $this->assertEquals('Category', $newShelf->tags->first()->name); $this->assertEquals('Ducks', $newShelf->tags->first()->value); @@ -112,8 +112,8 @@ public function test_book_convert_to_shelf() $this->assertActivityExists(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $newShelf); // Checks for old book to contain child pages - $this->assertDatabaseHas('books', ['id' => $book->id, 'name' => $book->name . ' Pages']); - $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => 0]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'name' => $book->name . ' Pages']); + $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => null]); // Checks for nested page $chapterChildPage->refresh(); diff --git a/tests/Entity/CopyTest.php b/tests/Entity/CopyTest.php new file mode 100644 index 00000000000..d4b6d54cf88 --- /dev/null +++ b/tests/Entity/CopyTest.php @@ -0,0 +1,399 @@ +entities->book(); + $resp = $this->asEditor()->get($book->getUrl()); + + $this->withHtml($resp)->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy'); + } + + public function test_book_copy_view() + { + $book = $this->entities->book(); + $resp = $this->asEditor()->get($book->getUrl('/copy')); + + $resp->assertOk(); + $resp->assertSee('Copy Book'); + $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]"); + } + + public function test_book_copy() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); + $resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $resp->assertRedirect($copy->getUrl()); + $this->assertEquals($book->getDirectVisibleChildren()->count(), $copy->getDirectVisibleChildren()->count()); + + $this->get($copy->getUrl())->assertSee($book->description_html, false); + } + + public function test_book_copy_does_not_copy_non_visible_content() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); + + // Hide child content + /** @var BookChild $page */ + foreach ($book->getDirectVisibleChildren() as $child) { + $this->permissions->setEntityPermissions($child, [], []); + } + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertEquals(0, $copy->getDirectVisibleChildren()->count()); + } + + public function test_book_copy_does_not_copy_pages_or_chapters_if_user_cant_create() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first(); + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['book-create-all']); + + $this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertEquals(0, $copy->pages()->count()); + $this->assertEquals(0, $copy->chapters()->count()); + } + + public function test_book_copy_clones_cover_image_if_existing() + { + $book = $this->entities->book(); + $bookRepo = $this->app->make(BookRepo::class); + $coverImageFile = $this->files->uploadedImage('cover.png'); + $bookRepo->updateCoverImage($book, $coverImageFile); + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book'])->assertRedirect(); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertNotNull($copy->coverInfo()->getImage()); + $this->assertNotEquals($book->coverInfo()->getImage()->id, $copy->coverInfo()->getImage()->id); + } + + public function test_book_copy_adds_book_to_shelves_if_edit_permissions_allows() + { + /** @var Bookshelf $shelfA */ + /** @var Bookshelf $shelfB */ + [$shelfA, $shelfB] = Bookshelf::query()->take(2)->get(); + $book = $this->entities->book(); + + $shelfA->appendBook($book); + $shelfB->appendBook($book); + + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['book-update-all', 'book-create-all', 'bookshelf-update-all']); + $this->permissions->setEntityPermissions($shelfB); + + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertTrue($copy->shelves()->where('id', '=', $shelfA->id)->exists()); + $this->assertFalse($copy->shelves()->where('id', '=', $shelfB->id)->exists()); + } + + public function test_chapter_show_view_has_copy_button() + { + $chapter = $this->entities->chapter(); + + $resp = $this->asEditor()->get($chapter->getUrl()); + $this->withHtml($resp)->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy'); + } + + public function test_chapter_copy_view() + { + $chapter = $this->entities->chapter(); + + $resp = $this->asEditor()->get($chapter->getUrl('/copy')); + $resp->assertOk(); + $resp->assertSee('Copy Chapter'); + $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]"); + $this->withHtml($resp)->assertElementExists('input[name="entity_selection"]'); + } + + public function test_chapter_copy() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + /** @var Book $otherBook */ + $otherBook = Book::query()->where('id', '!=', $chapter->book_id)->first(); + + $resp = $this->asEditor()->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + 'entity_selection' => 'book:' . $otherBook->id, + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + + $resp->assertRedirect($newChapter->getUrl()); + $this->assertEquals($otherBook->id, $newChapter->book_id); + $this->assertEquals($chapter->pages->count(), $newChapter->pages->count()); + } + + public function test_chapter_copy_does_not_copy_non_visible_pages() + { + $chapter = $this->entities->chapterHasPages(); + + // Hide pages to all non-admin roles + /** @var Page $page */ + foreach ($chapter->pages as $page) { + $this->permissions->setEntityPermissions($page, [], []); + } + + $this->asEditor()->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $this->assertEquals(0, $newChapter->pages()->count()); + } + + public function test_chapter_copy_does_not_copy_pages_if_user_cant_page_create() + { + $chapter = $this->entities->chapterHasPages(); + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['chapter-create-all']); + + // Lacking permission results in no copied pages + $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $this->assertEquals(0, $newChapter->pages()->count()); + + $this->permissions->grantUserRolePermissions($viewer, ['page-create-all']); + + // Having permission rules in copied pages + $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied again chapter', + ]); + + /** @var Chapter $newChapter2 */ + $newChapter2 = Chapter::query()->where('name', '=', 'My copied again chapter')->first(); + $this->assertEquals($chapter->pages()->count(), $newChapter2->pages()->count()); + } + + public function test_book_copy_updates_internal_references() + { + $book = $this->entities->bookHasChaptersAndPages(); + /** @var Chapter $chapter */ + $chapter = $book->chapters()->first(); + /** @var Page $page */ + $page = $chapter->pages()->first(); + $this->asEditor(); + $this->entities->updatePage($page, [ + 'name' => 'reference test page', + 'html' => '

    This is a test book link

    ', + ]); + + // Quick pre-update to get stable slug + $this->put($book->getUrl(), ['name' => 'Internal ref test']); + $book->refresh(); + $page->refresh(); + + $html = '

    This is a test page link

    '; + $this->put($book->getUrl(), ['name' => 'Internal ref test', 'description_html' => $html]); + + $this->post($book->getUrl('/copy'), ['name' => 'My copied book']); + + $newBook = Book::query()->where('name', '=', 'My copied book')->first(); + $newPage = $newBook->pages()->where('name', '=', 'reference test page')->first(); + + $this->assertStringContainsString($newBook->getUrl(), $newPage->html); + $this->assertStringContainsString($newPage->getUrl(), $newBook->description_html); + + $this->assertStringNotContainsString($book->getUrl(), $newPage->html); + $this->assertStringNotContainsString($page->getUrl(), $newBook->description_html); + } + + public function test_chapter_copy_updates_internal_references() + { + $chapter = $this->entities->chapterHasPages(); + /** @var Page $page */ + $page = $chapter->pages()->first(); + $this->asEditor(); + $this->entities->updatePage($page, [ + 'name' => 'reference test page', + 'html' => '

    This is a test chapter link

    ', + ]); + + // Quick pre-update to get stable slug + $this->put($chapter->getUrl(), ['name' => 'Internal ref test']); + $chapter->refresh(); + $page->refresh(); + + $html = '

    This is a test page link

    '; + $this->put($chapter->getUrl(), ['name' => 'Internal ref test', 'description_html' => $html]); + + $this->post($chapter->getUrl('/copy'), ['name' => 'My copied chapter']); + + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $newPage = $newChapter->pages()->where('name', '=', 'reference test page')->first(); + + $this->assertStringContainsString($newChapter->getUrl() . '"', $newPage->html); + $this->assertStringContainsString($newPage->getUrl() . '"', $newChapter->description_html); + + $this->assertStringNotContainsString($chapter->getUrl() . '"', $newPage->html); + $this->assertStringNotContainsString($page->getUrl() . '"', $newChapter->description_html); + } + + public function test_chapter_copy_updates_internal_permalink_references_in_its_description() + { + $chapter = $this->entities->chapterHasPages(); + /** @var Page $page */ + $page = $chapter->pages()->first(); + + $this->asEditor()->put($chapter->getUrl(), [ + 'name' => 'Internal ref test', + 'description_html' => '

    This is a test page link

    ', + ]); + $chapter->refresh(); + + $this->post($chapter->getUrl('/copy'), ['name' => 'My copied chapter']); + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + + $this->assertStringContainsString('/link/', $newChapter->description_html); + $this->assertStringNotContainsString($page->getPermalink() . '"', $newChapter->description_html); + } + + public function test_page_copy_updates_internal_self_references() + { + $page = $this->entities->page(); + $this->asEditor(); + + // Initial update to get stable slug + $this->entities->updatePage($page, ['name' => 'reference test page']); + + $page->refresh(); + $this->entities->updatePage($page, [ + 'name' => 'reference test page', + 'html' => '

    This is a test page link

    ', + ]); + + $this->post($page->getUrl('/copy'), ['name' => 'My copied page']); + $newPage = Page::query()->where('name', '=', 'My copied page')->first(); + $this->assertNotNull($newPage); + + $this->assertStringContainsString($newPage->getUrl(), $newPage->html); + $this->assertStringNotContainsString($page->getUrl(), $newPage->html); + } + + public function test_page_copy() + { + $page = $this->entities->page(); + $page->html = '

    This is some test content

    '; + $page->save(); + + $currentBook = $page->book; + $newBook = Book::where('id', '!=', $currentBook->id)->first(); + + $resp = $this->asEditor()->get($page->getUrl('/copy')); + $resp->assertSee('Copy Page'); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'entity_selection' => 'book:' . $newBook->id, + 'name' => 'My copied test page', + ]); + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $movePageResp->assertRedirect($pageCopy->getUrl()); + $this->assertTrue($pageCopy->book->id == $newBook->id, 'Page was copied to correct book'); + $this->assertStringContainsString('This is some test content', $pageCopy->html); + } + + public function test_page_copy_with_markdown_has_both_html_and_markdown() + { + $page = $this->entities->page(); + $page->html = '

    This is some test content

    '; + $page->markdown = '# This is some test content'; + $page->save(); + $newBook = Book::where('id', '!=', $page->book->id)->first(); + + $this->asEditor()->post($page->getUrl('/copy'), [ + 'entity_selection' => 'book:' . $newBook->id, + 'name' => 'My copied test page', + ]); + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $this->assertStringContainsString('This is some test content', $pageCopy->html); + $this->assertEquals('# This is some test content', $pageCopy->markdown); + } + + public function test_page_copy_with_no_destination() + { + $page = $this->entities->page(); + $currentBook = $page->book; + + $resp = $this->asEditor()->get($page->getUrl('/copy')); + $resp->assertSee('Copy Page'); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'name' => 'My copied test page', + ]); + + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $movePageResp->assertRedirect($pageCopy->getUrl()); + $this->assertTrue($pageCopy->book->id == $currentBook->id, 'Page was copied to correct book'); + $this->assertTrue($pageCopy->id !== $page->id, 'Page copy is not the same instance'); + } + + public function test_page_can_be_copied_without_edit_permission() + { + $page = $this->entities->page(); + $currentBook = $page->book; + $newBook = Book::where('id', '!=', $currentBook->id)->first(); + $viewer = $this->users->viewer(); + + $resp = $this->actingAs($viewer)->get($page->getUrl()); + $resp->assertDontSee($page->getUrl('/copy')); + + $newBook->owned_by = $viewer->id; + $newBook->save(); + $this->permissions->grantUserRolePermissions($viewer, ['page-create-own']); + $this->permissions->regenerateForEntity($newBook); + + $resp = $this->actingAs($viewer)->get($page->getUrl()); + $resp->assertSee($page->getUrl('/copy')); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'entity_selection' => 'book:' . $newBook->id, + 'name' => 'My copied test page', + ]); + $movePageResp->assertRedirect(); + + $this->assertDatabaseHasEntityData('page', [ + 'name' => 'My copied test page', + 'created_by' => $viewer->id, + 'book_id' => $newBook->id, + ]); + } +} diff --git a/tests/Entity/DefaultTemplateTest.php b/tests/Entity/DefaultTemplateTest.php new file mode 100644 index 00000000000..d3109c8a2fe --- /dev/null +++ b/tests/Entity/DefaultTemplateTest.php @@ -0,0 +1,341 @@ +entities->templatePage(); + $details = [ + 'name' => 'My book with default template', + 'default_template_id' => $templatePage->id, + ]; + + $this->asEditor()->post('/books', $details); + $this->assertDatabaseHasEntityData('book', $details); + } + + public function test_creating_chapter_with_default_template() + { + $templatePage = $this->entities->templatePage(); + $book = $this->entities->book(); + $details = [ + 'name' => 'My chapter with default template', + 'default_template_id' => $templatePage->id, + ]; + + $this->asEditor()->post($book->getUrl('/create-chapter'), $details); + $this->assertDatabaseHasEntityData('chapter', $details); + } + + public function test_updating_book_with_default_template() + { + $book = $this->entities->book(); + $templatePage = $this->entities->templatePage(); + + $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => strval($templatePage->id)]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]); + + $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => '']); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); + } + + public function test_updating_chapter_with_default_template() + { + $chapter = $this->entities->chapter(); + $templatePage = $this->entities->templatePage(); + + $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => strval($templatePage->id)]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); + + $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => '']); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); + } + + public function test_default_book_template_cannot_be_set_if_not_a_template() + { + $book = $this->entities->book(); + $page = $this->entities->page(); + $this->assertFalse($page->template); + + $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $page->id]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); + } + + public function test_default_chapter_template_cannot_be_set_if_not_a_template() + { + $chapter = $this->entities->chapter(); + $page = $this->entities->page(); + $this->assertFalse($page->template); + + $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $page->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); + } + + + public function test_default_book_template_cannot_be_set_if_not_have_access() + { + $book = $this->entities->book(); + $templatePage = $this->entities->templatePage(); + $this->permissions->disableEntityInheritedPermissions($templatePage); + + $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); + } + + public function test_default_chapter_template_cannot_be_set_if_not_have_access() + { + $chapter = $this->entities->chapter(); + $templatePage = $this->entities->templatePage(); + $this->permissions->disableEntityInheritedPermissions($templatePage); + + $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); + } + + public function test_inaccessible_book_default_template_can_be_set_if_unchanged() + { + $templatePage = $this->entities->templatePage(); + $book = $this->bookUsingDefaultTemplate($templatePage); + $this->permissions->disableEntityInheritedPermissions($templatePage); + + $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]); + } + + public function test_inaccessible_chapter_default_template_can_be_set_if_unchanged() + { + $templatePage = $this->entities->templatePage(); + $chapter = $this->chapterUsingDefaultTemplate($templatePage); + $this->permissions->disableEntityInheritedPermissions($templatePage); + + $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); + } + + public function test_default_page_template_option_shows_on_book_form() + { + $templatePage = $this->entities->templatePage(); + $book = $this->bookUsingDefaultTemplate($templatePage); + + $resp = $this->asEditor()->get($book->getUrl('/edit')); + $this->withHtml($resp)->assertElementExists('input[name="default_template_id"][value="' . $templatePage->id . '"]'); + } + + public function test_default_page_template_option_shows_on_chapter_form() + { + $templatePage = $this->entities->templatePage(); + $chapter = $this->chapterUsingDefaultTemplate($templatePage); + + $resp = $this->asEditor()->get($chapter->getUrl('/edit')); + $this->withHtml($resp)->assertElementExists('input[name="default_template_id"][value="' . $templatePage->id . '"]'); + } + + public function test_book_default_page_template_option_only_shows_template_name_if_visible() + { + $templatePage = $this->entities->templatePage(); + $book = $this->bookUsingDefaultTemplate($templatePage); + + $resp = $this->asEditor()->get($book->getUrl('/edit')); + $this->withHtml($resp)->assertElementContains('#template-control a.text-page', "#{$templatePage->id}, {$templatePage->name}"); + + $this->permissions->disableEntityInheritedPermissions($templatePage); + + $resp = $this->asEditor()->get($book->getUrl('/edit')); + $this->withHtml($resp)->assertElementNotContains('#template-control a.text-page', "#{$templatePage->id}, {$templatePage->name}"); + $this->withHtml($resp)->assertElementContains('#template-control a.text-page', "#{$templatePage->id}"); + } + + public function test_chapter_default_page_template_option_only_shows_template_name_if_visible() + { + $templatePage = $this->entities->templatePage(); + $chapter = $this->chapterUsingDefaultTemplate($templatePage); + + $resp = $this->asEditor()->get($chapter->getUrl('/edit')); + $this->withHtml($resp)->assertElementContains('#template-control a.text-page', "#{$templatePage->id}, {$templatePage->name}"); + + $this->permissions->disableEntityInheritedPermissions($templatePage); + + $resp = $this->asEditor()->get($chapter->getUrl('/edit')); + $this->withHtml($resp)->assertElementNotContains('#template-control a.text-page', "#{$templatePage->id}, {$templatePage->name}"); + $this->withHtml($resp)->assertElementContains('#template-control a.text-page', "#{$templatePage->id}"); + } + + public function test_creating_book_page_uses_book_default_template() + { + $templatePage = $this->entities->templatePage(); + $templatePage->forceFill(['html' => '

    My template page

    ', 'markdown' => '# My template page'])->save(); + $book = $this->bookUsingDefaultTemplate($templatePage); + + $this->asEditor()->get($book->getUrl('/create-page'))->assertRedirect(); + $latestPage = $book->pages() + ->where('draft', '=', true) + ->where('template', '=', false) + ->latest()->first(); + + $this->assertEquals('

    My template page

    ', $latestPage->html); + $this->assertEquals('# My template page', $latestPage->markdown); + } + + public function test_creating_chapter_page_uses_chapter_default_template() + { + $templatePage = $this->entities->templatePage(); + $templatePage->forceFill(['html' => '

    My chapter template page

    ', 'markdown' => '# My chapter template page'])->save(); + $chapter = $this->chapterUsingDefaultTemplate($templatePage); + + $this->asEditor()->get($chapter->getUrl('/create-page')); + $latestPage = $chapter->pages() + ->where('draft', '=', true) + ->where('template', '=', false) + ->latest()->first(); + + $this->assertEquals('

    My chapter template page

    ', $latestPage->html); + $this->assertEquals('# My chapter template page', $latestPage->markdown); + } + + public function test_creating_chapter_page_uses_book_default_template_if_no_chapter_template_set() + { + $templatePage = $this->entities->templatePage(); + $templatePage->forceFill(['html' => '

    My template page in chapter

    ', 'markdown' => '# My template page in chapter'])->save(); + $book = $this->bookUsingDefaultTemplate($templatePage); + $chapter = $book->chapters()->first(); + + $this->asEditor()->get($chapter->getUrl('/create-page')); + $latestPage = $chapter->pages() + ->where('draft', '=', true) + ->where('template', '=', false) + ->latest()->first(); + + $this->assertEquals('

    My template page in chapter

    ', $latestPage->html); + $this->assertEquals('# My template page in chapter', $latestPage->markdown); + } + + public function test_creating_chapter_page_uses_chapter_template_instead_of_book_template() + { + $bookTemplatePage = $this->entities->templatePage(); + $bookTemplatePage->forceFill(['html' => '

    My book template

    ', 'markdown' => '# My book template'])->save(); + $book = $this->bookUsingDefaultTemplate($bookTemplatePage); + + $chapterTemplatePage = $this->entities->templatePage(); + $chapterTemplatePage->forceFill(['html' => '

    My chapter template

    ', 'markdown' => '# My chapter template'])->save(); + $chapter = $book->chapters()->first(); + $chapter->default_template_id = $chapterTemplatePage->id; + $chapter->save(); + + $this->asEditor()->get($chapter->getUrl('/create-page')); + $latestPage = $chapter->pages() + ->where('draft', '=', true) + ->where('template', '=', false) + ->latest()->first(); + + $this->assertEquals('

    My chapter template

    ', $latestPage->html); + $this->assertEquals('# My chapter template', $latestPage->markdown); + } + + public function test_creating_page_as_guest_uses_default_template() + { + $templatePage = $this->entities->templatePage(); + $templatePage->forceFill(['html' => '

    My template page

    ', 'markdown' => '# My template page'])->save(); + $book = $this->bookUsingDefaultTemplate($templatePage); + $chapter = $this->chapterUsingDefaultTemplate($templatePage); + $guest = $this->users->guest(); + + $this->permissions->makeAppPublic(); + $this->permissions->grantUserRolePermissions($guest, ['page-create-all', 'page-update-all']); + + $this->post($book->getUrl('/create-guest-page'), [ + 'name' => 'My guest page with template' + ])->assertRedirect(); + $latestBookPage = $book->pages() + ->where('draft', '=', false) + ->where('template', '=', false) + ->where('created_by', '=', $guest->id) + ->latest()->first(); + + $this->assertEquals('

    My template page

    ', $latestBookPage->html); + $this->assertEquals('# My template page', $latestBookPage->markdown); + + $this->post($chapter->getUrl('/create-guest-page'), [ + 'name' => 'My guest page with template' + ]); + $latestChapterPage = $chapter->pages() + ->where('draft', '=', false) + ->where('template', '=', false) + ->where('created_by', '=', $guest->id) + ->latest()->first(); + + $this->assertEquals('

    My template page

    ', $latestChapterPage->html); + $this->assertEquals('# My template page', $latestChapterPage->markdown); + } + + public function test_templates_not_used_if_not_visible() + { + $templatePage = $this->entities->templatePage(); + $templatePage->forceFill(['html' => '

    My template page

    ', 'markdown' => '# My template page'])->save(); + $book = $this->bookUsingDefaultTemplate($templatePage); + $chapter = $this->chapterUsingDefaultTemplate($templatePage); + + $this->permissions->disableEntityInheritedPermissions($templatePage); + + $this->asEditor()->get($book->getUrl('/create-page')); + $latestBookPage = $book->pages() + ->where('draft', '=', true) + ->where('template', '=', false) + ->latest()->first(); + + $this->assertEquals('', $latestBookPage->html); + $this->assertEquals('', $latestBookPage->markdown); + + $this->asEditor()->get($chapter->getUrl('/create-page')); + $latestChapterPage = $chapter->pages() + ->where('draft', '=', true) + ->where('template', '=', false) + ->latest()->first(); + + $this->assertEquals('', $latestChapterPage->html); + $this->assertEquals('', $latestChapterPage->markdown); + } + + public function test_template_page_delete_removes_template_usage() + { + $templatePage = $this->entities->templatePage(); + $book = $this->bookUsingDefaultTemplate($templatePage); + $chapter = $this->chapterUsingDefaultTemplate($templatePage); + + $book->refresh(); + $this->assertEquals($templatePage->id, $book->default_template_id); + $this->assertEquals($templatePage->id, $chapter->default_template_id); + + $this->asEditor()->delete($templatePage->getUrl()); + $this->asAdmin()->post('/settings/recycle-bin/empty'); + + $book->refresh(); + $chapter->refresh(); + $this->assertEquals(null, $book->default_template_id); + $this->assertEquals(null, $chapter->default_template_id); + } + + protected function bookUsingDefaultTemplate(Page $page): Book + { + $book = $this->entities->book(); + $book->default_template_id = $page->id; + $book->save(); + + return $book; + } + + protected function chapterUsingDefaultTemplate(Page $page): Chapter + { + $chapter = $this->entities->chapter(); + $chapter->default_template_id = $page->id; + $chapter->save(); + + return $chapter; + } +} diff --git a/tests/Entity/EntityQueryTest.php b/tests/Entity/EntityQueryTest.php new file mode 100644 index 00000000000..180cb3076c6 --- /dev/null +++ b/tests/Entity/EntityQueryTest.php @@ -0,0 +1,44 @@ +assertEquals($expected, $query->toSql()); + $this->assertEquals(['book', 'book'], $query->getBindings()); + } + + public function test_joins_in_sub_queries_use_alias_names() + { + $query = Book::query()->whereHas('chapters', function (Builder $query) { + $query->where('name', '=', 'a'); + }); + + // Probably from type limits on relation where not needed? + $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where exists (select * from `entities` as `laravel_reserved_%d` left join `entity_container_data` on `entity_container_data`.`entity_id` = `laravel_reserved_%d`.`id` and `entity_container_data`.`entity_type` = ? where `entities`.`id` = `laravel_reserved_%d`.`book_id` and `name` = ? and `type` = ? and `laravel_reserved_%d`.`deleted_at` is null) and `type` = ? and `entities`.`deleted_at` is null'; + $this->assertStringMatchesFormat($expected, $query->toSql()); + $this->assertEquals(['book', 'chapter', 'a', 'chapter', 'book'], $query->getBindings()); + } + + public function test_book_chapter_relation_applies_type_condition() + { + $book = $this->entities->book(); + $query = $book->chapters(); + $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where `entities`.`book_id` = ? and `entities`.`book_id` is not null and `type` = ? and `entities`.`deleted_at` is null'; + $this->assertEquals($expected, $query->toSql()); + $this->assertEquals(['chapter', $book->id, 'chapter'], $query->getBindings()); + + $query = Book::query()->whereHas('chapters'); + $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where exists (select * from `entities` as `laravel_reserved_%d` left join `entity_container_data` on `entity_container_data`.`entity_id` = `laravel_reserved_%d`.`id` and `entity_container_data`.`entity_type` = ? where `entities`.`id` = `laravel_reserved_%d`.`book_id` and `type` = ? and `laravel_reserved_%d`.`deleted_at` is null) and `type` = ? and `entities`.`deleted_at` is null'; + $this->assertStringMatchesFormat($expected, $query->toSql()); + $this->assertEquals(['book', 'chapter', 'chapter', 'book'], $query->getBindings()); + } +} diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php deleted file mode 100644 index eedcb672c99..00000000000 --- a/tests/Entity/ExportTest.php +++ /dev/null @@ -1,518 +0,0 @@ -entities->page(); - $this->asEditor(); - - $resp = $this->get($page->getUrl('/export/plaintext')); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); - } - - public function test_page_pdf_export() - { - $page = $this->entities->page(); - $this->asEditor(); - - $resp = $this->get($page->getUrl('/export/pdf')); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); - } - - public function test_page_html_export() - { - $page = $this->entities->page(); - $this->asEditor(); - - $resp = $this->get($page->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); - } - - public function test_book_text_export() - { - $book = $this->entities->bookHasChaptersAndPages(); - $directPage = $book->directPages()->first(); - $chapter = $book->chapters()->first(); - $chapterPage = $chapter->pages()->first(); - $this->entities->updatePage($directPage, ['html' => '

    My awesome page

    ']); - $this->entities->updatePage($chapterPage, ['html' => '

    My little nested page

    ']); - $this->asEditor(); - - $resp = $this->get($book->getUrl('/export/plaintext')); - $resp->assertStatus(200); - $resp->assertSee($book->name); - $resp->assertSee($chapterPage->name); - $resp->assertSee($chapter->name); - $resp->assertSee($directPage->name); - $resp->assertSee('My awesome page'); - $resp->assertSee('My little nested page'); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); - } - - public function test_book_text_export_format() - { - $entities = $this->entities->createChainBelongingToUser($this->users->viewer()); - $this->entities->updatePage($entities['page'], ['html' => '

    My great page

    Full of great stuff

    ', 'name' => 'My wonderful page!']); - $entities['chapter']->name = 'Export chapter'; - $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within"; - $entities['book']->name = 'Export Book'; - $entities['book']->description = "This is a book with stuff to export"; - $entities['chapter']->save(); - $entities['book']->save(); - - $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); - - $expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; - $expected .= "My wonderful page!\nMy great page Full of great stuff"; - $resp->assertSee($expected); - } - - public function test_book_pdf_export() - { - $page = $this->entities->page(); - $book = $page->book; - $this->asEditor(); - - $resp = $this->get($book->getUrl('/export/pdf')); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); - } - - public function test_book_html_export() - { - $page = $this->entities->page(); - $book = $page->book; - $this->asEditor(); - - $resp = $this->get($book->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertSee($book->name); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); - } - - public function test_book_html_export_shows_html_descriptions() - { - $book = $this->entities->bookHasChaptersAndPages(); - $chapter = $book->chapters()->first(); - $book->description_html = '

    A description with HTML within!

    '; - $chapter->description_html = '

    A chapter description with HTML within!

    '; - $book->save(); - $chapter->save(); - - $resp = $this->asEditor()->get($book->getUrl('/export/html')); - $resp->assertSee($book->description_html, false); - $resp->assertSee($chapter->description_html, false); - } - - public function test_chapter_text_export() - { - $chapter = $this->entities->chapter(); - $page = $chapter->pages[0]; - $this->entities->updatePage($page, ['html' => '

    This is content within the page!

    ']); - $this->asEditor(); - - $resp = $this->get($chapter->getUrl('/export/plaintext')); - $resp->assertStatus(200); - $resp->assertSee($chapter->name); - $resp->assertSee($page->name); - $resp->assertSee('This is content within the page!'); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); - } - - public function test_chapter_text_export_format() - { - $entities = $this->entities->createChainBelongingToUser($this->users->viewer()); - $this->entities->updatePage($entities['page'], ['html' => '

    My great page

    Full of great stuff

    ', 'name' => 'My wonderful page!']); - $entities['chapter']->name = 'Export chapter'; - $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within"; - $entities['chapter']->save(); - - $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); - - $expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; - $expected .= "My wonderful page!\nMy great page Full of great stuff"; - $resp->assertSee($expected); - } - - public function test_chapter_pdf_export() - { - $chapter = $this->entities->chapter(); - $this->asEditor(); - - $resp = $this->get($chapter->getUrl('/export/pdf')); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); - } - - public function test_chapter_html_export() - { - $chapter = $this->entities->chapter(); - $page = $chapter->pages[0]; - $this->asEditor(); - - $resp = $this->get($chapter->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertSee($chapter->name); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); - } - - public function test_chapter_html_export_shows_html_descriptions() - { - $chapter = $this->entities->chapter(); - $chapter->description_html = '

    A description with HTML within!

    '; - $chapter->save(); - - $resp = $this->asEditor()->get($chapter->getUrl('/export/html')); - $resp->assertSee($chapter->description_html, false); - } - - public function test_page_html_export_contains_custom_head_if_set() - { - $page = $this->entities->page(); - - $customHeadContent = ''; - $this->setSettings(['app-custom-head' => $customHeadContent]); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee($customHeadContent, false); - } - - public function test_page_html_export_does_not_break_with_only_comments_in_custom_head() - { - $page = $this->entities->page(); - - $customHeadContent = ''; - $this->setSettings(['app-custom-head' => $customHeadContent]); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertSee($customHeadContent, false); - } - - public function test_page_html_export_use_absolute_dates() - { - $page = $this->entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss')); - $resp->assertDontSee($page->created_at->diffForHumans()); - $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss')); - $resp->assertDontSee($page->updated_at->diffForHumans()); - } - - public function test_page_export_does_not_include_user_or_revision_links() - { - $page = $this->entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertDontSee($page->getUrl('/revisions')); - $resp->assertDontSee($page->createdBy->getProfileUrl()); - $resp->assertSee($page->createdBy->name); - } - - public function test_page_export_sets_right_data_type_for_svg_embeds() - { - $page = $this->entities->page(); - Storage::disk('local')->makeDirectory('uploads/images/gallery'); - Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', ''); - $page->html = ''; - $page->save(); - - $this->asEditor(); - $resp = $this->get($page->getUrl('/export/html')); - Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); - - $resp->assertStatus(200); - $resp->assertSee(''; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); - Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg'); - - $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test'); - } - - public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder() - { - $page = $this->entities->page(); - $page->html = '' - . '' - . ''; - $storageDisk = Storage::disk('local'); - $storageDisk->makeDirectory('uploads/images/gallery'); - $storageDisk->put('uploads/images/gallery/svg_test.svg', 'good'); - $storageDisk->put('uploads/svg_test.svg', 'bad'); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - - $storageDisk->delete('uploads/images/gallery/svg_test.svg'); - $storageDisk->delete('uploads/svg_test.svg'); - - $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false); - $resp->assertSee('http://localhost/uploads/svg_test.svg'); - $resp->assertSee('src="/uploads/svg_test.svg"', false); - } - - public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local() - { - $contents = file_get_contents(public_path('.htaccess')); - config()->set('filesystems.images', 'local'); - - $page = $this->entities->page(); - $page->html = ''; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertDontSee(base64_encode($contents)); - } - - public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local_secure() - { - $testFilePath = storage_path('logs/test.txt'); - config()->set('filesystems.images', 'local_secure'); - file_put_contents($testFilePath, 'I am a cat'); - - $page = $this->entities->page(); - $page->html = ''; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertDontSee(base64_encode('I am a cat')); - unlink($testFilePath); - } - - public function test_exports_removes_scripts_from_custom_head() - { - $entities = [ - Page::query()->first(), Chapter::query()->first(), Book::query()->first(), - ]; - setting()->put('app-custom-head', ''); - - foreach ($entities as $entity) { - $resp = $this->asEditor()->get($entity->getUrl('/export/html')); - $resp->assertDontSee('window.donkey'); - $resp->assertDontSee('assertSee('.my-test-class { color: red; }'); - } - } - - public function test_page_export_with_deleted_creator_and_updater() - { - $user = $this->users->viewer(['name' => 'ExportWizardTheFifth']); - $page = $this->entities->page(); - $page->created_by = $user->id; - $page->updated_by = $user->id; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee('ExportWizardTheFifth'); - - $user->delete(); - $resp = $this->get($page->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertDontSee('ExportWizardTheFifth'); - } - - public function test_page_pdf_export_converts_iframes_to_links() - { - $page = Page::query()->first()->forceFill([ - 'html' => '', - ]); - $page->save(); - - $pdfHtml = ''; - $mockPdfGenerator = $this->mock(PdfGenerator::class); - $mockPdfGenerator->shouldReceive('fromHtml') - ->with(\Mockery::capture($pdfHtml)) - ->andReturn(''); - $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); - - $this->asEditor()->get($page->getUrl('/export/pdf')); - $this->assertStringNotContainsString('iframe>', $pdfHtml); - $this->assertStringContainsString('

    https://www.youtube.com/embed/ShqUjt33uOs

    ', $pdfHtml); - } - - public function test_page_pdf_export_opens_details_blocks() - { - $page = $this->entities->page()->forceFill([ - 'html' => '
    Hello

    Content!

    ', - ]); - $page->save(); - - $pdfHtml = ''; - $mockPdfGenerator = $this->mock(PdfGenerator::class); - $mockPdfGenerator->shouldReceive('fromHtml') - ->with(\Mockery::capture($pdfHtml)) - ->andReturn(''); - $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); - - $this->asEditor()->get($page->getUrl('/export/pdf')); - $this->assertStringContainsString('
    entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); - } - - public function test_page_markdown_export_uses_existing_markdown_if_apparent() - { - $page = $this->entities->page()->forceFill([ - 'markdown' => '# A header', - 'html' => '

    Dogcat

    ', - ]); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertSee('A header'); - $resp->assertDontSee('Dogcat'); - } - - public function test_page_markdown_export_converts_html_where_no_markdown() - { - $page = $this->entities->page()->forceFill([ - 'markdown' => '', - 'html' => '

    Dogcat

    Some bold text

    ', - ]); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertSee("# Dogcat\n\nSome **bold** text"); - } - - public function test_chapter_markdown_export() - { - $chapter = $this->entities->chapter(); - $page = $chapter->pages()->first(); - $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown')); - - $resp->assertSee('# ' . $chapter->name); - $resp->assertSee('# ' . $page->name); - } - - public function test_book_markdown_export() - { - $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); - $chapter = $book->chapters()->first(); - $page = $chapter->pages()->first(); - $resp = $this->asEditor()->get($book->getUrl('/export/markdown')); - - $resp->assertSee('# ' . $book->name); - $resp->assertSee('# ' . $chapter->name); - $resp->assertSee('# ' . $page->name); - } - - public function test_book_markdown_export_concats_immediate_pages_with_newlines() - { - /** @var Book $book */ - $book = Book::query()->whereHas('pages')->first(); - - $this->asEditor()->get($book->getUrl('/create-page')); - $this->get($book->getUrl('/create-page')); - - [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get(); - $pageA->html = '

    hello tester

    '; - $pageA->save(); - $pageB->name = 'The second page in this test'; - $pageB->save(); - - $resp = $this->get($book->getUrl('/export/markdown')); - $resp->assertDontSee('hello tester# The second page in this test'); - $resp->assertSee("hello tester\n\n# The second page in this test"); - } - - public function test_export_option_only_visible_and_accessible_with_permission() - { - $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); - $chapter = $book->chapters()->first(); - $page = $chapter->pages()->first(); - $entities = [$book, $chapter, $page]; - $user = $this->users->viewer(); - $this->actingAs($user); - - foreach ($entities as $entity) { - $resp = $this->get($entity->getUrl()); - $resp->assertSee('/export/pdf'); - } - - $this->permissions->removeUserRolePermissions($user, ['content-export']); - - foreach ($entities as $entity) { - $resp = $this->get($entity->getUrl()); - $resp->assertDontSee('/export/pdf'); - $resp = $this->get($entity->getUrl('/export/pdf')); - $this->assertPermissionError($resp); - } - } - - public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true() - { - $page = $this->entities->page(); - - config()->set('snappy.pdf.binary', '/abc123'); - config()->set('app.allow_untrusted_server_fetching', false); - - $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); - $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage. - - config()->set('app.allow_untrusted_server_fetching', true); - $resp = $this->get($page->getUrl('/export/pdf')); - $resp->assertStatus(500); // Bad response indicates wkhtml usage - } - - public function test_html_exports_contain_csp_meta_tag() - { - $entities = [ - $this->entities->page(), - $this->entities->book(), - $this->entities->chapter(), - ]; - - foreach ($entities as $entity) { - $resp = $this->asEditor()->get($entity->getUrl('/export/html')); - $this->withHtml($resp)->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]'); - } - } - - public function test_html_exports_contain_body_classes_for_export_identification() - { - $page = $this->entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $this->withHtml($resp)->assertElementExists('body.export.export-format-html.export-engine-none'); - } -} diff --git a/tests/Entity/PageContentFilteringTest.php b/tests/Entity/PageContentFilteringTest.php new file mode 100644 index 00000000000..68b597387f2 --- /dev/null +++ b/tests/Entity/PageContentFilteringTest.php @@ -0,0 +1,541 @@ +asEditor(); + $page = $this->entities->page(); + $script = 'abc123abc123'; + $page->html = "escape {$script}"; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertDontSee($script, false); + $pageView->assertSee('abc123abc123'); + } + + public function test_more_complex_content_script_escaping_scenarios() + { + config()->set('app.content_filtering', 'j'); + + $checks = [ + "

    Some script

    ", + "

    Some script

    ", + "

    Some script

    ", + "

    Some script

    ", + "

    Some script

    ", + "

    Some script

    ", + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); + } + } + + public function test_js_and_base64_src_urls_are_removed() + { + config()->set('app.content_filtering', 'j'); + + $checks = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $html = $this->withHtml($pageView); + $html->assertElementNotContains('.page-content', 'assertElementNotContains('.page-content', 'data='); + $html->assertElementNotContains('.page-content', ''); + $html->assertElementNotContains('.page-content', 'src='); + $html->assertElementNotContains('.page-content', 'javascript:'); + $html->assertElementNotContains('.page-content', 'data:'); + $html->assertElementNotContains('.page-content', 'base64'); + } + } + + public function test_javascript_uri_links_are_removed() + { + config()->set('app.content_filtering', 'j'); + + $checks = [ + 'withHtml($pageView)->assertElementNotContains('.page-content', 'href=javascript:'); + } + } + + public function test_form_filtering_is_controlled_by_config() + { + config()->set('app.content_filtering', ''); + $page = $this->entities->page(); + $page->html = '
    '; + $page->save(); + + $this->asEditor()->get($page->getUrl())->assertSee('dont-see-this', false); + + config()->set('app.content_filtering', 'f'); + $this->get($page->getUrl())->assertDontSee('dont-see-this', false); + } + + public function test_form_actions_with_javascript_are_removed() + { + config()->set('app.content_filtering', 'j'); + + $checks = [ + '', + 'Click me', + 'Click me', + '', + '', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertDontSee('id="xss"', false); + $pageView->assertDontSee('action=javascript:', false); + $pageView->assertDontSee('action=JaVaScRiPt:', false); + $pageView->assertDontSee('formaction=javascript:', false); + $pageView->assertDontSee('formaction=JaVaScRiPt:', false); + } + } + + public function test_form_elements_are_removed() + { + config()->set('app.content_filtering', 'f'); + + $checks = [ + '

    thisisacattofind

    thisdogshouldnotbefound
    ', + '

    thisisacattofind

    ', + '

    thisisacattofind

    ', + '

    thisisacattofind

    ', + '

    thisisacattofind

    thisdogshouldnotbefound
    ', + '

    thisisacattofind

    ', + '

    thisisacattofind

    ', + <<<'TESTCASE' + + + + +

    thisisacattofind

    +
    +

    thisdogshouldnotbefound

    +
    + + + + +
    +
    +TESTCASE + + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertSee('thisisacattofind'); + $pageView->assertDontSee('thisdogshouldnotbefound'); + } + } + + public function test_form_attributes_are_removed() + { + config()->set('app.content_filtering', 'f'); + + $withinSvgSample = <<<'TESTCASE' + + + + +

    thisisacattofind

    +

    thisisacattofind

    + + +
    +
    +TESTCASE; + + $checks = [ + 'formaction' => '

    thisisacattofind

    ', + 'form' => '

    thisisacattofind

    ', + 'formmethod' => '

    thisisacattofind

    ', + 'formtarget' => '

    thisisacattofind

    ', + 'FORMTARGET' => '

    thisisacattofind

    ', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $attribute => $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertSee('thisisacattofind'); + $this->withHtml($pageView)->assertElementNotExists(".page-content [{$attribute}]"); + } + + $page->html = $withinSvgSample; + $page->save(); + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $html = $this->withHtml($pageView); + foreach ($checks as $attribute => $check) { + $pageView->assertSee('thisisacattofind'); + $html->assertElementNotExists(".page-content [{$attribute}]"); + } + } + + public function test_metadata_redirects_are_removed() + { + config()->set('app.content_filtering', 'h'); + + $checks = [ + '', + '', + '', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); + $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); + $this->withHtml($pageView)->assertElementNotContains('.page-content', 'content='); + $this->withHtml($pageView)->assertElementNotContains('.page-content', 'external_url'); + } + } + + public function test_page_inline_on_attributes_removed_by_default() + { + config()->set('app.content_filtering', 'j'); + + $this->asEditor(); + $page = $this->entities->page(); + $script = '

    Hello

    '; + $page->html = "escape {$script}"; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertDontSee($script, false); + $pageView->assertSee('

    Hello

    ', false); + } + + public function test_more_complex_inline_on_attributes_escaping_scenarios() + { + config()->set('app.content_filtering', 'j'); + + $checks = [ + '

    Hello

    ', + '

    Hello

    ', + '
    Lorem ipsum dolor sit amet.

    Hello

    ', + '
    Lorem ipsum dolor sit amet.

    Hello

    ', + '
    Lorem ipsum dolor sit amet.

    Hello

    ', + '
    Lorem ipsum dolor sit amet.

    Hello

    ', + '
    xss link\', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $this->withHtml($pageView)->assertElementNotContains('.page-content', 'onclick'); + } + } + + public function test_page_content_scripts_show_with_filters_disabled() + { + $this->asEditor(); + $page = $this->entities->page(); + config()->set('app.content_filtering', ''); + + $script = 'abc123abc123'; + $page->html = "no escape {$script}"; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertSee($script, false); + $pageView->assertDontSee('abc123abc123'); + } + + public function test_svg_script_usage_is_removed() + { + config()->set('app.content_filtering', 'j'); + + $checks = [ + '', + '', + '', + '', + '', + 'XSS', + 'XSS', + '', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $html = $this->withHtml($pageView); + $html->assertElementNotContains('.page-content', 'alert'); + $html->assertElementNotContains('.page-content', 'xlink:href'); + $html->assertElementNotContains('.page-content', 'application/xml'); + $html->assertElementNotContains('.page-content', 'javascript'); + } + } + + public function test_page_inline_on_attributes_show_with_filters_disabled() + { + $this->asEditor(); + $page = $this->entities->page(); + config()->set('app.content_filtering', ''); + + $script = '

    Hello

    '; + $page->html = "escape {$script}"; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertSee($script, false); + $pageView->assertDontSee('

    Hello

    ', false); + } + + public function test_non_content_filtering_is_controlled_by_config() + { + config()->set('app.content_filtering', ''); + $page = $this->entities->page(); + $html = <<<'HTML' + + +HTML; + $page->html = $html; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertSee('superbeans', false); + + config()->set('app.content_filtering', 'h'); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertDontSee('superbeans', false); + } + + public function test_non_content_filtering() + { + config()->set('app.content_filtering', 'h'); + $page = $this->entities->page(); + $html = <<<'HTML' + +

    inbetweenpsection

    + + +superbeans! + +HTML; + + $page->html = $html; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertDontSee('superbeans', false); + $resp->assertSee('inbetweenpsection', false); + } + + public function test_allow_list_filtering_is_controlled_by_config() + { + config()->set('app.content_filtering', ''); + $page = $this->entities->page(); + $page->html = '
    Hello!
    '; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertSee('style="position: absolute; left: 0;color:#00FFEE;"', false); + + config()->set('app.content_filtering', 'a'); + $resp = $this->get($page->getUrl()); + $resp->assertDontSee('style="position: absolute; left: 0;color:#00FFEE;"', false); + $resp->assertSee('style="color:#00FFEE;"', false); + } + + public function test_allow_list_style_filtering() + { + $testCasesExpectedByInput = [ + '
    Hello!
    ' => '
    Hello!
    ', + '
    Hello!
    ' => '
    Hello!
    ', + '
    Hello!
    ' => '
    Hello!
    ', + '
    Hello!
    ' => '
    Hello!
    ', + '' => '', + '' => '', + '' => '', + '
    My local image
    ' => '
    ', + '
    My local image
    ' => '
    ', + '
    My local image
    ' => '
    My local image
    ', + '
    cat
    ' => '
    cat
    ', + '' => '', + ]; + + config()->set('app.content_filtering', 'a'); + $page = $this->entities->page(); + $this->asEditor(); + + foreach ($testCasesExpectedByInput as $input => $expected) { + $page->html = $input; + $page->save(); + $resp = $this->get($page->getUrl()); + + $resp->assertSee($expected, false); + $resp->assertDontSee($input, false); + } + } + + public function test_media_protocol_relative_urls_are_given_scheme_depending_on_app_url() + { + $testCasesExpectedByInput = [ + '
    My local image
    ' => '
    My local image
    ', + '' => '', + '' => '', + '' => '', + '
    My local image
    ' => '
    My local image
    ', + ]; + + $baseUrls = ['https://example.com' => 'https', 'http://example.com' => 'http']; + foreach ($baseUrls as $baseUrl => $expectedScheme) { + $this->runWithEnv(['APP_URL' => $baseUrl], function () use ($expectedScheme, $baseUrl, $testCasesExpectedByInput) { + config()->set('app.content_filtering', 'a'); + $page = $this->entities->page(); + $this->asEditor(); + + foreach ($testCasesExpectedByInput as $input => $expected) { + $page->html = $input; + $page->save(); + $resp = $this->get($page->getUrl()); + + $resp->assertSee(str_replace('SCHEME', $expectedScheme, $expected), false); + $resp->assertDontSee($input, false); + } + }); + } + } + + public function test_allow_list_does_not_filter_cases() + { + $testCasesExpectedByInput = [ + '

    New tab linkydoodle

    ', + '

    @mentionusertext

    ', + '

    Link to file

    ', + '
    Hello

    Mydetailshere

    ', + ]; + + config()->set('app.content_filtering', 'a'); + $page = $this->entities->page(); + $this->asEditor(); + + foreach ($testCasesExpectedByInput as $input) { + $page->html = $input; + $page->save(); + $resp = $this->get($page->getUrl()); + + $resp->assertSee($input, false); + } + } +} diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php index 28897c14d2f..4d97e6b5961 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -101,261 +101,6 @@ public function test_page_includes_to_nonexisting_pages_does_not_error() $pageResp->assertSee('Hello Barry'); } - public function test_page_content_scripts_removed_by_default() - { - $this->asEditor(); - $page = $this->entities->page(); - $script = 'abc123abc123'; - $page->html = "escape {$script}"; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $pageView->assertDontSee($script, false); - $pageView->assertSee('abc123abc123'); - } - - public function test_more_complex_content_script_escaping_scenarios() - { - $checks = [ - "

    Some script

    ", - "

    Some script

    ", - "

    Some script

    ", - "

    Some script

    ", - "

    Some script

    ", - "

    Some script

    ", - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); - } - } - - public function test_js_and_base64_src_urls_are_removed() - { - $checks = [ - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $html = $this->withHtml($pageView); - $html->assertElementNotContains('.page-content', ''); - $html->assertElementNotContains('.page-content', 'src='); - $html->assertElementNotContains('.page-content', 'javascript:'); - $html->assertElementNotContains('.page-content', 'data:'); - $html->assertElementNotContains('.page-content', 'base64'); - } - } - - public function test_javascript_uri_links_are_removed() - { - $checks = [ - 'withHtml($pageView)->assertElementNotContains('.page-content', 'href=javascript:'); - } - } - - public function test_form_actions_with_javascript_are_removed() - { - $checks = [ - '
    ', - '
    ', - '
    ', - '
    ', - '
    ', - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $this->withHtml($pageView)->assertElementNotContains('.page-content', '