]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'master' of https://github.com/theodor-franke/BookStack into theodor...
authorDan Brown <redacted>
Thu, 21 Oct 2021 13:04:23 +0000 (14:04 +0100)
committerDan Brown <redacted>
Thu, 21 Oct 2021 13:04:23 +0000 (14:04 +0100)
288 files changed:
.env.example
.env.example.complete
.github/ISSUE_TEMPLATE/api_request.md [deleted file]
.github/ISSUE_TEMPLATE/api_request.yml [new file with mode: 0644]
.github/ISSUE_TEMPLATE/bug_report.md [deleted file]
.github/ISSUE_TEMPLATE/bug_report.yml [new file with mode: 0644]
.github/ISSUE_TEMPLATE/feature_request.md [deleted file]
.github/ISSUE_TEMPLATE/feature_request.yml [new file with mode: 0644]
.github/ISSUE_TEMPLATE/language_request.md [deleted file]
.github/ISSUE_TEMPLATE/language_request.yml [new file with mode: 0644]
.github/ISSUE_TEMPLATE/support_request.yml [new file with mode: 0644]
.github/translators.txt
app/Actions/ActivityService.php
app/Actions/Comment.php
app/Actions/TagRepo.php
app/Auth/Access/GroupSyncService.php [moved from app/Auth/Access/ExternalAuthService.php with 93% similarity]
app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php [moved from app/Auth/Access/Guards/Saml2SessionGuard.php with 92% similarity]
app/Auth/Access/LdapService.php
app/Auth/Access/LoginService.php
app/Auth/Access/Mfa/TotpService.php
app/Auth/Access/Oidc/OidcAccessToken.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcIdToken.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcInvalidKeyException.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcInvalidTokenException.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcJwtSigningKey.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcOAuthProvider.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcProviderSettings.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcService.php [new file with mode: 0644]
app/Auth/Access/RegistrationService.php
app/Auth/Access/Saml2Service.php
app/Auth/Access/SocialAuthService.php
app/Auth/Access/UserTokenService.php
app/Auth/Permissions/PermissionService.php
app/Auth/Role.php
app/Auth/UserRepo.php
app/Config/app.php
app/Config/auth.php
app/Config/database.php
app/Config/dompdf.php
app/Config/filesystems.php
app/Config/oidc.php [new file with mode: 0644]
app/Console/Commands/RegenerateSearch.php
app/Entities/Models/Book.php
app/Entities/Models/Page.php
app/Entities/Models/PageRevision.php
app/Entities/Tools/ExportFormatter.php
app/Entities/Tools/PageContent.php
app/Entities/Tools/PageEditActivity.php
app/Entities/Tools/SearchRunner.php
app/Exceptions/OpenIdConnectException.php [new file with mode: 0644]
app/Exceptions/StoppedAuthenticationException.php
app/Exceptions/WhoopsBookStackPrettyHandler.php [new file with mode: 0644]
app/Http/Controllers/Api/AttachmentApiController.php [new file with mode: 0644]
app/Http/Controllers/AttachmentController.php
app/Http/Controllers/Auth/ForgotPasswordController.php
app/Http/Controllers/Auth/LoginController.php
app/Http/Controllers/Auth/MfaTotpController.php
app/Http/Controllers/Auth/OidcController.php [new file with mode: 0644]
app/Http/Controllers/Auth/RegisterController.php
app/Http/Controllers/Auth/Saml2Controller.php
app/Http/Controllers/HomeController.php
app/Http/Controllers/PageController.php
app/Http/Controllers/UserController.php
app/Http/Kernel.php
app/Http/Middleware/ApplyCspRules.php [new file with mode: 0644]
app/Http/Middleware/CheckEmailConfirmed.php [new file with mode: 0644]
app/Http/Middleware/ControlIframeSecurity.php [deleted file]
app/Http/Middleware/PreventAuthenticatedResponseCaching.php [new file with mode: 0644]
app/Providers/AppServiceProvider.php
app/Providers/AuthServiceProvider.php
app/Providers/RouteServiceProvider.php
app/Theming/CustomHtmlHeadContentProvider.php [new file with mode: 0644]
app/Uploads/Attachment.php
app/Uploads/AttachmentService.php
app/Uploads/ImageRepo.php
app/Uploads/ImageService.php
app/Util/CspService.php [new file with mode: 0644]
app/Util/HtmlContentFilter.php
app/Util/HtmlNonceApplicator.php [new file with mode: 0644]
composer.json
composer.lock
database/migrations/2016_04_20_192649_create_joint_permissions_table.php
database/migrations/2021_09_26_044614_add_activities_ip_column.php [new file with mode: 0644]
dev/api/requests/attachments-create.json [new file with mode: 0644]
dev/api/requests/attachments-update.json [new file with mode: 0644]
dev/api/responses/attachments-create.json [new file with mode: 0644]
dev/api/responses/attachments-list.json [new file with mode: 0644]
dev/api/responses/attachments-read.json [new file with mode: 0644]
dev/api/responses/attachments-update.json [new file with mode: 0644]
phpunit.xml
readme.md
resources/icons/oidc.svg [new file with mode: 0644]
resources/js/components/page-editor.js
resources/lang/ar/entities.php
resources/lang/ar/settings.php
resources/lang/bg/entities.php
resources/lang/bg/settings.php
resources/lang/bs/entities.php
resources/lang/bs/settings.php
resources/lang/ca/entities.php
resources/lang/ca/settings.php
resources/lang/cs/activities.php
resources/lang/cs/auth.php
resources/lang/cs/common.php
resources/lang/cs/entities.php
resources/lang/cs/settings.php
resources/lang/cs/validation.php
resources/lang/da/activities.php
resources/lang/da/auth.php
resources/lang/da/common.php
resources/lang/da/entities.php
resources/lang/da/errors.php
resources/lang/da/settings.php
resources/lang/da/validation.php
resources/lang/de/activities.php
resources/lang/de/auth.php
resources/lang/de/common.php
resources/lang/de/entities.php
resources/lang/de/errors.php
resources/lang/de/settings.php
resources/lang/de/validation.php
resources/lang/de_informal/common.php
resources/lang/de_informal/entities.php
resources/lang/de_informal/settings.php
resources/lang/en/entities.php
resources/lang/en/errors.php
resources/lang/en/settings.php
resources/lang/es/auth.php
resources/lang/es/entities.php
resources/lang/es/settings.php
resources/lang/es_AR/activities.php
resources/lang/es_AR/auth.php
resources/lang/es_AR/entities.php
resources/lang/es_AR/settings.php
resources/lang/es_AR/validation.php
resources/lang/fa/activities.php
resources/lang/fa/auth.php
resources/lang/fa/entities.php
resources/lang/fa/settings.php
resources/lang/fr/activities.php
resources/lang/fr/auth.php
resources/lang/fr/common.php
resources/lang/fr/components.php
resources/lang/fr/entities.php
resources/lang/fr/errors.php
resources/lang/fr/passwords.php
resources/lang/fr/settings.php
resources/lang/fr/validation.php
resources/lang/he/entities.php
resources/lang/he/settings.php
resources/lang/hr/entities.php
resources/lang/hr/settings.php
resources/lang/hu/entities.php
resources/lang/hu/settings.php
resources/lang/id/entities.php
resources/lang/id/settings.php
resources/lang/it/activities.php
resources/lang/it/auth.php
resources/lang/it/common.php
resources/lang/it/entities.php
resources/lang/it/settings.php
resources/lang/it/validation.php
resources/lang/ja/entities.php
resources/lang/ja/settings.php
resources/lang/ko/entities.php
resources/lang/ko/settings.php
resources/lang/lt/entities.php
resources/lang/lt/settings.php
resources/lang/lv/activities.php
resources/lang/lv/auth.php
resources/lang/lv/common.php
resources/lang/lv/entities.php
resources/lang/lv/settings.php
resources/lang/lv/validation.php
resources/lang/nb/activities.php
resources/lang/nb/auth.php
resources/lang/nb/common.php
resources/lang/nb/entities.php
resources/lang/nb/settings.php
resources/lang/nb/validation.php
resources/lang/nl/entities.php
resources/lang/nl/settings.php
resources/lang/pl/activities.php
resources/lang/pl/entities.php
resources/lang/pl/settings.php
resources/lang/pl/validation.php
resources/lang/pt/activities.php
resources/lang/pt/auth.php
resources/lang/pt/common.php
resources/lang/pt/entities.php
resources/lang/pt/settings.php
resources/lang/pt/validation.php
resources/lang/pt_BR/entities.php
resources/lang/pt_BR/settings.php
resources/lang/ru/activities.php
resources/lang/ru/auth.php
resources/lang/ru/entities.php
resources/lang/ru/settings.php
resources/lang/ru/validation.php
resources/lang/sk/activities.php
resources/lang/sk/auth.php
resources/lang/sk/common.php
resources/lang/sk/entities.php
resources/lang/sk/errors.php
resources/lang/sk/settings.php
resources/lang/sl/entities.php
resources/lang/sl/settings.php
resources/lang/sv/entities.php
resources/lang/sv/settings.php
resources/lang/tr/entities.php
resources/lang/tr/settings.php
resources/lang/uk/activities.php
resources/lang/uk/entities.php
resources/lang/uk/settings.php
resources/lang/vi/activities.php
resources/lang/vi/auth.php
resources/lang/vi/common.php
resources/lang/vi/entities.php
resources/lang/vi/settings.php
resources/lang/zh_CN/activities.php
resources/lang/zh_CN/auth.php
resources/lang/zh_CN/common.php
resources/lang/zh_CN/entities.php
resources/lang/zh_CN/settings.php
resources/lang/zh_CN/validation.php
resources/lang/zh_TW/entities.php
resources/lang/zh_TW/settings.php
resources/sass/_layout.scss
resources/views/api-docs/parts/getting-started.blade.php
resources/views/auth/parts/login-form-oidc.blade.php [new file with mode: 0644]
resources/views/common/custom-head.blade.php
resources/views/common/export-custom-head.blade.php
resources/views/errors/debug.blade.php [new file with mode: 0644]
resources/views/layouts/base.blade.php
resources/views/mfa/totp-generate.blade.php
resources/views/pages/edit.blade.php
resources/views/settings/audit.blade.php
resources/views/settings/index.blade.php
resources/views/settings/roles/form.blade.php [new file with mode: 0644]
resources/views/settings/roles/parts/form.blade.php
resources/views/users/parts/form.blade.php
routes/api.php
routes/web.php
tests/ActivityTrackingTest.php [deleted file]
tests/Api/ApiDocsTest.php
tests/Api/AttachmentsApiTest.php [new file with mode: 0644]
tests/Api/TestsApi.php
tests/AuditLogTest.php
tests/Auth/AuthTest.php
tests/Auth/MfaConfigurationTest.php
tests/Auth/OidcTest.php [new file with mode: 0644]
tests/Auth/Saml2Test.php
tests/Auth/SocialAuthTest.php
tests/Auth/UserInviteTest.php
tests/BrowserKitTest.php [deleted file]
tests/DebugViewTest.php [new file with mode: 0644]
tests/Entity/BookShelfTest.php
tests/Entity/BookTest.php
tests/Entity/ChapterTest.php
tests/Entity/EntityAccessTest.php [new file with mode: 0644]
tests/Entity/EntityTest.php [deleted file]
tests/Entity/ExportTest.php
tests/Entity/MarkdownTest.php [deleted file]
tests/Entity/PageContentTest.php
tests/Entity/PageDraftTest.php
tests/Entity/PageEditorTest.php [new file with mode: 0644]
tests/Entity/PageTest.php
tests/Entity/SortTest.php
tests/Helpers/OidcJwtHelper.php [new file with mode: 0644]
tests/HomepageTest.php
tests/Permissions/EntityPermissionsTest.php
tests/Permissions/RolesTest.php
tests/PublicActionTest.php
tests/RecycleBinTest.php
tests/SecurityHeaderTest.php
tests/Settings/CustomHeadContentTest.php [new file with mode: 0644]
tests/Settings/FooterLinksTest.php [moved from tests/FooterLinksTest.php with 98% similarity]
tests/SharedTestHelpers.php
tests/TestCase.php
tests/ThemeTest.php
tests/Unit/ConfigTest.php
tests/Unit/OidcIdTokenTest.php [new file with mode: 0644]
tests/Uploads/AttachmentTest.php
tests/User/UserManagementTest.php
tests/User/UserPreferencesTest.php
tests/User/UserProfileTest.php
version

index 05383f04abcce2f08d732f2b08719cb5b3775a76..a0a1b72e6836bcab495a34c0f8633ea295edb644 100644 (file)
@@ -41,4 +41,4 @@ MAIL_HOST=localhost
 MAIL_PORT=1025
 MAIL_USERNAME=null
 MAIL_PASSWORD=null
-MAIL_ENCRYPTION=null
\ No newline at end of file
+MAIL_ENCRYPTION=null
index 58e4e47543d9df1b0d9fbc7ba66c5dc5f9e9f982..a29afaafd014f600cd90784f39d04b1b76841670 100644 (file)
@@ -42,6 +42,14 @@ APP_TIMEZONE=UTC
 # overrides can be made. Defaults to disabled.
 APP_THEME=false
 
+# Trusted Proxies
+# Used to indicate trust of systems that proxy to the application so
+# certain header values (Such as "X-Forwarded-For") can be used from the
+# incoming proxy request to provide origin detail.
+# Set to an IP address, or multiple comma seperated IP addresses.
+# Can alternatively be set to "*" to trust all proxy addresses.
+APP_PROXIES=null
+
 # Database details
 # Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
 DB_HOST=localhost
@@ -236,6 +244,18 @@ SAML2_USER_TO_GROUPS=false
 SAML2_GROUP_ATTRIBUTE=group
 SAML2_REMOVE_FROM_GROUPS=false
 
+# OpenID Connect authentication configuration
+OIDC_NAME=SSO
+OIDC_DISPLAY_NAME_CLAIMS=name
+OIDC_CLIENT_ID=null
+OIDC_CLIENT_SECRET=null
+OIDC_ISSUER=null
+OIDC_ISSUER_DISCOVER=false
+OIDC_PUBLIC_KEY=null
+OIDC_AUTH_ENDPOINT=null
+OIDC_TOKEN_ENDPOINT=null
+OIDC_DUMP_USER_DETAILS=false
+
 # Disable default third-party services such as Gravatar and Draw.IO
 # Service-specific options will override this option
 DISABLE_EXTERNAL_SERVICES=false
@@ -286,6 +306,12 @@ ALLOW_CONTENT_SCRIPTS=false
 # Contents of the robots.txt file can be overridden, making this option obsolete.
 ALLOW_ROBOTS=null
 
+# Allow server-side fetches to be performed to potentially unknown
+# and user-provided locations. Primarily used in exports when loading
+# in externally referenced assets.
+# Can be 'true' or 'false'.
+ALLOW_UNTRUSTED_SERVER_FETCHING=false
+
 # A list of hosts that BookStack can be iframed within.
 # Space separated if multiple. BookStack host domain is auto-inferred.
 # For Example: ALLOWED_IFRAME_HOSTS="https://example.com https://a.example.com"
diff --git a/.github/ISSUE_TEMPLATE/api_request.md b/.github/ISSUE_TEMPLATE/api_request.md
deleted file mode 100644 (file)
index dc050ef..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
----
-name: New API Endpoint or Feature
-about: Request a new endpoint or API feature be added
-labels: ":nut_and_bolt: API Request"
----
-
-#### API Endpoint or Feature
-
-Clearly describe what you'd like to have added to the API. 
-
-#### Use-Case
-
-Explain the use-case that you're working-on that requires the above request.
-
-#### Additional Context
-
-If required, add any other context about the feature request here.
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/api_request.yml b/.github/ISSUE_TEMPLATE/api_request.yml
new file mode 100644 (file)
index 0000000..81e11e2
--- /dev/null
@@ -0,0 +1,26 @@
+name: New API Endpoint or API Ability
+description: Request a new endpoint or API feature be added
+title: "[API Request]: "
+labels: [":nut_and_bolt: API Request"]
+body:
+  - type: textarea
+    id: feature
+    attributes:
+      label: API Endpoint or Feature
+      description: Clearly describe what you'd like to have added to the API.
+    validations:
+      required: true
+  - type: textarea
+    id: usecase
+    attributes:
+      label: Use-Case
+      description: Explain the use-case that you're working-on that requires the above request.
+    validations:
+      required: true
+  - type: textarea
+    id: context
+    attributes:
+      label: Additional context
+      description: Add any other context about the feature request here.
+    validations:
+      required: false
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644 (file)
index c4444f2..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
----
-name: Bug Report
-about: Create a report to help us improve
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**Steps To Reproduce**
-Steps to reproduce the behavior:
-1. Go to '...'
-2. Click on '....'
-3. Scroll down to '....'
-4. See error
-
-**Expected behavior**
-A clear and concise description of what you expected to happen.
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
-
-**Your Configuration (please complete the following information):**
- - Exact BookStack Version (Found in settings):
- - PHP Version:
- - Hosting Method (Nginx/Apache/Docker): 
-
-**Additional context**
-Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644 (file)
index 0000000..35aa481
--- /dev/null
@@ -0,0 +1,62 @@
+name: Bug Report
+description: Create a report to help us improve or fix things
+title: "[Bug Report]: "
+labels: [":bug: Bug"]
+body:
+  - type: textarea
+    id: description
+    attributes:
+      label: Describe the Bug
+      description: Provide a clear and concise description of what the bug is.
+    validations:
+      required: true
+  - type: textarea
+    id: reproduction
+    attributes:
+      label: Steps to Reproduce
+      description: Detail the steps that would replicate this issue
+      placeholder: |
+        1. Go to '...'
+        2. Click on '....'
+        3. Scroll down to '....'
+        4. See error
+    validations:
+      required: true
+  - type: textarea
+    id: expected
+    attributes:
+      label: Expected Behaviour
+      description: Provide clear and concise description of what you expected to happen.
+    validations:
+      required: true
+  - type: textarea
+    id: context
+    attributes:
+      label: Screenshots or Additional Context
+      description: Provide any additional context and screenshots here to help us solve this issue
+    validations:
+      required: false
+  - type: input
+    id: bsversion
+    attributes:
+      label: Exact BookStack Version
+      description: This can be found in the settings view of BookStack. Please provide an exact version.
+      placeholder: (eg. v21.08.5)
+    validations:
+      required: true
+  - type: input
+    id: phpversion
+    attributes:
+      label: PHP Version
+      description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue.
+      placeholder: (eg. 7.4)
+    validations:
+      required: false
+  - type: textarea
+    id: hosting
+    attributes:
+      label: Hosting Environment
+      description: Describe your hosting environment as much as possible including any proxies used (If applicable).
+      placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
+    validations:
+      required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644 (file)
index 781cca5..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
----
-name: Feature Request
-about: Suggest an idea for this project
-
----
-
-**Describe the feature you'd like**
-A clear description of the feature you'd like implemented in BookStack.
-
-**Describe the benefits this feature would bring to BookStack users**
-Explain the measurable benefits this feature would achieve.
-
-**Additional context**
-Add any other context or screenshots about the feature request here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644 (file)
index 0000000..a945c34
--- /dev/null
@@ -0,0 +1,26 @@
+name: Feature Request
+description: Request a new language to be added to CrowdIn for you to translate
+title: "[Feature Request]: "
+labels: [":hammer: Feature Request"]
+body:
+  - type: textarea
+    id: description
+    attributes:
+      label: Describe the feature you'd like
+      description: Provide a clear description of the feature you'd like implemented in BookStack
+    validations:
+      required: true
+  - type: textarea
+    id: benefits
+    attributes:
+      label: Describe the benefits this feature would bring to BookStack users
+      description: Explain the measurable benefits this feature would achieve for existing BookStack users
+    validations:
+      required: true
+  - type: textarea
+    id: context
+    attributes:
+      label: Additional context
+      description: Add any other context or screenshots about the feature request here.
+    validations:
+      required: false
diff --git a/.github/ISSUE_TEMPLATE/language_request.md b/.github/ISSUE_TEMPLATE/language_request.md
deleted file mode 100644 (file)
index 249ef78..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
----
-name: Language Request
-about: Request a new language to be added to Crowdin for you to translate
-
----
-
-### Language To Add
-
-_Specify here the language you want to add._
-
-----
-
-_This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack). Please don't use this template to request a new language that you are not prepared to provide translations for._   
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/language_request.yml b/.github/ISSUE_TEMPLATE/language_request.yml
new file mode 100644 (file)
index 0000000..b94bb88
--- /dev/null
@@ -0,0 +1,32 @@
+name: Language Request
+description: Request a new language to be added to CrowdIn for you to translate
+title: "[Language Request]: "
+labels: [":earth_africa: Translations"]
+assignees:
+  - ssddanbrown
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for offering to help start a new translation for BookStack!
+  - type: input
+    id: language
+    attributes:
+      label: Language to Add
+      description: What language (and region if applicable) are you offering to help add to BookStack?
+    validations:
+      required: true
+  - type: checkboxes
+    id: confirm
+    attributes:
+      label: Confirmation of Intent
+      description: |
+        This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack).
+        Please don't use this template to request a new language that you are not prepared to provide translations for.
+      options:
+        - label: I confirm I'm offering to help translate for this new language via CrowdIn.
+          required: true
+  - type: markdown
+    attributes:
+      value: |
+        *__Note: New languages are added at specific points of the development process so it may be a small while before the requested language is added for translation.__*
diff --git a/.github/ISSUE_TEMPLATE/support_request.yml b/.github/ISSUE_TEMPLATE/support_request.yml
new file mode 100644 (file)
index 0000000..bd52b12
--- /dev/null
@@ -0,0 +1,63 @@
+name: Support Request
+description: Request support for a specific problem you have not been able to solve yourself
+title: "[Support Request]: "
+labels: [":dog2: Support"]
+body:
+  - type: checkboxes
+    id: useddocs
+    attributes:
+      label: Attempted Debugging
+      description: |
+        I have read the [BookStack debugging](https://www.bookstackapp.com/docs/admin/debugging/) page and seeked resolution or more
+        detail for the issue.
+      options:
+        - label: I have read the debugging page
+          required: true
+  - type: checkboxes
+    id: searchissue
+    attributes:
+      label: Searched GitHub Issues
+      description: |
+        I have searched for the issue and potential resolutions within the [project's GitHub issue list](https://github.com/BookStackApp/BookStack/issues)
+      options:
+        - label: I have searched GitHub for the issue.
+          required: true
+  - type: textarea
+    id: scenario
+    attributes:
+      label: Describe the Scenario
+      description: Detail the problem that you're having or what you need support with.
+    validations:
+      required: true
+  - type: input
+    id: bsversion
+    attributes:
+      label: Exact BookStack Version
+      description: This can be found in the settings view of BookStack. Please provide an exact version.
+      placeholder: (eg. v21.08.5)
+    validations:
+      required: true
+  - type: textarea
+    id: logs
+    attributes:
+      label: Log Content
+      description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below.
+      placeholder: Be sure to remove any confidential details in your logs
+    validations:
+      required: false
+  - type: input
+    id: phpversion
+    attributes:
+      label: PHP Version
+      description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that most relevant to the issue.
+      placeholder: (eg. 7.4)
+    validations:
+      required: false
+  - type: textarea
+    id: hosting
+    attributes:
+      label: Hosting Environment
+      description: Describe your hosting environment as much as possible including any proxies used (If applicable).
+      placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
+    validations:
+      required: true
index baaea5d92261fc0035000e4a42fe715b76bc58bf..ff91f4033d162adb498d374bab6705a35e11a139 100644 (file)
@@ -167,3 +167,28 @@ whenwesober :: Indonesian
 Rem (remkovdhoef) :: Dutch
 syn7ax69 :: Bulgarian; Turkish
 Blaade :: French
+Behzad HosseinPoor (behzad.hp) :: Persian
+Ole Aldric (Swoy) :: Norwegian Bokmal
+fharis arabia (raednahdi) :: Arabic
+Alexander Predl (Harveyhase68) :: German
+Rem (Rem9000) :: Dutch
+Michał Stelmach (stelmach-web) :: Polish
+arniom :: French
+REMOVED_USER :: Turkish
+林祖年 (contagion) :: Chinese Traditional
+Siamak Guodarzi (siamakgoudarzi88) :: Persian
+Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
+Nathanaël (nathanaelhoun) :: French
+A Ibnu Hibban (abd.ibnuhibban) :: Indonesian
+Frost-ZX :: Chinese Simplified
+Kuzma Simonov (ovmach) :: Russian
+Vojtěch Krystek (acantophis) :: Czech
+Michał Lipok (mLipok) :: Polish
+Nicolas Pawlak (Mikolajek) :: French; Polish; German
+Thomas Hansen (thomasdk81) :: Danish
+Hl2run :: Slovak
+Ngo Tri Hoai (trihoai) :: Vietnamese
+Atalonica :: Catalan
+慕容潭谈 (591442386) :: Chinese Simplified
+Radim Pesek (ramess18) :: Czech
+anastasiia.motylko :: Ukrainian
index dce7dc7b2595df00a1652bcfbcce7124de811b62..bc7a6b6b7c3353f39384ae73d88eaca284d5ed41 100644 (file)
@@ -55,9 +55,12 @@ class ActivityService
      */
     protected function newActivityForUser(string $type): Activity
     {
+        $ip = request()->ip() ?? '';
+
         return $this->activity->newInstance()->forceFill([
             'type'     => strtolower($type),
             'user_id'  => user()->id,
+            'ip'       => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
         ]);
     }
 
index ef390939e2fe7f65c98a151364ced89788294864..34fd84709ec1d746bd84c2de87854a3845743b01 100644 (file)
@@ -7,10 +7,11 @@ use BookStack\Traits\HasCreatorAndUpdater;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
 
 /**
- * @property string text
- * @property string html
- * @property int|null parent_id
- * @property int local_id
+ * @property int      $id
+ * @property string   $text
+ * @property string   $html
+ * @property int|null $parent_id
+ * @property int      $local_id
  */
 class Comment extends Model
 {
index ca65b78e8e2dab7b7742204f6decd8e0c5a908f2..b892efe577901191c4a7fea292e134eefbff86ea 100644 (file)
@@ -4,8 +4,8 @@ namespace BookStack\Actions;
 
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Entities\Models\Entity;
-use DB;
 use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
 
 class TagRepo
 {
similarity index 93%
rename from app/Auth/Access/ExternalAuthService.php
rename to app/Auth/Access/GroupSyncService.php
index 7bd3679ac0653829989a090778ed7e0062bc2c4e..db19b007ac32b31f5a2bb6112514b6aa7b271826 100644 (file)
@@ -6,7 +6,7 @@ use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use Illuminate\Support\Collection;
 
-class ExternalAuthService
+class GroupSyncService
 {
     /**
      * Check a role against an array of group names to see if it matches.
@@ -60,13 +60,13 @@ class ExternalAuthService
     /**
      * Sync the groups to the user roles for the current user.
      */
-    public function syncWithGroups(User $user, array $userGroups): void
+    public function syncUserWithFoundGroups(User $user, array $userGroups, bool $detachExisting): void
     {
         // Get the ids for the roles from the names
         $groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups);
 
         // Sync groups
-        if ($this->config['remove_from_groups']) {
+        if ($detachExisting) {
             $user->roles()->sync($groupsAsRoles);
             $user->attachDefaultRole();
         } else {
similarity index 92%
rename from app/Auth/Access/Guards/Saml2SessionGuard.php
rename to app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php
index eacd5d21e702f13efcbc654589a932a945131661..6677f5b108393a1b7d1bc68fc6b1605a826cacf1 100644 (file)
@@ -10,7 +10,7 @@ namespace BookStack\Auth\Access\Guards;
  * via the Saml2 controller & Saml2Service. This class provides a safer, thin
  * version of SessionGuard.
  */
-class Saml2SessionGuard extends ExternalBaseSessionGuard
+class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
 {
     /**
      * Validate a user's credentials.
index 7bfdb5328d874e5296f0227253a45475b2baddd5..e3a38537ac3ae4ae9def8d7336b29a330e587aa6 100644 (file)
@@ -13,9 +13,10 @@ use Illuminate\Support\Facades\Log;
  * Class LdapService
  * Handles any app-specific LDAP tasks.
  */
-class LdapService extends ExternalAuthService
+class LdapService
 {
     protected $ldap;
+    protected $groupSyncService;
     protected $ldapConnection;
     protected $userAvatars;
     protected $config;
@@ -24,20 +25,19 @@ class LdapService extends ExternalAuthService
     /**
      * LdapService constructor.
      */
-    public function __construct(Ldap $ldap, UserAvatars $userAvatars)
+    public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
     {
         $this->ldap = $ldap;
         $this->userAvatars = $userAvatars;
+        $this->groupSyncService = $groupSyncService;
         $this->config = config('services.ldap');
         $this->enabled = config('auth.method') === 'ldap';
     }
 
     /**
      * Check if groups should be synced.
-     *
-     * @return bool
      */
-    public function shouldSyncGroups()
+    public function shouldSyncGroups(): bool
     {
         return $this->enabled && $this->config['user_to_groups'] !== false;
     }
@@ -285,9 +285,8 @@ class LdapService extends ExternalAuthService
         }
 
         $userGroups = $this->groupFilter($user);
-        $userGroups = $this->getGroupsRecursive($userGroups, []);
 
-        return $userGroups;
+        return $this->getGroupsRecursive($userGroups, []);
     }
 
     /**
@@ -374,7 +373,7 @@ class LdapService extends ExternalAuthService
     public function syncGroups(User $user, string $username)
     {
         $userLdapGroups = $this->getUserGroups($username);
-        $this->syncWithGroups($user, $userLdapGroups);
+        $this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);
     }
 
     /**
index e02296b37309fa731f6d916a9ae416b22da8685f..f41570417ef7f4594495a042e9834eda69adbb7d 100644 (file)
@@ -47,7 +47,7 @@ class LoginService
 
         // Authenticate on all session guards if a likely admin
         if ($user->can('users-manage') && $user->can('user-roles-manage')) {
-            $guards = ['standard', 'ldap', 'saml2'];
+            $guards = ['standard', 'ldap', 'saml2', 'oidc'];
             foreach ($guards as $guard) {
                 auth($guard)->login($user);
             }
index a3e9fc82754ae14dd8c9a6b14c19f11ad24fffdc..e73c549fe0790230f89cb97334afcdc1ca9079a0 100644 (file)
@@ -8,6 +8,7 @@ use BaconQrCode\Renderer\ImageRenderer;
 use BaconQrCode\Renderer\RendererStyle\Fill;
 use BaconQrCode\Renderer\RendererStyle\RendererStyle;
 use BaconQrCode\Writer;
+use BookStack\Auth\User;
 use PragmaRX\Google2FA\Google2FA;
 use PragmaRX\Google2FA\Support\Constants;
 
@@ -36,11 +37,11 @@ class TotpService
     /**
      * Generate a TOTP URL from secret key.
      */
-    public function generateUrl(string $secret): string
+    public function generateUrl(string $secret, User $user): string
     {
         return $this->google2fa->getQRCodeUrl(
             setting('app-name'),
-            user()->email,
+            $user->email,
             $secret
         );
     }
@@ -54,7 +55,7 @@ class TotpService
 
         return (new Writer(
             new ImageRenderer(
-                new RendererStyle(192, 0, null, null, $color),
+                new RendererStyle(192, 4, null, null, $color),
                 new SvgImageBackEnd()
             )
         ))->writeString($url);
diff --git a/app/Auth/Access/Oidc/OidcAccessToken.php b/app/Auth/Access/Oidc/OidcAccessToken.php
new file mode 100644 (file)
index 0000000..520966f
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use InvalidArgumentException;
+use League\OAuth2\Client\Token\AccessToken;
+
+class OidcAccessToken extends AccessToken
+{
+    /**
+     * Constructs an access token.
+     *
+     * @param array $options An array of options returned by the service provider
+     *                       in the access token request. The `access_token` option is required.
+     *
+     * @throws InvalidArgumentException if `access_token` is not provided in `$options`.
+     */
+    public function __construct(array $options = [])
+    {
+        parent::__construct($options);
+        $this->validate($options);
+    }
+
+    /**
+     * Validate this access token response for OIDC.
+     * As per https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
+     */
+    private function validate(array $options): void
+    {
+        // access_token: REQUIRED. Access Token for the UserInfo Endpoint.
+        // Performed on the extended class
+
+        // token_type: REQUIRED. OAuth 2.0 Token Type value. The value MUST be Bearer, as specified in OAuth 2.0
+        // Bearer Token Usage [RFC6750], for Clients using this subset.
+        // Note that the token_type value is case-insensitive.
+        if (strtolower(($options['token_type'] ?? '')) !== 'bearer') {
+            throw new InvalidArgumentException('The response token type MUST be "Bearer"');
+        }
+
+        // id_token: REQUIRED. ID Token.
+        if (empty($options['id_token'])) {
+            throw new InvalidArgumentException('An "id_token" property must be provided');
+        }
+    }
+
+    /**
+     * Get the id token value from this access token response.
+     */
+    public function getIdToken(): string
+    {
+        return $this->getValues()['id_token'];
+    }
+}
diff --git a/app/Auth/Access/Oidc/OidcIdToken.php b/app/Auth/Access/Oidc/OidcIdToken.php
new file mode 100644 (file)
index 0000000..c955c3b
--- /dev/null
@@ -0,0 +1,238 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+class OidcIdToken
+{
+    /**
+     * @var array
+     */
+    protected $header;
+
+    /**
+     * @var array
+     */
+    protected $payload;
+
+    /**
+     * @var string
+     */
+    protected $signature;
+
+    /**
+     * @var array[]|string[]
+     */
+    protected $keys;
+
+    /**
+     * @var string
+     */
+    protected $issuer;
+
+    /**
+     * @var array
+     */
+    protected $tokenParts = [];
+
+    public function __construct(string $token, string $issuer, array $keys)
+    {
+        $this->keys = $keys;
+        $this->issuer = $issuer;
+        $this->parse($token);
+    }
+
+    /**
+     * Parse the token content into its components.
+     */
+    protected function parse(string $token): void
+    {
+        $this->tokenParts = explode('.', $token);
+        $this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
+        $this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
+        $this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
+    }
+
+    /**
+     * Parse a Base64-JSON encoded token part.
+     * Returns the data as a key-value array or empty array upon error.
+     */
+    protected function parseEncodedTokenPart(string $part): array
+    {
+        $json = $this->base64UrlDecode($part) ?: '{}';
+        $decoded = json_decode($json, true);
+
+        return is_array($decoded) ? $decoded : [];
+    }
+
+    /**
+     * Base64URL decode. Needs some character conversions to be compatible
+     * with PHP's default base64 handling.
+     */
+    protected function base64UrlDecode(string $encoded): string
+    {
+        return base64_decode(strtr($encoded, '-_', '+/'));
+    }
+
+    /**
+     * Validate all possible parts of the id token.
+     *
+     * @throws OidcInvalidTokenException
+     */
+    public function validate(string $clientId): bool
+    {
+        $this->validateTokenStructure();
+        $this->validateTokenSignature();
+        $this->validateTokenClaims($clientId);
+
+        return true;
+    }
+
+    /**
+     * Fetch a specific claim from this token.
+     * Returns null if it is null or does not exist.
+     *
+     * @return mixed|null
+     */
+    public function getClaim(string $claim)
+    {
+        return $this->payload[$claim] ?? null;
+    }
+
+    /**
+     * Get all returned claims within the token.
+     */
+    public function getAllClaims(): array
+    {
+        return $this->payload;
+    }
+
+    /**
+     * Validate the structure of the given token and ensure we have the required pieces.
+     * As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
+     *
+     * @throws OidcInvalidTokenException
+     */
+    protected function validateTokenStructure(): void
+    {
+        foreach (['header', 'payload'] as $prop) {
+            if (empty($this->$prop) || !is_array($this->$prop)) {
+                throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
+            }
+        }
+
+        if (empty($this->signature) || !is_string($this->signature)) {
+            throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
+        }
+    }
+
+    /**
+     * Validate the signature of the given token and ensure it validates against the provided key.
+     *
+     * @throws OidcInvalidTokenException
+     */
+    protected function validateTokenSignature(): void
+    {
+        if ($this->header['alg'] !== 'RS256') {
+            throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
+        }
+
+        $parsedKeys = array_map(function ($key) {
+            try {
+                return new OidcJwtSigningKey($key);
+            } catch (OidcInvalidKeyException $e) {
+                throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
+            }
+        }, $this->keys);
+
+        $parsedKeys = array_filter($parsedKeys);
+
+        $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
+        /** @var OidcJwtSigningKey $parsedKey */
+        foreach ($parsedKeys as $parsedKey) {
+            if ($parsedKey->verify($contentToSign, $this->signature)) {
+                return;
+            }
+        }
+
+        throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
+    }
+
+    /**
+     * Validate the claims of the token.
+     * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
+     *
+     * @throws OidcInvalidTokenException
+     */
+    protected function validateTokenClaims(string $clientId): void
+    {
+        // 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
+        // MUST exactly match the value of the iss (issuer) Claim.
+        if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
+            throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
+        }
+
+        // 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
+        // at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
+        // if the ID Token does not list the Client as a valid audience, or if it contains additional
+        // audiences not trusted by the Client.
+        if (empty($this->payload['aud'])) {
+            throw new OidcInvalidTokenException('Missing token audience value');
+        }
+
+        $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
+        if (count($aud) !== 1) {
+            throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
+        }
+
+        if ($aud[0] !== $clientId) {
+            throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
+        }
+
+        // 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
+        // NOTE: Addressed by enforcing a count of 1 above.
+
+        // 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id
+        // is the Claim Value.
+        if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) {
+            throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id');
+        }
+
+        // 5. The current time MUST be before the time represented by the exp Claim
+        // (possibly allowing for some small leeway to account for clock skew).
+        if (empty($this->payload['exp'])) {
+            throw new OidcInvalidTokenException('Missing token expiration time value');
+        }
+
+        $skewSeconds = 120;
+        $now = time();
+        if ($now >= (intval($this->payload['exp']) + $skewSeconds)) {
+            throw new OidcInvalidTokenException('Token has expired');
+        }
+
+        // 6. The iat Claim can be used to reject tokens that were issued too far away from the current time,
+        // limiting the amount of time that nonces need to be stored to prevent attacks.
+        // The acceptable range is Client specific.
+        if (empty($this->payload['iat'])) {
+            throw new OidcInvalidTokenException('Missing token issued at time value');
+        }
+
+        $dayAgo = time() - 86400;
+        $iat = intval($this->payload['iat']);
+        if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) {
+            throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid');
+        }
+
+        // 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate.
+        // The meaning and processing of acr Claim Values is out of scope for this document.
+        // NOTE: Not used for our case here. acr is not requested.
+
+        // 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request
+        // re-authentication if it determines too much time has elapsed since the last End-User authentication.
+        // NOTE: Not used for our case here. A max_age request is not made.
+
+        // Custom: Ensure the "sub" (Subject) Claim exists and has a value.
+        if (empty($this->payload['sub'])) {
+            throw new OidcInvalidTokenException('Missing token subject value');
+        }
+    }
+}
diff --git a/app/Auth/Access/Oidc/OidcInvalidKeyException.php b/app/Auth/Access/Oidc/OidcInvalidKeyException.php
new file mode 100644 (file)
index 0000000..1b3310e
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+class OidcInvalidKeyException extends \Exception
+{
+}
diff --git a/app/Auth/Access/Oidc/OidcInvalidTokenException.php b/app/Auth/Access/Oidc/OidcInvalidTokenException.php
new file mode 100644 (file)
index 0000000..4f47eb0
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use Exception;
+
+class OidcInvalidTokenException extends Exception
+{
+}
diff --git a/app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php b/app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php
new file mode 100644 (file)
index 0000000..e2f364e
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+class OidcIssuerDiscoveryException extends \Exception
+{
+}
diff --git a/app/Auth/Access/Oidc/OidcJwtSigningKey.php b/app/Auth/Access/Oidc/OidcJwtSigningKey.php
new file mode 100644 (file)
index 0000000..9a5b383
--- /dev/null
@@ -0,0 +1,109 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use phpseclib3\Crypt\Common\PublicKey;
+use phpseclib3\Crypt\PublicKeyLoader;
+use phpseclib3\Crypt\RSA;
+use phpseclib3\Math\BigInteger;
+
+class OidcJwtSigningKey
+{
+    /**
+     * @var PublicKey
+     */
+    protected $key;
+
+    /**
+     * Can be created either from a JWK parameter array or local file path to load a certificate from.
+     * Examples:
+     * 'file:///var/www/cert.pem'
+     * ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'].
+     *
+     * @param array|string $jwkOrKeyPath
+     *
+     * @throws OidcInvalidKeyException
+     */
+    public function __construct($jwkOrKeyPath)
+    {
+        if (is_array($jwkOrKeyPath)) {
+            $this->loadFromJwkArray($jwkOrKeyPath);
+        } elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) {
+            $this->loadFromPath($jwkOrKeyPath);
+        } else {
+            throw new OidcInvalidKeyException('Unexpected type of key value provided');
+        }
+    }
+
+    /**
+     * @throws OidcInvalidKeyException
+     */
+    protected function loadFromPath(string $path)
+    {
+        try {
+            $this->key = PublicKeyLoader::load(
+                file_get_contents($path)
+            )->withPadding(RSA::SIGNATURE_PKCS1);
+        } catch (\Exception $exception) {
+            throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}");
+        }
+
+        if (!($this->key instanceof RSA)) {
+            throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
+        }
+    }
+
+    /**
+     * @throws OidcInvalidKeyException
+     */
+    protected function loadFromJwkArray(array $jwk)
+    {
+        if ($jwk['alg'] !== 'RS256') {
+            throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}");
+        }
+
+        if (empty($jwk['use'])) {
+            throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected');
+        }
+
+        if ($jwk['use'] !== 'sig') {
+            throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
+        }
+
+        if (empty($jwk['e'])) {
+            throw new OidcInvalidKeyException('An "e" parameter on the provided key is expected');
+        }
+
+        if (empty($jwk['n'])) {
+            throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected');
+        }
+
+        $n = strtr($jwk['n'] ?? '', '-_', '+/');
+
+        try {
+            /** @var RSA $key */
+            $this->key = PublicKeyLoader::load([
+                'e' => new BigInteger(base64_decode($jwk['e']), 256),
+                'n' => new BigInteger(base64_decode($n), 256),
+            ])->withPadding(RSA::SIGNATURE_PKCS1);
+        } catch (\Exception $exception) {
+            throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}");
+        }
+    }
+
+    /**
+     * Use this key to sign the given content and return the signature.
+     */
+    public function verify(string $content, string $signature): bool
+    {
+        return $this->key->verify($content, $signature);
+    }
+
+    /**
+     * Convert the key to a PEM encoded key string.
+     */
+    public function toPem(): string
+    {
+        return $this->key->toString('PKCS8');
+    }
+}
diff --git a/app/Auth/Access/Oidc/OidcOAuthProvider.php b/app/Auth/Access/Oidc/OidcOAuthProvider.php
new file mode 100644 (file)
index 0000000..9b9d052
--- /dev/null
@@ -0,0 +1,127 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use League\OAuth2\Client\Grant\AbstractGrant;
+use League\OAuth2\Client\Provider\AbstractProvider;
+use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
+use League\OAuth2\Client\Provider\GenericResourceOwner;
+use League\OAuth2\Client\Provider\ResourceOwnerInterface;
+use League\OAuth2\Client\Token\AccessToken;
+use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Extended OAuth2Provider for using with OIDC.
+ * Credit to the https://github.com/steverhoades/oauth2-openid-connect-client
+ * project for the idea of extending a League\OAuth2 client for this use-case.
+ */
+class OidcOAuthProvider extends AbstractProvider
+{
+    use BearerAuthorizationTrait;
+
+    /**
+     * @var string
+     */
+    protected $authorizationEndpoint;
+
+    /**
+     * @var string
+     */
+    protected $tokenEndpoint;
+
+    /**
+     * Returns the base URL for authorizing a client.
+     */
+    public function getBaseAuthorizationUrl(): string
+    {
+        return $this->authorizationEndpoint;
+    }
+
+    /**
+     * Returns the base URL for requesting an access token.
+     */
+    public function getBaseAccessTokenUrl(array $params): string
+    {
+        return $this->tokenEndpoint;
+    }
+
+    /**
+     * Returns the URL for requesting the resource owner's details.
+     */
+    public function getResourceOwnerDetailsUrl(AccessToken $token): string
+    {
+        return '';
+    }
+
+    /**
+     * Returns the default scopes used by this provider.
+     *
+     * This should only be the scopes that are required to request the details
+     * of the resource owner, rather than all the available scopes.
+     */
+    protected function getDefaultScopes(): array
+    {
+        return ['openid', 'profile', 'email'];
+    }
+
+    /**
+     * Returns the string that should be used to separate scopes when building
+     * the URL for requesting an access token.
+     */
+    protected function getScopeSeparator(): string
+    {
+        return ' ';
+    }
+
+    /**
+     * Checks a provider response for errors.
+     *
+     * @param ResponseInterface $response
+     * @param array|string      $data     Parsed response data
+     *
+     * @throws IdentityProviderException
+     *
+     * @return void
+     */
+    protected function checkResponse(ResponseInterface $response, $data)
+    {
+        if ($response->getStatusCode() >= 400 || isset($data['error'])) {
+            throw new IdentityProviderException(
+                $data['error'] ?? $response->getReasonPhrase(),
+                $response->getStatusCode(),
+                (string) $response->getBody()
+            );
+        }
+    }
+
+    /**
+     * Generates a resource owner object from a successful resource owner
+     * details request.
+     *
+     * @param array       $response
+     * @param AccessToken $token
+     *
+     * @return ResourceOwnerInterface
+     */
+    protected function createResourceOwner(array $response, AccessToken $token)
+    {
+        return new GenericResourceOwner($response, '');
+    }
+
+    /**
+     * Creates an access token from a response.
+     *
+     * The grant that was used to fetch the response can be used to provide
+     * additional context.
+     *
+     * @param array         $response
+     * @param AbstractGrant $grant
+     *
+     * @return OidcAccessToken
+     */
+    protected function createAccessToken(array $response, AbstractGrant $grant)
+    {
+        return new OidcAccessToken($response);
+    }
+}
diff --git a/app/Auth/Access/Oidc/OidcProviderSettings.php b/app/Auth/Access/Oidc/OidcProviderSettings.php
new file mode 100644 (file)
index 0000000..32946d0
--- /dev/null
@@ -0,0 +1,203 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use GuzzleHttp\Psr7\Request;
+use Illuminate\Contracts\Cache\Repository;
+use InvalidArgumentException;
+use Psr\Http\Client\ClientExceptionInterface;
+use Psr\Http\Client\ClientInterface;
+
+/**
+ * OpenIdConnectProviderSettings
+ * Acts as a DTO for settings used within the oidc request and token handling.
+ * Performs auto-discovery upon request.
+ */
+class OidcProviderSettings
+{
+    /**
+     * @var string
+     */
+    public $issuer;
+
+    /**
+     * @var string
+     */
+    public $clientId;
+
+    /**
+     * @var string
+     */
+    public $clientSecret;
+
+    /**
+     * @var string
+     */
+    public $redirectUri;
+
+    /**
+     * @var string
+     */
+    public $authorizationEndpoint;
+
+    /**
+     * @var string
+     */
+    public $tokenEndpoint;
+
+    /**
+     * @var string[]|array[]
+     */
+    public $keys = [];
+
+    public function __construct(array $settings)
+    {
+        $this->applySettingsFromArray($settings);
+        $this->validateInitial();
+    }
+
+    /**
+     * Apply an array of settings to populate setting properties within this class.
+     */
+    protected function applySettingsFromArray(array $settingsArray)
+    {
+        foreach ($settingsArray as $key => $value) {
+            if (property_exists($this, $key)) {
+                $this->$key = $value;
+            }
+        }
+    }
+
+    /**
+     * Validate any core, required properties have been set.
+     *
+     * @throws InvalidArgumentException
+     */
+    protected function validateInitial()
+    {
+        $required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
+        foreach ($required as $prop) {
+            if (empty($this->$prop)) {
+                throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
+            }
+        }
+
+        if (strpos($this->issuer, 'https://') !== 0) {
+            throw new InvalidArgumentException('Issuer value must start with https://');
+        }
+    }
+
+    /**
+     * Perform a full validation on these settings.
+     *
+     * @throws InvalidArgumentException
+     */
+    public function validate(): void
+    {
+        $this->validateInitial();
+        $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
+        foreach ($required as $prop) {
+            if (empty($this->$prop)) {
+                throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
+            }
+        }
+    }
+
+    /**
+     * Discover and autoload settings from the configured issuer.
+     *
+     * @throws OidcIssuerDiscoveryException
+     */
+    public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
+    {
+        try {
+            $cacheKey = 'oidc-discovery::' . $this->issuer;
+            $discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) {
+                return $this->loadSettingsFromIssuerDiscovery($httpClient);
+            });
+            $this->applySettingsFromArray($discoveredSettings);
+        } catch (ClientExceptionInterface $exception) {
+            throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}");
+        }
+    }
+
+    /**
+     * @throws OidcIssuerDiscoveryException
+     * @throws ClientExceptionInterface
+     */
+    protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array
+    {
+        $issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration';
+        $request = new Request('GET', $issuerUrl);
+        $response = $httpClient->sendRequest($request);
+        $result = json_decode($response->getBody()->getContents(), true);
+
+        if (empty($result) || !is_array($result)) {
+            throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}");
+        }
+
+        if ($result['issuer'] !== $this->issuer) {
+            throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response');
+        }
+
+        $discoveredSettings = [];
+
+        if (!empty($result['authorization_endpoint'])) {
+            $discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint'];
+        }
+
+        if (!empty($result['token_endpoint'])) {
+            $discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
+        }
+
+        if (!empty($result['jwks_uri'])) {
+            $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
+            $discoveredSettings['keys'] = $this->filterKeys($keys);
+        }
+
+        return $discoveredSettings;
+    }
+
+    /**
+     * Filter the given JWK keys down to just those we support.
+     */
+    protected function filterKeys(array $keys): array
+    {
+        return array_filter($keys, function (array $key) {
+            return $key['kty'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256';
+        });
+    }
+
+    /**
+     * Return an array of jwks as PHP key=>value arrays.
+     *
+     * @throws ClientExceptionInterface
+     * @throws OidcIssuerDiscoveryException
+     */
+    protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array
+    {
+        $request = new Request('GET', $uri);
+        $response = $httpClient->sendRequest($request);
+        $result = json_decode($response->getBody()->getContents(), true);
+
+        if (empty($result) || !is_array($result) || !isset($result['keys'])) {
+            throw new OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri');
+        }
+
+        return $result['keys'];
+    }
+
+    /**
+     * Get the settings needed by an OAuth provider, as a key=>value array.
+     */
+    public function arrayForProvider(): array
+    {
+        $settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
+        $settings = [];
+        foreach ($settingKeys as $setting) {
+            $settings[$setting] = $this->$setting;
+        }
+
+        return $settings;
+    }
+}
diff --git a/app/Auth/Access/Oidc/OidcService.php b/app/Auth/Access/Oidc/OidcService.php
new file mode 100644 (file)
index 0000000..b8e017b
--- /dev/null
@@ -0,0 +1,221 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use function auth;
+use BookStack\Auth\Access\LoginService;
+use BookStack\Auth\Access\RegistrationService;
+use BookStack\Auth\User;
+use BookStack\Exceptions\JsonDebugException;
+use BookStack\Exceptions\OpenIdConnectException;
+use BookStack\Exceptions\StoppedAuthenticationException;
+use BookStack\Exceptions\UserRegistrationException;
+use function config;
+use Exception;
+use Illuminate\Support\Facades\Cache;
+use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
+use Psr\Http\Client\ClientExceptionInterface;
+use Psr\Http\Client\ClientInterface as HttpClient;
+use function trans;
+use function url;
+
+/**
+ * Class OpenIdConnectService
+ * Handles any app-specific OIDC tasks.
+ */
+class OidcService
+{
+    protected $registrationService;
+    protected $loginService;
+    protected $httpClient;
+
+    /**
+     * OpenIdService constructor.
+     */
+    public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
+    {
+        $this->registrationService = $registrationService;
+        $this->loginService = $loginService;
+        $this->httpClient = $httpClient;
+    }
+
+    /**
+     * Initiate an authorization flow.
+     *
+     * @return array{url: string, state: string}
+     */
+    public function login(): array
+    {
+        $settings = $this->getProviderSettings();
+        $provider = $this->getProvider($settings);
+
+        return [
+            'url'   => $provider->getAuthorizationUrl(),
+            'state' => $provider->getState(),
+        ];
+    }
+
+    /**
+     * Process the Authorization response from the authorization server and
+     * return the matching, or new if registration active, user matched to
+     * the authorization server.
+     * Returns null if not authenticated.
+     *
+     * @throws Exception
+     * @throws ClientExceptionInterface
+     */
+    public function processAuthorizeResponse(?string $authorizationCode): ?User
+    {
+        $settings = $this->getProviderSettings();
+        $provider = $this->getProvider($settings);
+
+        // Try to exchange authorization code for access token
+        $accessToken = $provider->getAccessToken('authorization_code', [
+            'code' => $authorizationCode,
+        ]);
+
+        return $this->processAccessTokenCallback($accessToken, $settings);
+    }
+
+    /**
+     * @throws OidcIssuerDiscoveryException
+     * @throws ClientExceptionInterface
+     */
+    protected function getProviderSettings(): OidcProviderSettings
+    {
+        $config = $this->config();
+        $settings = new OidcProviderSettings([
+            'issuer'                => $config['issuer'],
+            'clientId'              => $config['client_id'],
+            'clientSecret'          => $config['client_secret'],
+            'redirectUri'           => url('/oidc/callback'),
+            'authorizationEndpoint' => $config['authorization_endpoint'],
+            'tokenEndpoint'         => $config['token_endpoint'],
+        ]);
+
+        // Use keys if configured
+        if (!empty($config['jwt_public_key'])) {
+            $settings->keys = [$config['jwt_public_key']];
+        }
+
+        // Run discovery
+        if ($config['discover'] ?? false) {
+            $settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
+        }
+
+        $settings->validate();
+
+        return $settings;
+    }
+
+    /**
+     * Load the underlying OpenID Connect Provider.
+     */
+    protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
+    {
+        return new OidcOAuthProvider($settings->arrayForProvider(), [
+            'httpClient'     => $this->httpClient,
+            'optionProvider' => new HttpBasicAuthOptionProvider(),
+        ]);
+    }
+
+    /**
+     * Calculate the display name.
+     */
+    protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
+    {
+        $displayNameAttr = $this->config()['display_name_claims'];
+
+        $displayName = [];
+        foreach ($displayNameAttr as $dnAttr) {
+            $dnComponent = $token->getClaim($dnAttr) ?? '';
+            if ($dnComponent !== '') {
+                $displayName[] = $dnComponent;
+            }
+        }
+
+        if (count($displayName) == 0) {
+            $displayName[] = $defaultValue;
+        }
+
+        return implode(' ', $displayName);
+    }
+
+    /**
+     * Extract the details of a user from an ID token.
+     *
+     * @return array{name: string, email: string, external_id: string}
+     */
+    protected function getUserDetails(OidcIdToken $token): array
+    {
+        $id = $token->getClaim('sub');
+
+        return [
+            'external_id' => $id,
+            'email'       => $token->getClaim('email'),
+            'name'        => $this->getUserDisplayName($token, $id),
+        ];
+    }
+
+    /**
+     * Processes a received access token for a user. Login the user when
+     * they exist, optionally registering them automatically.
+     *
+     * @throws OpenIdConnectException
+     * @throws JsonDebugException
+     * @throws UserRegistrationException
+     * @throws StoppedAuthenticationException
+     */
+    protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
+    {
+        $idTokenText = $accessToken->getIdToken();
+        $idToken = new OidcIdToken(
+            $idTokenText,
+            $settings->issuer,
+            $settings->keys,
+        );
+
+        if ($this->config()['dump_user_details']) {
+            throw new JsonDebugException($idToken->getAllClaims());
+        }
+
+        try {
+            $idToken->validate($settings->clientId);
+        } catch (OidcInvalidTokenException $exception) {
+            throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}");
+        }
+
+        $userDetails = $this->getUserDetails($idToken);
+        $isLoggedIn = auth()->check();
+
+        if (empty($userDetails['email'])) {
+            throw new OpenIdConnectException(trans('errors.oidc_no_email_address'));
+        }
+
+        if ($isLoggedIn) {
+            throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login');
+        }
+
+        $user = $this->registrationService->findOrRegister(
+            $userDetails['name'],
+            $userDetails['email'],
+            $userDetails['external_id']
+        );
+
+        if ($user === null) {
+            throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
+        }
+
+        $this->loginService->login($user, 'oidc');
+
+        return $user;
+    }
+
+    /**
+     * Get the OIDC config from the application.
+     */
+    protected function config(): array
+    {
+        return config('oidc');
+    }
+}
index 16e3edbb44e8dc799c4bd3939a358d5d3b9b28df..dcdb68bd5cd725530ab31cf69c4a6cc382ea39b6 100644 (file)
@@ -11,6 +11,7 @@ use BookStack\Facades\Activity;
 use BookStack\Facades\Theme;
 use BookStack\Theming\ThemeEvents;
 use Exception;
+use Illuminate\Support\Str;
 
 class RegistrationService
 {
@@ -50,6 +51,32 @@ class RegistrationService
         return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled');
     }
 
+    /**
+     * Attempt to find a user in the system otherwise register them as a new
+     * user. For use with external auth systems since password is auto-generated.
+     *
+     * @throws UserRegistrationException
+     */
+    public function findOrRegister(string $name, string $email, string $externalId): User
+    {
+        $user = User::query()
+            ->where('external_auth_id', '=', $externalId)
+            ->first();
+
+        if (is_null($user)) {
+            $userData = [
+                'name'             => $name,
+                'email'            => $email,
+                'password'         => Str::random(32),
+                'external_auth_id' => $externalId,
+            ];
+
+            $user = $this->registerUser($userData, null, false);
+        }
+
+        return $user;
+    }
+
     /**
      * The registrations flow for all users.
      *
index 339701d27c89fcad4530657916bdd898a2c73210..58f999709e881fab60142017a4d1e9cef56b572c 100644 (file)
@@ -8,7 +8,6 @@ use BookStack\Exceptions\SamlException;
 use BookStack\Exceptions\StoppedAuthenticationException;
 use BookStack\Exceptions\UserRegistrationException;
 use Exception;
-use Illuminate\Support\Str;
 use OneLogin\Saml2\Auth;
 use OneLogin\Saml2\Error;
 use OneLogin\Saml2\IdPMetadataParser;
@@ -18,20 +17,25 @@ use OneLogin\Saml2\ValidationError;
  * Class Saml2Service
  * Handles any app-specific SAML tasks.
  */
-class Saml2Service extends ExternalAuthService
+class Saml2Service
 {
     protected $config;
     protected $registrationService;
     protected $loginService;
+    protected $groupSyncService;
 
     /**
      * Saml2Service constructor.
      */
-    public function __construct(RegistrationService $registrationService, LoginService $loginService)
-    {
+    public function __construct(
+        RegistrationService $registrationService,
+        LoginService $loginService,
+        GroupSyncService $groupSyncService
+    ) {
         $this->config = config('saml2');
         $this->registrationService = $registrationService;
         $this->loginService = $loginService;
+        $this->groupSyncService = $groupSyncService;
     }
 
     /**
@@ -91,8 +95,11 @@ class Saml2Service extends ExternalAuthService
      * @throws JsonDebugException
      * @throws UserRegistrationException
      */
-    public function processAcsResponse(?string $requestId): ?User
+    public function processAcsResponse(string $requestId, string $samlResponse): ?User
     {
+        // The SAML2 toolkit expects the response to be within the $_POST superglobal
+        // so we need to manually put it back there at this point.
+        $_POST['SAMLResponse'] = $samlResponse;
         $toolkit = $this->getToolkit();
         $toolkit->processResponse($requestId);
         $errors = $toolkit->getErrors();
@@ -264,6 +271,8 @@ class Saml2Service extends ExternalAuthService
 
     /**
      * Extract the details of a user from a SAML response.
+     *
+     * @return array{external_id: string, name: string, email: string, saml_id: string}
      */
     protected function getUserDetails(string $samlID, $samlAttributes): array
     {
@@ -328,31 +337,6 @@ class Saml2Service extends ExternalAuthService
         return $defaultValue;
     }
 
-    /**
-     * Get the user from the database for the specified details.
-     *
-     * @throws UserRegistrationException
-     */
-    protected function getOrRegisterUser(array $userDetails): ?User
-    {
-        $user = User::query()
-          ->where('external_auth_id', '=', $userDetails['external_id'])
-          ->first();
-
-        if (is_null($user)) {
-            $userData = [
-                'name'             => $userDetails['name'],
-                'email'            => $userDetails['email'],
-                'password'         => Str::random(32),
-                'external_auth_id' => $userDetails['external_id'],
-            ];
-
-            $user = $this->registrationService->registerUser($userData, null, false);
-        }
-
-        return $user;
-    }
-
     /**
      * Process the SAML response for a user. Login the user when
      * they exist, optionally registering them automatically.
@@ -383,14 +367,19 @@ class Saml2Service extends ExternalAuthService
             throw new SamlException(trans('errors.saml_already_logged_in'), '/login');
         }
 
-        $user = $this->getOrRegisterUser($userDetails);
+        $user = $this->registrationService->findOrRegister(
+            $userDetails['name'],
+            $userDetails['email'],
+            $userDetails['external_id']
+        );
+
         if ($user === null) {
             throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
         }
 
         if ($this->shouldSyncGroups()) {
             $groups = $this->getUserGroups($samlAttributes);
-            $this->syncWithGroups($user, $groups);
+            $this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']);
         }
 
         $this->loginService->login($user, 'saml2');
index 8cf243fe78eafa6aeedd0b2694bdbb42ff7cf690..d165e76b121bbe2b6f5064c1b844906272d04f99 100644 (file)
@@ -141,7 +141,7 @@ class SocialAuthService
         // When a user is not logged in and a matching SocialAccount exists,
         // Simply log the user into the application.
         if (!$isLoggedIn && $socialAccount !== null) {
-            $this->loginService->login($socialAccount->user, $socialAccount);
+            $this->loginService->login($socialAccount->user, $socialDriver);
 
             return redirect()->intended('/');
         }
index 565dcb948b4caab36ee2056784d200241417fa27..ffd828ab5095194b8df7ab638a73980215095c52 100644 (file)
@@ -6,7 +6,7 @@ use BookStack\Auth\User;
 use BookStack\Exceptions\UserTokenExpiredException;
 use BookStack\Exceptions\UserTokenNotFoundException;
 use Carbon\Carbon;
-use Illuminate\Database\Connection as Database;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Str;
 use stdClass;
 
@@ -26,18 +26,6 @@ class UserTokenService
      */
     protected $expiryTime = 24;
 
-    protected $db;
-
-    /**
-     * UserTokenService constructor.
-     *
-     * @param Database $db
-     */
-    public function __construct(Database $db)
-    {
-        $this->db = $db;
-    }
-
     /**
      * Delete all email confirmations that belong to a user.
      *
@@ -47,7 +35,7 @@ class UserTokenService
      */
     public function deleteByUser(User $user)
     {
-        return $this->db->table($this->tokenTable)
+        return DB::table($this->tokenTable)
             ->where('user_id', '=', $user->id)
             ->delete();
     }
@@ -102,7 +90,7 @@ class UserTokenService
     protected function createTokenForUser(User $user): string
     {
         $token = $this->generateToken();
-        $this->db->table($this->tokenTable)->insert([
+        DB::table($this->tokenTable)->insert([
             'user_id'    => $user->id,
             'token'      => $token,
             'created_at' => Carbon::now(),
@@ -121,7 +109,7 @@ class UserTokenService
      */
     protected function tokenExists(string $token): bool
     {
-        return $this->db->table($this->tokenTable)
+        return DB::table($this->tokenTable)
             ->where('token', '=', $token)->exists();
     }
 
@@ -134,7 +122,7 @@ class UserTokenService
      */
     protected function getEntryByToken(string $token)
     {
-        return $this->db->table($this->tokenTable)
+        return DB::table($this->tokenTable)
             ->where('token', '=', $token)
             ->first();
     }
index f84f518944ab81f2d95e8fee776f7e00b8cde8ba..139725339717edb04175d64a8e849b0226afe41d 100644 (file)
@@ -603,7 +603,7 @@ class PermissionService
     /**
      * Filter items that have entities set as a polymorphic relation.
      *
-     * @param Builder|\Illuminate\Database\Query\Builder $query
+     * @param Builder|QueryBuilder $query
      */
     public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
     {
@@ -611,9 +611,10 @@ class PermissionService
 
         $q = $query->where(function ($query) use ($tableDetails, $action) {
             $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
+                /** @var Builder $permissionQuery */
                 $permissionQuery->select(['role_id'])->from('joint_permissions')
-                    ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
-                    ->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
+                    ->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
+                    ->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
                     ->where('action', '=', $action)
                     ->whereIn('role_id', $this->getCurrentUserRoles())
                     ->where(function (QueryBuilder $query) {
@@ -639,8 +640,9 @@ class PermissionService
         $q = $query->where(function ($query) use ($tableDetails, $morphClass) {
             $query->where(function ($query) use (&$tableDetails, $morphClass) {
                 $query->whereExists(function ($permissionQuery) use (&$tableDetails, $morphClass) {
+                    /** @var Builder $permissionQuery */
                     $permissionQuery->select('id')->from('joint_permissions')
-                        ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
+                        ->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
                         ->where('entity_type', '=', $morphClass)
                         ->where('action', '=', 'view')
                         ->whereIn('role_id', $this->getCurrentUserRoles())
index dcd960948039d85279768fe9ee247416e851a6e9..46921caeb1a2adebb9255088c39dd1c13489497b 100644 (file)
@@ -13,12 +13,13 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
 /**
  * Class Role.
  *
- * @property int    $id
- * @property string $display_name
- * @property string $description
- * @property string $external_auth_id
- * @property string $system_name
- * @property bool   $mfa_enforced
+ * @property int        $id
+ * @property string     $display_name
+ * @property string     $description
+ * @property string     $external_auth_id
+ * @property string     $system_name
+ * @property bool       $mfa_enforced
+ * @property Collection $users
  */
 class Role extends Model implements Loggable
 {
index e1a040fc2ceb8850dd52b046556690add64bd606..6d48f12402060edbbe56f5660301dc1183ca5dcc 100644 (file)
@@ -15,7 +15,7 @@ use Exception;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Pagination\LengthAwarePaginator;
-use Log;
+use Illuminate\Support\Facades\Log;
 
 class UserRepo
 {
index a09c329ce738e2d72c0741faed88759a89520ef7..120644aede9b3e048e38120719ff10a4a92549eb 100755 (executable)
@@ -36,6 +36,11 @@ return [
     // Even when overridden the WYSIWYG editor may still escape script content.
     'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
 
+    // Allow server-side fetches to be performed to potentially unknown
+    // and user-provided locations. Primarily used in exports when loading
+    // in externally referenced assets.
+    'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
+
     // Override the default behaviour for allowing crawlers to crawl the instance.
     // May be ignored if view has be overridden or modified.
     // Defaults to null since, if not set, 'app-public' status used instead.
index 404b5352dcc2b45537d4407a634171cee7a6c69e..88c22e70aca0760315963f942422adf6d2f8c511 100644 (file)
@@ -11,7 +11,7 @@
 return [
 
     // Method of authentication to use
-    // Options: standard, ldap, saml2
+    // Options: standard, ldap, saml2, oidc
     'method' => env('AUTH_METHOD', 'standard'),
 
     // Authentication Defaults
@@ -26,7 +26,7 @@ return [
     // All authentication drivers have a user provider. This defines how the
     // users are actually retrieved out of your database or other storage
     // mechanisms used by this application to persist your user's data.
-    // Supported drivers: "session", "api-token", "ldap-session"
+    // Supported drivers: "session", "api-token", "ldap-session", "async-external-session"
     'guards' => [
         'standard' => [
             'driver'   => 'session',
@@ -37,7 +37,11 @@ return [
             'provider' => 'external',
         ],
         'saml2' => [
-            'driver'   => 'saml2-session',
+            'driver'   => 'async-external-session',
+            'provider' => 'external',
+        ],
+        'oidc' => [
+            'driver'   => 'async-external-session',
             'provider' => 'external',
         ],
         'api' => [
@@ -70,6 +74,7 @@ return [
             'email'    => 'emails.password',
             'table'    => 'password_resets',
             'expire'   => 60,
+            'throttle' => 60,
         ],
     ],
 
index 7fb51a13bf0664c3994447d2df4cf20591dccdc8..0c696609526fa5fc02fca74e975f076f87ee6884 100644 (file)
@@ -69,7 +69,10 @@ return [
             'port'           => $mysql_port,
             'charset'        => 'utf8mb4',
             'collation'      => 'utf8mb4_unicode_ci',
-            'prefix'         => '',
+            // Prefixes are only semi-supported and may be unstable
+            // since they are not tested as part of our automated test suite.
+            // If used, the prefix should not be changed otherwise you will likely receive errors.
+            'prefix'         => env('DB_TABLE_PREFIX', ''),
             'prefix_indexes' => true,
             'strict'         => false,
             'engine'         => null,
index 71ea716f38729b6073ae96c48eb07c1bafd2a755..a5490294c0eb870dc44510d604eaf67e8d2b746a 100644 (file)
@@ -37,7 +37,7 @@ return [
          * Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
          * Symbol, ZapfDingbats.
          */
-        'DOMPDF_FONT_DIR' => storage_path('fonts/'),  // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
+        'font_dir' => storage_path('fonts/'),  // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
 
         /**
          * The location of the DOMPDF font cache directory.
@@ -47,7 +47,7 @@ return [
          *
          * Note: This directory must exist and be writable by the webserver process.
          */
-        'DOMPDF_FONT_CACHE' => storage_path('fonts/'),
+        'font_cache' => storage_path('fonts/'),
 
         /**
          * The location of a temporary directory.
@@ -56,7 +56,7 @@ return [
          * The temporary directory is required to download remote images and when
          * using the PFDLib back end.
          */
-        'DOMPDF_TEMP_DIR' => sys_get_temp_dir(),
+        'temp_dir' => sys_get_temp_dir(),
 
         /**
          * ==== IMPORTANT ====.
@@ -70,7 +70,7 @@ return [
          * direct class use like:
          * $dompdf = new DOMPDF();  $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
          */
-        'DOMPDF_CHROOT' => realpath(base_path()),
+        'chroot' => realpath(public_path()),
 
         /**
          * Whether to use Unicode fonts or not.
@@ -81,12 +81,12 @@ return [
          * When enabled, dompdf can support all Unicode glyphs. Any glyphs used in a
          * document must be present in your fonts, however.
          */
-        'DOMPDF_UNICODE_ENABLED' => true,
+        'unicode_enabled' => true,
 
         /**
          * Whether to enable font subsetting or not.
          */
-        'DOMPDF_ENABLE_FONTSUBSETTING' => false,
+        'enable_fontsubsetting' => false,
 
         /**
          * The PDF rendering backend to use.
@@ -115,7 +115,7 @@ return [
          * @link http://www.ros.co.nz/pdf
          * @link http://www.php.net/image
          */
-        'DOMPDF_PDF_BACKEND' => 'CPDF',
+        'pdf_backend' => 'CPDF',
 
         /**
          * PDFlib license key.
@@ -141,7 +141,7 @@ return [
          * the desired content might be different (e.g. screen or projection view of html file).
          * Therefore allow specification of content here.
          */
-        'DOMPDF_DEFAULT_MEDIA_TYPE' => 'print',
+        'default_media_type' => 'print',
 
         /**
          * The default paper size.
@@ -150,7 +150,7 @@ return [
          *
          * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
          */
-        'DOMPDF_DEFAULT_PAPER_SIZE' => 'a4',
+        'default_paper_size' => 'a4',
 
         /**
          * The default font family.
@@ -159,7 +159,7 @@ return [
          *
          * @var string
          */
-        'DOMPDF_DEFAULT_FONT' => 'dejavu sans',
+        'default_font' => 'dejavu sans',
 
         /**
          * Image DPI setting.
@@ -194,7 +194,7 @@ return [
          *
          * @var int
          */
-        'DOMPDF_DPI' => 96,
+        'dpi' => 96,
 
         /**
          * Enable inline PHP.
@@ -208,7 +208,7 @@ return [
          *
          * @var bool
          */
-        'DOMPDF_ENABLE_PHP' => false,
+        'enable_php' => false,
 
         /**
          * Enable inline Javascript.
@@ -218,7 +218,7 @@ return [
          *
          * @var bool
          */
-        'DOMPDF_ENABLE_JAVASCRIPT' => false,
+        'enable_javascript' => false,
 
         /**
          * Enable remote file access.
@@ -237,12 +237,12 @@ return [
          *
          * @var bool
          */
-        'DOMPDF_ENABLE_REMOTE' => true,
+        'enable_remote' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
 
         /**
          * A ratio applied to the fonts height to be more like browsers' line height.
          */
-        'DOMPDF_FONT_HEIGHT_RATIO' => 1.1,
+        'font_height_ratio' => 1.1,
 
         /**
          * Enable CSS float.
@@ -251,12 +251,12 @@ return [
          *
          * @var bool
          */
-        'DOMPDF_ENABLE_CSS_FLOAT' => true,
+        'enable_css_float' => true,
 
         /**
          * Use the more-than-experimental HTML5 Lib parser.
          */
-        'DOMPDF_ENABLE_HTML5PARSER' => true,
+        'enable_html5parser' => true,
 
     ],
 
index 95fc35c2a8c0b702ee2cc8743a39425f9416b369..a6b05c8c4aa3644f14030afd781ef6f7e56866d9 100644 (file)
@@ -37,9 +37,14 @@ return [
             'root'   => public_path(),
         ],
 
-        'local_secure' => [
+        'local_secure_attachments' => [
             'driver' => 'local',
-            'root'   => storage_path(),
+            'root'   => storage_path('uploads/files/'),
+        ],
+
+        'local_secure_images' => [
+            'driver' => 'local',
+            'root'   => storage_path('uploads/images/'),
         ],
 
         's3' => [
diff --git a/app/Config/oidc.php b/app/Config/oidc.php
new file mode 100644 (file)
index 0000000..842ac8a
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+
+return [
+
+    // Display name, shown to users, for OpenId option
+    'name' => env('OIDC_NAME', 'SSO'),
+
+    // Dump user details after a login request for debugging purposes
+    'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
+
+    // Attribute, within a OpenId token, to find the user's display name
+    'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
+
+    // OAuth2/OpenId client id, as configured in your Authorization server.
+    'client_id' => env('OIDC_CLIENT_ID', null),
+
+    // OAuth2/OpenId client secret, as configured in your Authorization server.
+    'client_secret' => env('OIDC_CLIENT_SECRET', null),
+
+    // The issuer of the identity token (id_token) this will be compared with
+    // what is returned in the token.
+    'issuer' => env('OIDC_ISSUER', null),
+
+    // Auto-discover the relevant endpoints and keys from the issuer.
+    // Fetched details are cached for 15 minutes.
+    'discover' => env('OIDC_ISSUER_DISCOVER', false),
+
+    // Public key that's used to verify the JWT token with.
+    // Can be the key value itself or a local 'file://public.key' reference.
+    'jwt_public_key' => env('OIDC_PUBLIC_KEY', null),
+
+    // OAuth2 endpoints.
+    'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
+    'token_endpoint'         => env('OIDC_TOKEN_ENDPOINT', null),
+];
index 3dc3ec0af0e98b33bd3f1d741540dd13e1d319c5..50e81a2b8e2578edeb8b2d2208f158def6a0256f 100644 (file)
@@ -3,8 +3,8 @@
 namespace BookStack\Console\Commands;
 
 use BookStack\Entities\Tools\SearchIndex;
-use DB;
 use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
 
 class RegenerateSearch extends Command
 {
index df30c1c714abb4fff2ddd9d92e2b3c4fa6119623..1e4591bd75d3f385e5a4cffe217b5f8c7f643f2c 100644 (file)
@@ -12,9 +12,12 @@ use Illuminate\Support\Collection;
 /**
  * Class Book.
  *
- * @property string     $description
- * @property int        $image_id
- * @property Image|null $cover
+ * @property string                                   $description
+ * @property int                                      $image_id
+ * @property Image|null                               $cover
+ * @property \Illuminate\Database\Eloquent\Collection $chapters
+ * @property \Illuminate\Database\Eloquent\Collection $pages
+ * @property \Illuminate\Database\Eloquent\Collection $directPages
  */
 class Book extends Entity implements HasCoverImage
 {
index aeee50d0f72e881564f9d236324508e01d48eb7c..601e9630db07eec6fef204c7a6b9fac7f52c7e48 100644 (file)
@@ -25,10 +25,10 @@ use Permissions;
  */
 class Page extends BookChild
 {
-    public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at'];
-    public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at'];
+    public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at', 'priority'];
+    public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at', 'priority'];
 
-    protected $fillable = ['name', 'priority', 'markdown'];
+    protected $fillable = ['name', 'priority'];
 
     public $textField = 'text';
 
index c1a74f66b2a92d5664a48ebb0649a1365539e17b..b994e7a04ceec606e56757362e87a74372311dad 100644 (file)
@@ -5,6 +5,7 @@ namespace BookStack\Entities\Models;
 use BookStack\Auth\User;
 use BookStack\Model;
 use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
  * Class PageRevision.
@@ -14,11 +15,13 @@ use Carbon\Carbon;
  * @property string $book_slug
  * @property int    $created_by
  * @property Carbon $created_at
+ * @property Carbon $updated_at
  * @property string $type
  * @property string $summary
  * @property string $markdown
  * @property string $html
  * @property int    $revision_number
+ * @property Page   $page
  */
 class PageRevision extends Model
 {
@@ -26,20 +29,16 @@ class PageRevision extends Model
 
     /**
      * Get the user that created the page revision.
-     *
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
      */
-    public function createdBy()
+    public function createdBy(): BelongsTo
     {
         return $this->belongsTo(User::class, 'created_by');
     }
 
     /**
      * Get the page this revision originates from.
-     *
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
      */
-    public function page()
+    public function page(): BelongsTo
     {
         return $this->belongsTo(Page::class);
     }
index c299f9c71937cd279182249dbfa9d1aba3a79839..05d0ff13466ad81c9de1da4ee60b2373429e6dac 100644 (file)
@@ -140,7 +140,7 @@ class ExportFormatter
     protected function htmlToPdf(string $html): string
     {
         $containedHtml = $this->containHtml($html);
-        $useWKHTML = config('snappy.pdf.binary') !== false;
+        $useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
         if ($useWKHTML) {
             $pdf = SnappyPDF::loadHTML($containedHtml);
             $pdf->setOption('print-media-type', true);
index b4cc1b81c8c1e01396d515951403253b6f5b4976..9f4ac2893f7fe0857acbf3ea476035f39b887cec 100644 (file)
@@ -37,7 +37,7 @@ class PageContent
      */
     public function setNewHTML(string $html)
     {
-        $html = $this->extractBase64Images($this->page, $html);
+        $html = $this->extractBase64ImagesFromHtml($html);
         $this->page->html = $this->formatHtml($html);
         $this->page->text = $this->toPlainText();
         $this->page->markdown = '';
@@ -48,6 +48,7 @@ class PageContent
      */
     public function setNewMarkdown(string $markdown)
     {
+        $markdown = $this->extractBase64ImagesFromMarkdown($markdown);
         $this->page->markdown = $markdown;
         $html = $this->markdownToHtml($markdown);
         $this->page->html = $this->formatHtml($html);
@@ -74,7 +75,7 @@ class PageContent
     /**
      * Convert all base64 image data to saved images.
      */
-    public function extractBase64Images(Page $page, string $htmlText): string
+    protected function extractBase64ImagesFromHtml(string $htmlText): string
     {
         if (empty($htmlText) || strpos($htmlText, 'data:image') === false) {
             return $htmlText;
@@ -86,7 +87,6 @@ class PageContent
         $childNodes = $body->childNodes;
         $xPath = new DOMXPath($doc);
         $imageRepo = app()->make(ImageRepo::class);
-        $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
 
         // Get all img elements with image data blobs
         $imageNodes = $xPath->query('//img[contains(@src, \'data:image\')]');
@@ -96,7 +96,7 @@ class PageContent
             $extension = strtolower(preg_split('/[\/;]/', $dataDefinition)[1] ?? 'png');
 
             // Validate extension
-            if (!in_array($extension, $allowedExtensions)) {
+            if (!$imageRepo->imageExtensionSupported($extension)) {
                 $imageNode->setAttribute('src', '');
                 continue;
             }
@@ -105,7 +105,7 @@ class PageContent
             $imageName = 'embedded-image-' . Str::random(8) . '.' . $extension;
 
             try {
-                $image = $imageRepo->saveNewFromData($imageName, base64_decode($base64ImageData), 'gallery', $page->id);
+                $image = $imageRepo->saveNewFromData($imageName, base64_decode($base64ImageData), 'gallery', $this->page->id);
                 $imageNode->setAttribute('src', $image->url);
             } catch (ImageUploadException $exception) {
                 $imageNode->setAttribute('src', '');
@@ -121,6 +121,39 @@ class PageContent
         return $html;
     }
 
+    /**
+     * Convert all inline base64 content to uploaded image files.
+     */
+    protected function extractBase64ImagesFromMarkdown(string $markdown)
+    {
+        $imageRepo = app()->make(ImageRepo::class);
+        $matches = [];
+        preg_match_all('/!\[.*?]\(.*?(data:image\/.*?)[)"\s]/', $markdown, $matches);
+
+        foreach ($matches[1] as $base64Match) {
+            [$dataDefinition, $base64ImageData] = explode(',', $base64Match, 2);
+            $extension = strtolower(preg_split('/[\/;]/', $dataDefinition)[1] ?? 'png');
+
+            // Validate extension
+            if (!$imageRepo->imageExtensionSupported($extension)) {
+                $markdown = str_replace($base64Match, '', $markdown);
+                continue;
+            }
+
+            // Save image from data with a random name
+            $imageName = 'embedded-image-' . Str::random(8) . '.' . $extension;
+
+            try {
+                $image = $imageRepo->saveNewFromData($imageName, base64_decode($base64ImageData), 'gallery', $this->page->id);
+                $markdown = str_replace($base64Match, $image->url, $markdown);
+            } catch (ImageUploadException $exception) {
+                $markdown = str_replace($base64Match, '', $markdown);
+            }
+        }
+
+        return $markdown;
+    }
+
     /**
      * Formats a page's html to be tagged correctly within the system.
      */
@@ -316,6 +349,7 @@ class PageContent
             }
 
             // Find page and skip this if page not found
+            /** @var ?Page $matchedPage */
             $matchedPage = Page::visible()->find($pageId);
             if ($matchedPage === null) {
                 $html = str_replace($fullMatch, '', $html);
index a88dea8307767b2afe5a770f30f60296271634e0..ef6c085ac72c54e086442f80f7be532ba31fe442 100644 (file)
@@ -21,8 +21,6 @@ class PageEditActivity
 
     /**
      * Check if there's active editing being performed on this page.
-     *
-     * @return bool
      */
     public function hasActiveEditing(): bool
     {
@@ -44,11 +42,37 @@ class PageEditActivity
     }
 
     /**
-     * Get the message to show when the user will be editing one of their drafts.
+     * Get any editor clash warning messages to show for the given draft revision.
      *
-     * @param PageRevision $draft
+     * @param PageRevision|Page $draft
      *
-     * @return string
+     * @return string[]
+     */
+    public function getWarningMessagesForDraft($draft): array
+    {
+        $warnings = [];
+
+        if ($this->hasActiveEditing()) {
+            $warnings[] = $this->activeEditingMessage();
+        }
+
+        if ($draft instanceof PageRevision && $this->hasPageBeenUpdatedSinceDraftCreated($draft)) {
+            $warnings[] = trans('entities.pages_draft_page_changed_since_creation');
+        }
+
+        return $warnings;
+    }
+
+    /**
+     * Check if the page has been updated since the draft has been saved.
+     */
+    protected function hasPageBeenUpdatedSinceDraftCreated(PageRevision $draft): bool
+    {
+        return $draft->page->updated_at->timestamp > $draft->created_at->timestamp;
+    }
+
+    /**
+     * Get the message to show when the user will be editing one of their drafts.
      */
     public function getEditingActiveDraftMessage(PageRevision $draft): string
     {
index 8e18408bd70b10864dbaea09fac3f8c902cd638c..ca592755b7e6ec716971b46f2fb9ab05c5c028ec 100644 (file)
@@ -156,7 +156,9 @@ class SearchRunner
             })->groupBy('entity_type', 'entity_id');
             $entitySelect->join($this->db->raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
                 $join->on('id', '=', 'entity_id');
-            })->selectRaw($entity->getTable() . '.*, s.score')->orderBy('score', 'desc');
+            })->addSelect($entity->getTable() . '.*')
+                ->selectRaw('s.score')
+                ->orderBy('score', 'desc');
             $entitySelect->mergeBindings($subQuery);
         }
 
diff --git a/app/Exceptions/OpenIdConnectException.php b/app/Exceptions/OpenIdConnectException.php
new file mode 100644 (file)
index 0000000..7bbc4bd
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+class OpenIdConnectException extends NotifyException
+{
+}
index ef7f24017904f1f85f39f5de26fa528ef479184e..d10a6da5e15902e28fdcfe36f1e3e72ae687e76a 100644 (file)
@@ -55,7 +55,7 @@ class StoppedAuthenticationException extends \Exception implements Responsable
             ], 401);
         }
 
-        if (session()->get('sent-email-confirmation') === true) {
+        if (session()->pull('sent-email-confirmation') === true) {
             return redirect('/register/confirm');
         }
 
diff --git a/app/Exceptions/WhoopsBookStackPrettyHandler.php b/app/Exceptions/WhoopsBookStackPrettyHandler.php
new file mode 100644 (file)
index 0000000..dcf50fa
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+use Whoops\Handler\Handler;
+
+class WhoopsBookStackPrettyHandler extends Handler
+{
+    /**
+     * @return int|null A handler may return nothing, or a Handler::HANDLE_* constant
+     */
+    public function handle()
+    {
+        $exception = $this->getException();
+
+        echo view('errors.debug', [
+            'error'       => $exception->getMessage(),
+            'errorClass'  => get_class($exception),
+            'trace'       => $exception->getTraceAsString(),
+            'environment' => $this->getEnvironment(),
+        ])->render();
+
+        return Handler::QUIT;
+    }
+
+    protected function safeReturn(callable $callback, $default = null)
+    {
+        try {
+            return $callback();
+        } catch (\Exception $e) {
+            return $default;
+        }
+    }
+
+    protected function getEnvironment(): array
+    {
+        return [
+            'PHP Version'       => phpversion(),
+            'BookStack Version' => $this->safeReturn(function () {
+                $versionFile = base_path('version');
+
+                return trim(file_get_contents($versionFile));
+            }, 'unknown'),
+            'Theme Configured' => $this->safeReturn(function () {
+                return config('view.theme');
+            }) ?? 'None',
+        ];
+    }
+}
diff --git a/app/Http/Controllers/Api/AttachmentApiController.php b/app/Http/Controllers/Api/AttachmentApiController.php
new file mode 100644 (file)
index 0000000..06d9f69
--- /dev/null
@@ -0,0 +1,165 @@
+<?php
+
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Exceptions\FileUploadException;
+use BookStack\Uploads\Attachment;
+use BookStack\Uploads\AttachmentService;
+use Exception;
+use Illuminate\Contracts\Filesystem\FileNotFoundException;
+use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
+
+class AttachmentApiController extends ApiController
+{
+    protected $attachmentService;
+
+    protected $rules = [
+        'create' => [
+            'name'        => 'required|min:1|max:255|string',
+            'uploaded_to' => 'required|integer|exists:pages,id',
+            'file'        => 'required_without:link|file',
+            'link'        => 'required_without:file|min:1|max:255|safe_url',
+        ],
+        'update' => [
+            'name'        => 'min:1|max:255|string',
+            'uploaded_to' => 'integer|exists:pages,id',
+            'file'        => 'file',
+            'link'        => 'min:1|max:255|safe_url',
+        ],
+    ];
+
+    public function __construct(AttachmentService $attachmentService)
+    {
+        $this->attachmentService = $attachmentService;
+    }
+
+    /**
+     * Get a listing of attachments visible to the user.
+     * The external property indicates whether the attachment is simple a link.
+     * A false value for the external property would indicate a file upload.
+     */
+    public function list()
+    {
+        return $this->apiListingResponse(Attachment::visible(), [
+            'id', 'name', 'extension', 'uploaded_to', 'external', 'order', 'created_at', 'updated_at', 'created_by', 'updated_by',
+        ]);
+    }
+
+    /**
+     * Create a new attachment in the system.
+     * An uploaded_to value must be provided containing an ID of the page
+     * that this upload will be related to.
+     *
+     * If you're uploading a file the POST data should be provided via
+     * a multipart/form-data type request instead of JSON.
+     *
+     * @throws ValidationException
+     * @throws FileUploadException
+     */
+    public function create(Request $request)
+    {
+        $this->checkPermission('attachment-create-all');
+        $requestData = $this->validate($request, $this->rules['create']);
+
+        $pageId = $request->get('uploaded_to');
+        $page = Page::visible()->findOrFail($pageId);
+        $this->checkOwnablePermission('page-update', $page);
+
+        if ($request->hasFile('file')) {
+            $uploadedFile = $request->file('file');
+            $attachment = $this->attachmentService->saveNewUpload($uploadedFile, $page->id);
+        } else {
+            $attachment = $this->attachmentService->saveNewFromLink(
+                $requestData['name'],
+                $requestData['link'],
+                $page->id
+            );
+        }
+
+        $this->attachmentService->updateFile($attachment, $requestData);
+
+        return response()->json($attachment);
+    }
+
+    /**
+     * Get the details & content of a single attachment of the given ID.
+     * The attachment link or file content is provided via a 'content' property.
+     * For files the content will be base64 encoded.
+     *
+     * @throws FileNotFoundException
+     */
+    public function read(string $id)
+    {
+        /** @var Attachment $attachment */
+        $attachment = Attachment::visible()
+            ->with(['createdBy', 'updatedBy'])
+            ->findOrFail($id);
+
+        $attachment->setAttribute('links', [
+            'html'     => $attachment->htmlLink(),
+            'markdown' => $attachment->markdownLink(),
+        ]);
+
+        if (!$attachment->external) {
+            $attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
+            $attachment->setAttribute('content', base64_encode($attachmentContents));
+        } else {
+            $attachment->setAttribute('content', $attachment->path);
+        }
+
+        return response()->json($attachment);
+    }
+
+    /**
+     * Update the details of a single attachment.
+     * As per the create endpoint, if a file is being provided as the attachment content
+     * the request should be formatted as a multipart/form-data request instead of JSON.
+     *
+     * @throws ValidationException
+     * @throws FileUploadException
+     */
+    public function update(Request $request, string $id)
+    {
+        $requestData = $this->validate($request, $this->rules['update']);
+        /** @var Attachment $attachment */
+        $attachment = Attachment::visible()->findOrFail($id);
+
+        $page = $attachment->page;
+        if ($requestData['uploaded_to'] ?? false) {
+            $pageId = $request->get('uploaded_to');
+            $page = Page::visible()->findOrFail($pageId);
+            $attachment->uploaded_to = $requestData['uploaded_to'];
+        }
+
+        $this->checkOwnablePermission('page-view', $page);
+        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission('attachment-update', $attachment);
+
+        if ($request->hasFile('file')) {
+            $uploadedFile = $request->file('file');
+            $attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $attachment);
+        }
+
+        $this->attachmentService->updateFile($attachment, $requestData);
+
+        return response()->json($attachment);
+    }
+
+    /**
+     * Delete an attachment of the given ID.
+     *
+     * @throws Exception
+     */
+    public function delete(string $id)
+    {
+        /** @var Attachment $attachment */
+        $attachment = Attachment::visible()->findOrFail($id);
+        $this->checkOwnablePermission('attachment-delete', $attachment);
+
+        $this->attachmentService->deleteFile($attachment);
+
+        return response('', 204);
+    }
+}
index 046b8c19dc83478c59c92b587f97e71ffc51d484..56503a694fb06247f17a1f55ef3b57e9ee42ca7d 100644 (file)
@@ -121,9 +121,9 @@ class AttachmentController extends Controller
             ]), 422);
         }
 
-        $this->checkOwnablePermission('view', $attachment->page);
+        $this->checkOwnablePermission('page-view', $attachment->page);
         $this->checkOwnablePermission('page-update', $attachment->page);
-        $this->checkOwnablePermission('attachment-create', $attachment);
+        $this->checkOwnablePermission('attachment-update', $attachment);
 
         $attachment = $this->attachmentService->updateFile($attachment, [
             'name' => $request->get('attachment_edit_name'),
index 3df0608f87ffad7f09754ce2f24e6d82694b716b..8eaee08a2b49985dbcfc299681f7be0577d48b7a 100644 (file)
@@ -56,7 +56,7 @@ class ForgotPasswordController extends Controller
             $this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
         }
 
-        if ($response === Password::RESET_LINK_SENT || $response === Password::INVALID_USER) {
+        if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) {
             $message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
             $this->showSuccessNotification($message);
 
index 01cc77d8455ed5759e264b10d71d745f834a60f0..d12d7c9bc48279f398fd450681baac2d794ebc73 100644 (file)
@@ -43,7 +43,8 @@ class LoginController extends Controller
     public function __construct(SocialAuthService $socialAuthService, LoginService $loginService)
     {
         $this->middleware('guest', ['only' => ['getLogin', 'login']]);
-        $this->middleware('guard:standard,ldap', ['only' => ['login', 'logout']]);
+        $this->middleware('guard:standard,ldap', ['only' => ['login']]);
+        $this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]);
 
         $this->socialAuthService = $socialAuthService;
         $this->loginService = $loginService;
@@ -81,13 +82,7 @@ class LoginController extends Controller
         }
 
         // Store the previous location for redirect after login
-        $previous = url()->previous('');
-        if ($previous && $previous !== url('/login') && setting('app-public')) {
-            $isPreviousFromInstance = (strpos($previous, url('/')) === 0);
-            if ($isPreviousFromInstance) {
-                redirect()->setIntendedUrl($previous);
-            }
-        }
+        $this->updateIntendedFromPrevious();
 
         return view('auth.login', [
             'socialDrivers' => $socialDrivers,
@@ -228,4 +223,32 @@ class LoginController extends Controller
             $this->username() => [trans('auth.failed')],
         ])->redirectTo('/login');
     }
+
+    /**
+     * Update the intended URL location from their previous URL.
+     * Ignores if not from the current app instance or if from certain
+     * login or authentication routes.
+     */
+    protected function updateIntendedFromPrevious(): void
+    {
+        // Store the previous location for redirect after login
+        $previous = url()->previous('');
+        $isPreviousFromInstance = (strpos($previous, url('/')) === 0);
+        if (!$previous || !setting('app-public') || !$isPreviousFromInstance) {
+            return;
+        }
+
+        $ignorePrefixList = [
+            '/login',
+            '/mfa',
+        ];
+
+        foreach ($ignorePrefixList as $ignorePrefix) {
+            if (strpos($previous, url($ignorePrefix)) === 0) {
+                return;
+            }
+        }
+
+        redirect()->setIntendedUrl($previous);
+    }
 }
index 5a932d6e9ae80f00a0096a6d306d8633874688b5..5644f02688e8618a99e0599bb60eb3cb1b68f82e 100644 (file)
@@ -31,12 +31,12 @@ class MfaTotpController extends Controller
             session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
         }
 
-        $qrCodeUrl = $totp->generateUrl($totpSecret);
+        $qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
         $svg = $totp->generateQrCodeSvg($qrCodeUrl);
 
         return view('mfa.totp-generate', [
-            'secret' => $totpSecret,
-            'svg'    => $svg,
+            'url' => $qrCodeUrl,
+            'svg' => $svg,
         ]);
     }
 
diff --git a/app/Http/Controllers/Auth/OidcController.php b/app/Http/Controllers/Auth/OidcController.php
new file mode 100644 (file)
index 0000000..ff93dd8
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+
+namespace BookStack\Http\Controllers\Auth;
+
+use BookStack\Auth\Access\Oidc\OidcService;
+use BookStack\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+
+class OidcController extends Controller
+{
+    protected $oidcService;
+
+    /**
+     * OpenIdController constructor.
+     */
+    public function __construct(OidcService $oidcService)
+    {
+        $this->oidcService = $oidcService;
+        $this->middleware('guard:oidc');
+    }
+
+    /**
+     * Start the authorization login flow via OIDC.
+     */
+    public function login()
+    {
+        $loginDetails = $this->oidcService->login();
+        session()->flash('oidc_state', $loginDetails['state']);
+
+        return redirect($loginDetails['url']);
+    }
+
+    /**
+     * Authorization flow redirect callback.
+     * Processes authorization response from the OIDC Authorization Server.
+     */
+    public function callback(Request $request)
+    {
+        $storedState = session()->pull('oidc_state');
+        $responseState = $request->query('state');
+
+        if ($storedState !== $responseState) {
+            $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
+
+            return redirect('/login');
+        }
+
+        $this->oidcService->processAuthorizeResponse($request->query('code'));
+
+        return redirect()->intended();
+    }
+}
index bd1ffeac2e6661f3c391613c30d8315d7e58d0d7..209827d6db800d7a75a7a568ec9a1af8468f4355 100644 (file)
@@ -12,7 +12,7 @@ use BookStack\Http\Controllers\Controller;
 use Illuminate\Foundation\Auth\RegistersUsers;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Hash;
-use Validator;
+use Illuminate\Support\Facades\Validator;
 
 class RegisterController extends Controller
 {
index 14eb65b717de6076ecbd76f1d352b877146c95f7..871abf59f9114f222c73301423d75c279d815009 100644 (file)
@@ -4,6 +4,9 @@ namespace BookStack\Http\Controllers\Auth;
 
 use BookStack\Auth\Access\Saml2Service;
 use BookStack\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Str;
 
 class Saml2Controller extends Controller
 {
@@ -68,15 +71,59 @@ class Saml2Controller extends Controller
     }
 
     /**
-     * Assertion Consumer Service.
-     * Processes the SAML response from the IDP.
+     * Assertion Consumer Service start URL. Takes the SAMLResponse from the IDP.
+     * Due to being an external POST request, we likely won't have context of the
+     * current user session due to lax cookies. To work around this we store the
+     * SAMLResponse data and redirect to the processAcs endpoint for the actual
+     * processing of the request with proper context of the user session.
      */
-    public function acs()
+    public function startAcs(Request $request)
     {
-        $requestId = session()->pull('saml2_request_id', null);
+        // Note: This is a bit of a hack to prevent a session being stored
+        // on the response of this request. Within Laravel7+ this could instead
+        // be done via removing the StartSession middleware from the route.
+        config()->set('session.driver', 'array');
 
-        $user = $this->samlService->processAcsResponse($requestId);
-        if ($user === null) {
+        $samlResponse = $request->get('SAMLResponse', null);
+
+        if (empty($samlResponse)) {
+            $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
+
+            return redirect('/login');
+        }
+
+        $acsId = Str::random(16);
+        $cacheKey = 'saml2_acs:' . $acsId;
+        cache()->set($cacheKey, encrypt($samlResponse), 10);
+
+        return redirect()->guest('/saml2/acs?id=' . $acsId);
+    }
+
+    /**
+     * Assertion Consumer Service process endpoint.
+     * Processes the SAML response from the IDP with context of the current session.
+     * Takes the SAML request from the cache, added by the startAcs method above.
+     */
+    public function processAcs(Request $request)
+    {
+        $acsId = $request->get('id', null);
+        $cacheKey = 'saml2_acs:' . $acsId;
+        $samlResponse = null;
+
+        try {
+            $samlResponse = decrypt(cache()->pull($cacheKey));
+        } catch (\Exception $exception) {
+        }
+        $requestId = session()->pull('saml2_request_id', 'unset');
+
+        if (empty($acsId) || empty($samlResponse)) {
+            $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
+
+            return redirect('/login');
+        }
+
+        $user = $this->samlService->processAcsResponse($requestId, $samlResponse);
+        if (is_null($user)) {
             $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
 
             return redirect('/login');
index 6706de575f454ace49e87ebe4fbac6459fedd54c..5451c0abfe8289730e26649eec36c713aa3c5d36 100644 (file)
@@ -96,9 +96,10 @@ class HomeController extends Controller
         if ($homepageOption === 'page') {
             $homepageSetting = setting('app-homepage', '0:');
             $id = intval(explode(':', $homepageSetting)[0]);
+            /** @var Page $customHomepage */
             $customHomepage = Page::query()->where('draft', '=', false)->findOrFail($id);
             $pageContent = new PageContent($customHomepage);
-            $customHomepage->html = $pageContent->render(true);
+            $customHomepage->html = $pageContent->render(false);
 
             return view('home.specific-page', array_merge($commonData, ['customHomepage' => $customHomepage]));
         }
index 853ac28fc4c770501f056d4972acfc2c4399f4aa..a94a6180304d9abe8f909c7a4ea020cddbb5d205 100644 (file)
@@ -259,13 +259,13 @@ class PageController extends Controller
         }
 
         $draft = $this->pageRepo->updatePageDraft($page, $request->only(['name', 'html', 'markdown']));
-
-        $updateTime = $draft->updated_at->timestamp;
+        $warnings = (new PageEditActivity($page))->getWarningMessagesForDraft($draft);
 
         return response()->json([
             'status'    => 'success',
             'message'   => trans('entities.pages_edit_draft_save_at'),
-            'timestamp' => $updateTime,
+            'warning'   => implode("\n", $warnings),
+            'timestamp' => $draft->updated_at->timestamp,
         ]);
     }
 
index a0da220ee55f9735884bc5befe09677718732bf7..2ee303f3fee46b2ea6be11810612e11593fe0698 100644 (file)
@@ -84,7 +84,7 @@ class UserController extends Controller
         if ($authMethod === 'standard' && !$sendInvite) {
             $validationRules['password'] = 'required|min:6';
             $validationRules['password-confirm'] = 'required|same:password';
-        } elseif ($authMethod === 'ldap' || $authMethod === 'saml2') {
+        } elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
             $validationRules['external_auth_id'] = 'required';
         }
         $this->validate($request, $validationRules);
@@ -93,7 +93,7 @@ class UserController extends Controller
 
         if ($authMethod === 'standard') {
             $user->password = bcrypt($request->get('password', Str::random(32)));
-        } elseif ($authMethod === 'ldap' || $authMethod === 'saml2') {
+        } elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
             $user->external_auth_id = $request->get('external_auth_id');
         }
 
index 1733d29b358bc930489491874d361c67a11ae108..7a09493afc80488ca3d09832662e90e85a81ce67 100644 (file)
@@ -24,12 +24,14 @@ class Kernel extends HttpKernel
      */
     protected $middlewareGroups = [
         'web' => [
-            \BookStack\Http\Middleware\ControlIframeSecurity::class,
+            \BookStack\Http\Middleware\ApplyCspRules::class,
             \BookStack\Http\Middleware\EncryptCookies::class,
             \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
             \Illuminate\Session\Middleware\StartSession::class,
             \Illuminate\View\Middleware\ShareErrorsFromSession::class,
             \BookStack\Http\Middleware\VerifyCsrfToken::class,
+            \BookStack\Http\Middleware\PreventAuthenticatedResponseCaching::class,
+            \BookStack\Http\Middleware\CheckEmailConfirmed::class,
             \BookStack\Http\Middleware\RunThemeActions::class,
             \BookStack\Http\Middleware\Localization::class,
         ],
@@ -38,6 +40,8 @@ class Kernel extends HttpKernel
             \BookStack\Http\Middleware\EncryptCookies::class,
             \BookStack\Http\Middleware\StartSessionIfCookieExists::class,
             \BookStack\Http\Middleware\ApiAuthenticate::class,
+            \BookStack\Http\Middleware\PreventAuthenticatedResponseCaching::class,
+            \BookStack\Http\Middleware\CheckEmailConfirmed::class,
         ],
     ];
 
diff --git a/app/Http/Middleware/ApplyCspRules.php b/app/Http/Middleware/ApplyCspRules.php
new file mode 100644 (file)
index 0000000..6c9d14e
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use BookStack\Util\CspService;
+use Closure;
+use Illuminate\Http\Request;
+
+class ApplyCspRules
+{
+    /**
+     * @var CspService
+     */
+    protected $cspService;
+
+    public function __construct(CspService $cspService)
+    {
+        $this->cspService = $cspService;
+    }
+
+    /**
+     * Handle an incoming request.
+     *
+     * @param Request $request
+     * @param Closure $next
+     *
+     * @return mixed
+     */
+    public function handle($request, Closure $next)
+    {
+        view()->share('cspNonce', $this->cspService->getNonce());
+        if ($this->cspService->allowedIFrameHostsConfigured()) {
+            config()->set('session.same_site', 'none');
+        }
+
+        $response = $next($request);
+
+        $this->cspService->setFrameAncestors($response);
+        $this->cspService->setScriptSrc($response);
+        $this->cspService->setObjectSrc($response);
+        $this->cspService->setBaseUri($response);
+
+        return $response;
+    }
+}
diff --git a/app/Http/Middleware/CheckEmailConfirmed.php b/app/Http/Middleware/CheckEmailConfirmed.php
new file mode 100644 (file)
index 0000000..7dd970a
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use BookStack\Auth\Access\EmailConfirmationService;
+use BookStack\Auth\User;
+use Closure;
+
+/**
+ * Check that the user's email address is confirmed.
+ *
+ * As of v21.08 this is technically not required but kept as a prevention
+ * to log out any users that may be logged in but in an "awaiting confirmation" state.
+ * We'll keep this for a while until it'd be very unlikely for a user to be upgrading from
+ * a pre-v21.08 version.
+ *
+ * Ideally we'd simply invalidate all existing sessions upon update but that has
+ * proven to be a lot more difficult than expected.
+ */
+class CheckEmailConfirmed
+{
+    protected $confirmationService;
+
+    public function __construct(EmailConfirmationService $confirmationService)
+    {
+        $this->confirmationService = $confirmationService;
+    }
+
+    /**
+     * Handle an incoming request.
+     *
+     * @param \Illuminate\Http\Request $request
+     * @param \Closure                 $next
+     *
+     * @return mixed
+     */
+    public function handle($request, Closure $next)
+    {
+        /** @var User $user */
+        $user = auth()->user();
+        if (auth()->check() && !$user->email_confirmed && $this->confirmationService->confirmationRequired()) {
+            auth()->logout();
+
+            return redirect()->to('/');
+        }
+
+        return $next($request);
+    }
+}
diff --git a/app/Http/Middleware/ControlIframeSecurity.php b/app/Http/Middleware/ControlIframeSecurity.php
deleted file mode 100644 (file)
index 11d9e6d..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php
-
-namespace BookStack\Http\Middleware;
-
-use Closure;
-
-/**
- * Sets CSP headers to restrict the hosts that BookStack can be
- * iframed within. Also adjusts the cookie samesite options
- * so that cookies will operate in the third-party context.
- */
-class ControlIframeSecurity
-{
-    /**
-     * Handle an incoming request.
-     *
-     * @param \Illuminate\Http\Request $request
-     * @param \Closure                 $next
-     *
-     * @return mixed
-     */
-    public function handle($request, Closure $next)
-    {
-        $iframeHosts = collect(explode(' ', config('app.iframe_hosts', '')))->filter();
-        if ($iframeHosts->count() > 0) {
-            config()->set('session.same_site', 'none');
-        }
-
-        $iframeHosts->prepend("'self'");
-
-        $response = $next($request);
-        $cspValue = 'frame-ancestors ' . $iframeHosts->join(' ');
-        $response->headers->set('Content-Security-Policy', $cspValue);
-
-        return $response;
-    }
-}
diff --git a/app/Http/Middleware/PreventAuthenticatedResponseCaching.php b/app/Http/Middleware/PreventAuthenticatedResponseCaching.php
new file mode 100644 (file)
index 0000000..60c9138
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use Closure;
+use Symfony\Component\HttpFoundation\Response;
+
+class PreventAuthenticatedResponseCaching
+{
+    /**
+     * Handle an incoming request.
+     *
+     * @param \Illuminate\Http\Request $request
+     * @param \Closure                 $next
+     *
+     * @return mixed
+     */
+    public function handle($request, Closure $next)
+    {
+        /** @var Response $response */
+        $response = $next($request);
+
+        if (signedInUser()) {
+            $response->headers->set('Cache-Control', 'max-age=0, no-store, private');
+            $response->headers->set('Pragma', 'no-cache');
+            $response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
+        }
+
+        return $response;
+    }
+}
index 145a7645b72254904dcaa890f1cf2b082918841c..34a3a290f0c0cf200537217abf0f0d677c38a5d3 100644 (file)
@@ -2,7 +2,6 @@
 
 namespace BookStack\Providers;
 
-use Blade;
 use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\Access\SocialAuthService;
 use BookStack\Entities\BreadcrumbsViewComposer;
@@ -10,15 +9,21 @@ use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
+use BookStack\Exceptions\WhoopsBookStackPrettyHandler;
 use BookStack\Settings\Setting;
 use BookStack\Settings\SettingService;
+use BookStack\Util\CspService;
+use GuzzleHttp\Client;
 use Illuminate\Contracts\Cache\Repository;
 use Illuminate\Database\Eloquent\Relations\Relation;
+use Illuminate\Support\Facades\Blade;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\URL;
 use Illuminate\Support\Facades\View;
 use Illuminate\Support\ServiceProvider;
 use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
-use Schema;
-use URL;
+use Psr\Http\Client\ClientInterface as HttpClientInterface;
+use Whoops\Handler\HandlerInterface;
 
 class AppServiceProvider extends ServiceProvider
 {
@@ -64,6 +69,10 @@ class AppServiceProvider extends ServiceProvider
      */
     public function register()
     {
+        $this->app->bind(HandlerInterface::class, function ($app) {
+            return $app->make(WhoopsBookStackPrettyHandler::class);
+        });
+
         $this->app->singleton(SettingService::class, function ($app) {
             return new SettingService($app->make(Setting::class), $app->make(Repository::class));
         });
@@ -71,5 +80,15 @@ class AppServiceProvider extends ServiceProvider
         $this->app->singleton(SocialAuthService::class, function ($app) {
             return new SocialAuthService($app->make(SocialiteFactory::class), $app->make(LoginService::class));
         });
+
+        $this->app->singleton(CspService::class, function ($app) {
+            return new CspService();
+        });
+
+        $this->app->bind(HttpClientInterface::class, function ($app) {
+            return new Client([
+                'timeout' => 3,
+            ]);
+        });
     }
 }
index 71b7ab016200bb085c5383584463313c673c1eb6..4a626e4fadd49e49d697967c496aad433f6de488 100644 (file)
@@ -2,14 +2,14 @@
 
 namespace BookStack\Providers;
 
-use Auth;
 use BookStack\Api\ApiTokenGuard;
 use BookStack\Auth\Access\ExternalBaseUserProvider;
+use BookStack\Auth\Access\Guards\AsyncExternalBaseSessionGuard;
 use BookStack\Auth\Access\Guards\LdapSessionGuard;
-use BookStack\Auth\Access\Guards\Saml2SessionGuard;
 use BookStack\Auth\Access\LdapService;
 use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\Access\RegistrationService;
+use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\ServiceProvider;
 
 class AuthServiceProvider extends ServiceProvider
@@ -37,10 +37,10 @@ class AuthServiceProvider extends ServiceProvider
             );
         });
 
-        Auth::extend('saml2-session', function ($app, $name, array $config) {
+        Auth::extend('async-external-session', function ($app, $name, array $config) {
             $provider = Auth::createUserProvider($config['provider']);
 
-            return new Saml2SessionGuard(
+            return new AsyncExternalBaseSessionGuard(
                 $name,
                 $provider,
                 $app['session.store'],
index 8f0dab400c7efd4ce00902d7ef94874a654e516e..b60443a452895fc19a8c4a5dcffdfeb03f339b52 100644 (file)
@@ -3,7 +3,7 @@
 namespace BookStack\Providers;
 
 use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
-use Route;
+use Illuminate\Support\Facades\Route;
 
 class RouteServiceProvider extends ServiceProvider
 {
diff --git a/app/Theming/CustomHtmlHeadContentProvider.php b/app/Theming/CustomHtmlHeadContentProvider.php
new file mode 100644 (file)
index 0000000..041e5d0
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+
+namespace BookStack\Theming;
+
+use BookStack\Util\CspService;
+use BookStack\Util\HtmlContentFilter;
+use BookStack\Util\HtmlNonceApplicator;
+use Illuminate\Contracts\Cache\Repository as Cache;
+
+class CustomHtmlHeadContentProvider
+{
+    /**
+     * @var CspService
+     */
+    protected $cspService;
+
+    /**
+     * @var Cache
+     */
+    protected $cache;
+
+    public function __construct(CspService $cspService, Cache $cache)
+    {
+        $this->cspService = $cspService;
+        $this->cache = $cache;
+    }
+
+    /**
+     * Fetch our custom HTML head content prepared for use on web pages.
+     * Content has a nonce applied for CSP.
+     */
+    public function forWeb(): string
+    {
+        $content = $this->getSourceContent();
+        $hash = md5($content);
+        $html = $this->cache->remember('custom-head-web:' . $hash, 86400, function () use ($content) {
+            return HtmlNonceApplicator::prepare($content);
+        });
+
+        return HtmlNonceApplicator::apply($html, $this->cspService->getNonce());
+    }
+
+    /**
+     * Fetch our custom HTML head content prepared for use in export formats.
+     * Scripts are stripped to avoid potential issues.
+     */
+    public function forExport(): string
+    {
+        $content = $this->getSourceContent();
+        $hash = md5($content);
+
+        return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) {
+            return HtmlContentFilter::removeScripts($content);
+        });
+    }
+
+    /**
+     * Get the original custom head content to use.
+     */
+    protected function getSourceContent(): string
+    {
+        return setting('app-custom-head', '');
+    }
+}
index 5acd4f141bb2dfcac8f118bde2bdcf11b97b2af8..a470ec5346ccfc091642eee052d5e8022a28d40b 100644 (file)
@@ -2,24 +2,37 @@
 
 namespace BookStack\Uploads;
 
+use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
 use BookStack\Model;
 use BookStack\Traits\HasCreatorAndUpdater;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
- * @property int id
- * @property string name
- * @property string path
- * @property string extension
- * @property ?Page page
- * @property bool external
+ * @property int    $id
+ * @property string $name
+ * @property string $path
+ * @property string $extension
+ * @property ?Page  $page
+ * @property bool   $external
+ * @property int    $uploaded_to
+ * @property User   $updatedBy
+ * @property User   $createdBy
+ *
+ * @method static Entity|Builder visible()
  */
 class Attachment extends Model
 {
     use HasCreatorAndUpdater;
 
     protected $fillable = ['name', 'order'];
+    protected $hidden = ['path', 'page'];
+    protected $casts = [
+        'external' => 'bool',
+    ];
 
     /**
      * Get the downloadable file name for this upload.
@@ -70,4 +83,19 @@ class Attachment extends Model
     {
         return '[' . $this->name . '](' . $this->getUrl() . ')';
     }
+
+    /**
+     * Scope the query to those attachments that are visible based upon related page permissions.
+     */
+    public function scopeVisible(): Builder
+    {
+        $permissionService = app()->make(PermissionService::class);
+
+        return $permissionService->filterRelatedEntity(
+            Page::class,
+            Attachment::query(),
+            'attachments',
+            'uploaded_to'
+        );
+    }
 }
index 298d53a04c109ff42175318fd3ed69f9166ea375..f7a0918c60930c22b5a7a565a4cafe3fbb462e37 100644 (file)
@@ -7,8 +7,9 @@ use Exception;
 use Illuminate\Contracts\Filesystem\Factory as FileSystem;
 use Illuminate\Contracts\Filesystem\FileNotFoundException;
 use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
+use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
-use Log;
+use League\Flysystem\Util;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
 class AttachmentService
@@ -27,15 +28,39 @@ class AttachmentService
      * Get the storage that will be used for storing files.
      */
     protected function getStorage(): FileSystemInstance
+    {
+        return $this->fileSystem->disk($this->getStorageDiskName());
+    }
+
+    /**
+     * Get the name of the storage disk to use.
+     */
+    protected function getStorageDiskName(): string
     {
         $storageType = config('filesystems.attachments');
 
-        // Override default location if set to local public to ensure not visible.
-        if ($storageType === 'local') {
-            $storageType = 'local_secure';
+        // Change to our secure-attachment disk if any of the local options
+        // are used to prevent escaping that location.
+        if ($storageType === 'local' || $storageType === 'local_secure') {
+            $storageType = 'local_secure_attachments';
         }
 
-        return $this->fileSystem->disk($storageType);
+        return $storageType;
+    }
+
+    /**
+     * Change the originally provided path to fit any disk-specific requirements.
+     * This also ensures the path is kept to the expected root folders.
+     */
+    protected function adjustPathForStorageDisk(string $path): string
+    {
+        $path = Util::normalizePath(str_replace('uploads/files/', '', $path));
+
+        if ($this->getStorageDiskName() === 'local_secure_attachments') {
+            return $path;
+        }
+
+        return 'uploads/files/' . $path;
     }
 
     /**
@@ -45,30 +70,26 @@ class AttachmentService
      */
     public function getAttachmentFromStorage(Attachment $attachment): string
     {
-        return $this->getStorage()->get($attachment->path);
+        return $this->getStorage()->get($this->adjustPathForStorageDisk($attachment->path));
     }
 
     /**
      * Store a new attachment upon user upload.
      *
-     * @param UploadedFile $uploadedFile
-     * @param int          $page_id
-     *
      * @throws FileUploadException
-     *
-     * @return Attachment
      */
-    public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
+    public function saveNewUpload(UploadedFile $uploadedFile, int $pageId): Attachment
     {
         $attachmentName = $uploadedFile->getClientOriginalName();
         $attachmentPath = $this->putFileInStorage($uploadedFile);
-        $largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
+        $largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $pageId)->max('order');
 
-        $attachment = Attachment::forceCreate([
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->forceCreate([
             'name'        => $attachmentName,
             'path'        => $attachmentPath,
             'extension'   => $uploadedFile->getClientOriginalExtension(),
-            'uploaded_to' => $page_id,
+            'uploaded_to' => $pageId,
             'created_by'  => user()->id,
             'updated_by'  => user()->id,
             'order'       => $largestExistingOrder + 1,
@@ -78,17 +99,12 @@ class AttachmentService
     }
 
     /**
-     * Store a upload, saving to a file and deleting any existing uploads
+     * Store an upload, saving to a file and deleting any existing uploads
      * attached to that file.
      *
-     * @param UploadedFile $uploadedFile
-     * @param Attachment   $attachment
-     *
      * @throws FileUploadException
-     *
-     * @return Attachment
      */
-    public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment)
+    public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment): Attachment
     {
         if (!$attachment->external) {
             $this->deleteFileInStorage($attachment);
@@ -143,51 +159,46 @@ class AttachmentService
     public function updateFile(Attachment $attachment, array $requestData): Attachment
     {
         $attachment->name = $requestData['name'];
+        $link = trim($requestData['link'] ?? '');
 
-        if (isset($requestData['link']) && trim($requestData['link']) !== '') {
-            $attachment->path = $requestData['link'];
+        if (!empty($link)) {
             if (!$attachment->external) {
                 $this->deleteFileInStorage($attachment);
                 $attachment->external = true;
+                $attachment->extension = '';
             }
+            $attachment->path = $requestData['link'];
         }
 
         $attachment->save();
 
-        return $attachment;
+        return $attachment->refresh();
     }
 
     /**
      * Delete a File from the database and storage.
      *
-     * @param Attachment $attachment
-     *
      * @throws Exception
      */
     public function deleteFile(Attachment $attachment)
     {
-        if ($attachment->external) {
-            $attachment->delete();
-
-            return;
+        if (!$attachment->external) {
+            $this->deleteFileInStorage($attachment);
         }
 
-        $this->deleteFileInStorage($attachment);
         $attachment->delete();
     }
 
     /**
      * Delete a file from the filesystem it sits on.
      * Cleans any empty leftover folders.
-     *
-     * @param Attachment $attachment
      */
     protected function deleteFileInStorage(Attachment $attachment)
     {
         $storage = $this->getStorage();
-        $dirPath = dirname($attachment->path);
+        $dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path));
 
-        $storage->delete($attachment->path);
+        $storage->delete($this->adjustPathForStorageDisk($attachment->path));
         if (count($storage->allFiles($dirPath)) === 0) {
             $storage->deleteDirectory($dirPath);
         }
@@ -196,13 +207,9 @@ class AttachmentService
     /**
      * Store a file in storage with the given filename.
      *
-     * @param UploadedFile $uploadedFile
-     *
      * @throws FileUploadException
-     *
-     * @return string
      */
-    protected function putFileInStorage(UploadedFile $uploadedFile)
+    protected function putFileInStorage(UploadedFile $uploadedFile): string
     {
         $attachmentData = file_get_contents($uploadedFile->getRealPath());
 
@@ -210,14 +217,14 @@ class AttachmentService
         $basePath = 'uploads/files/' . date('Y-m-M') . '/';
 
         $uploadFileName = Str::random(16) . '.' . $uploadedFile->getClientOriginalExtension();
-        while ($storage->exists($basePath . $uploadFileName)) {
+        while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
             $uploadFileName = Str::random(3) . $uploadFileName;
         }
 
         $attachmentPath = $basePath . $uploadFileName;
 
         try {
-            $storage->put($attachmentPath, $attachmentData);
+            $storage->put($this->adjustPathForStorageDisk($attachmentPath), $attachmentData);
         } catch (Exception $e) {
             Log::error('Error when attempting file upload:' . $e->getMessage());
 
index 11507856140a0a7c330991cb8c488a1167257170..c4205e35740d0fce777ac8fd0c358b241dedfd49 100644 (file)
@@ -16,6 +16,8 @@ class ImageRepo
     protected $restrictionService;
     protected $page;
 
+    protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
+
     /**
      * ImageRepo constructor.
      */
@@ -31,6 +33,14 @@ class ImageRepo
         $this->page = $page;
     }
 
+    /**
+     * Check if the given image extension is supported by BookStack.
+     */
+    public function imageExtensionSupported(string $extension): bool
+    {
+        return in_array(trim($extension, '. \t\n\r\0\x0B'), static::$supportedExtensions);
+    }
+
     /**
      * Get an image with the given id.
      */
index 51ddf9bdc55912c3884e9b126aa3b92634a66ab2..d6c74c751c774e258aeabd1151a60b52fc52655c 100644 (file)
@@ -3,7 +3,6 @@
 namespace BookStack\Uploads;
 
 use BookStack\Exceptions\ImageUploadException;
-use DB;
 use ErrorException;
 use Exception;
 use Illuminate\Contracts\Cache\Repository as Cache;
@@ -11,9 +10,11 @@ use Illuminate\Contracts\Filesystem\Factory as FileSystem;
 use Illuminate\Contracts\Filesystem\FileNotFoundException;
 use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
 use Illuminate\Contracts\Filesystem\Filesystem as Storage;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Str;
 use Intervention\Image\Exception\NotSupportedException;
 use Intervention\Image\ImageManager;
+use League\Flysystem\Util;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
 class ImageService
@@ -38,16 +39,43 @@ class ImageService
     /**
      * Get the storage that will be used for storing images.
      */
-    protected function getStorage(string $type = ''): FileSystemInstance
+    protected function getStorage(string $imageType = ''): FileSystemInstance
+    {
+        return $this->fileSystem->disk($this->getStorageDiskName($imageType));
+    }
+
+    /**
+     * Change the originally provided path to fit any disk-specific requirements.
+     * This also ensures the path is kept to the expected root folders.
+     */
+    protected function adjustPathForStorageDisk(string $path, string $imageType = ''): string
+    {
+        $path = Util::normalizePath(str_replace('uploads/images/', '', $path));
+
+        if ($this->getStorageDiskName($imageType) === 'local_secure_images') {
+            return $path;
+        }
+
+        return 'uploads/images/' . $path;
+    }
+
+    /**
+     * Get the name of the storage disk to use.
+     */
+    protected function getStorageDiskName(string $imageType): string
     {
         $storageType = config('filesystems.images');
 
         // Ensure system images (App logo) are uploaded to a public space
-        if ($type === 'system' && $storageType === 'local_secure') {
+        if ($imageType === 'system' && $storageType === 'local_secure') {
             $storageType = 'local';
         }
 
-        return $this->fileSystem->disk($storageType);
+        if ($storageType === 'local_secure') {
+            $storageType = 'local_secure_images';
+        }
+
+        return $storageType;
     }
 
     /**
@@ -104,7 +132,7 @@ class ImageService
 
         $imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/';
 
-        while ($storage->exists($imagePath . $fileName)) {
+        while ($storage->exists($this->adjustPathForStorageDisk($imagePath . $fileName, $type))) {
             $fileName = Str::random(3) . $fileName;
         }
 
@@ -114,7 +142,7 @@ class ImageService
         }
 
         try {
-            $this->saveImageDataInPublicSpace($storage, $fullPath, $imageData);
+            $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData);
         } catch (Exception $e) {
             \Log::error('Error when attempting image upload:' . $e->getMessage());
 
@@ -216,13 +244,13 @@ class ImageService
         }
 
         $storage = $this->getStorage($image->type);
-        if ($storage->exists($thumbFilePath)) {
+        if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
             return $this->getPublicUrl($thumbFilePath);
         }
 
-        $thumbData = $this->resizeImage($storage->get($imagePath), $width, $height, $keepRatio);
+        $thumbData = $this->resizeImage($storage->get($this->adjustPathForStorageDisk($imagePath, $image->type)), $width, $height, $keepRatio);
 
-        $this->saveImageDataInPublicSpace($storage, $thumbFilePath, $thumbData);
+        $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
         $this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
 
         return $this->getPublicUrl($thumbFilePath);
@@ -279,10 +307,9 @@ class ImageService
      */
     public function getImageData(Image $image): string
     {
-        $imagePath = $image->path;
         $storage = $this->getStorage();
 
-        return $storage->get($imagePath);
+        return $storage->get($this->adjustPathForStorageDisk($image->path, $image->type));
     }
 
     /**
@@ -292,7 +319,7 @@ class ImageService
      */
     public function destroy(Image $image)
     {
-        $this->destroyImagesFromPath($image->path);
+        $this->destroyImagesFromPath($image->path, $image->type);
         $image->delete();
     }
 
@@ -300,9 +327,10 @@ class ImageService
      * Destroys an image at the given path.
      * Searches for image thumbnails in addition to main provided path.
      */
-    protected function destroyImagesFromPath(string $path): bool
+    protected function destroyImagesFromPath(string $path, string $imageType): bool
     {
-        $storage = $this->getStorage();
+        $path = $this->adjustPathForStorageDisk($path, $imageType);
+        $storage = $this->getStorage($imageType);
 
         $imageFolder = dirname($path);
         $imageFileName = basename($path);
@@ -326,7 +354,7 @@ class ImageService
     }
 
     /**
-     * Check whether or not a folder is empty.
+     * Check whether a folder is empty.
      */
     protected function isFolderEmpty(FileSystemInstance $storage, string $path): bool
     {
@@ -374,7 +402,7 @@ class ImageService
     }
 
     /**
-     * Convert a image URI to a Base64 encoded string.
+     * Convert an image URI to a Base64 encoded string.
      * Attempts to convert the URL to a system storage url then
      * fetch the data from the disk or storage location.
      * Returns null if the image data cannot be fetched from storage.
@@ -388,6 +416,7 @@ class ImageService
             return null;
         }
 
+        $storagePath = $this->adjustPathForStorageDisk($storagePath);
         $storage = $this->getStorage();
         $imageData = null;
         if ($storage->exists($storagePath)) {
diff --git a/app/Util/CspService.php b/app/Util/CspService.php
new file mode 100644 (file)
index 0000000..812e1a4
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+
+namespace BookStack\Util;
+
+use Illuminate\Support\Str;
+use Symfony\Component\HttpFoundation\Response;
+
+class CspService
+{
+    /** @var string */
+    protected $nonce;
+
+    public function __construct(string $nonce = '')
+    {
+        $this->nonce = $nonce ?: Str::random(24);
+    }
+
+    /**
+     * Get the nonce value for CSP.
+     */
+    public function getNonce(): string
+    {
+        return $this->nonce;
+    }
+
+    /**
+     * Sets CSP 'script-src' headers to restrict the forms of script that can
+     * run on the page.
+     */
+    public function setScriptSrc(Response $response)
+    {
+        if (config('app.allow_content_scripts')) {
+            return;
+        }
+
+        $parts = [
+            'http:',
+            'https:',
+            '\'nonce-' . $this->nonce . '\'',
+            '\'strict-dynamic\'',
+        ];
+
+        $value = 'script-src ' . implode(' ', $parts);
+        $response->headers->set('Content-Security-Policy', $value, false);
+    }
+
+    /**
+     * Sets CSP "frame-ancestors" headers to restrict the hosts that BookStack can be
+     * iframed within. Also adjusts the cookie samesite options so that cookies will
+     * operate in the third-party context.
+     */
+    public function setFrameAncestors(Response $response)
+    {
+        $iframeHosts = $this->getAllowedIframeHosts();
+        array_unshift($iframeHosts, "'self'");
+        $cspValue = 'frame-ancestors ' . implode(' ', $iframeHosts);
+        $response->headers->set('Content-Security-Policy', $cspValue, false);
+    }
+
+    /**
+     * Check if the user has configured some allowed iframe hosts.
+     */
+    public function allowedIFrameHostsConfigured(): bool
+    {
+        return count($this->getAllowedIframeHosts()) > 0;
+    }
+
+    /**
+     * Sets CSP 'object-src' headers to restrict the types of dynamic content
+     * that can be embedded on the page.
+     */
+    public function setObjectSrc(Response $response)
+    {
+        if (config('app.allow_content_scripts')) {
+            return;
+        }
+
+        $response->headers->set('Content-Security-Policy', 'object-src \'self\'', false);
+    }
+
+    /**
+     * Sets CSP 'base-uri' headers to restrict what base tags can be set on
+     * the page to prevent manipulation of relative links.
+     */
+    public function setBaseUri(Response $response)
+    {
+        $response->headers->set('Content-Security-Policy', 'base-uri \'self\'', false);
+    }
+
+    protected function getAllowedIframeHosts(): array
+    {
+        $hosts = config('app.iframe_hosts', '');
+
+        return array_filter(explode(' ', $hosts));
+    }
+}
index f251a22fdc94730ea1fc049299641b3cd42a279a..1943aa7802c81d6edb7707bc0f7d211d45514839 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Util;
 
+use DOMAttr;
 use DOMDocument;
 use DOMNodeList;
 use DOMXPath;
@@ -9,7 +10,7 @@ use DOMXPath;
 class HtmlContentFilter
 {
     /**
-     * Remove all of the script elements from the given HTML.
+     * Remove all the script elements from the given HTML.
      */
     public static function removeScripts(string $html): string
     {
@@ -28,28 +29,29 @@ class HtmlContentFilter
         static::removeNodes($scriptElems);
 
         // Remove clickable links to JavaScript URI
-        $badLinks = $xPath->query('//*[contains(@href, \'javascript:\')]');
+        $badLinks = $xPath->query('//*[' . static::xpathContains('@href', 'javascript:') . ']');
         static::removeNodes($badLinks);
 
         // Remove forms with calls to JavaScript URI
-        $badForms = $xPath->query('//*[contains(@action, \'javascript:\')] | //*[contains(@formaction, \'javascript:\')]');
+        $badForms = $xPath->query('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
         static::removeNodes($badForms);
 
         // Remove meta tag to prevent external redirects
-        $metaTags = $xPath->query('//meta[contains(@content, \'url\')]');
+        $metaTags = $xPath->query('//meta[' . static::xpathContains('@content', 'url') . ']');
         static::removeNodes($metaTags);
 
         // Remove data or JavaScript iFrames
-        $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
+        $badIframes = $xPath->query('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
         static::removeNodes($badIframes);
 
+        // Remove elements with a xlink:href attribute
+        // Used in SVG but deprecated anyway, so we'll be a bit more heavy-handed here.
+        $xlinkHrefAttributes = $xPath->query('//@*[contains(name(), \'xlink:href\')]');
+        static::removeAttributes($xlinkHrefAttributes);
+
         // Remove 'on*' attributes
         $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
-        foreach ($onAttributes as $attr) {
-            /** @var \DOMAttr $attr */
-            $attrName = $attr->nodeName;
-            $attr->parentNode->removeAttribute($attrName);
-        }
+        static::removeAttributes($onAttributes);
 
         $html = '';
         $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
@@ -61,7 +63,19 @@ class HtmlContentFilter
     }
 
     /**
-     * Removed all of the given DOMNodes.
+     * Create a xpath contains statement with a translation automatically built within
+     * to affectively search in a cases-insensitive manner.
+     */
+    protected static function xpathContains(string $property, string $value): string
+    {
+        $value = strtolower($value);
+        $upperVal = strtoupper($value);
+
+        return 'contains(translate(' . $property . ', \'' . $upperVal . '\', \'' . $value . '\'), \'' . $value . '\')';
+    }
+
+    /**
+     * Remove all the given DOMNodes.
      */
     protected static function removeNodes(DOMNodeList $nodes): void
     {
@@ -69,4 +83,16 @@ class HtmlContentFilter
             $node->parentNode->removeChild($node);
         }
     }
+
+    /**
+     * Remove all the given attribute nodes.
+     */
+    protected static function removeAttributes(DOMNodeList $attrs): void
+    {
+        /** @var DOMAttr $attr */
+        foreach ($attrs as $attr) {
+            $attrName = $attr->nodeName;
+            $attr->parentNode->removeAttribute($attrName);
+        }
+    }
 }
diff --git a/app/Util/HtmlNonceApplicator.php b/app/Util/HtmlNonceApplicator.php
new file mode 100644 (file)
index 0000000..0729857
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+namespace BookStack\Util;
+
+use DOMDocument;
+use DOMElement;
+use DOMNodeList;
+use DOMXPath;
+
+class HtmlNonceApplicator
+{
+    protected static $placeholder = '[CSP_NONCE_VALUE]';
+
+    /**
+     * Prepare the given HTML content with nonce attributes including a placeholder
+     * value which we can target later.
+     */
+    public static function prepare(string $html): string
+    {
+        if (empty($html)) {
+            return $html;
+        }
+
+        $html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
+        libxml_use_internal_errors(true);
+        $doc = new DOMDocument();
+        $doc->loadHTML($html, LIBXML_SCHEMA_CREATE);
+        $xPath = new DOMXPath($doc);
+
+        // Apply to scripts
+        $scriptElems = $xPath->query('//script');
+        static::addNonceAttributes($scriptElems, static::$placeholder);
+
+        // Apply to styles
+        $styleElems = $xPath->query('//style');
+        static::addNonceAttributes($styleElems, static::$placeholder);
+
+        $returnHtml = '';
+        $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
+        foreach ($topElems as $child) {
+            $content = $doc->saveHTML($child);
+            $returnHtml .= $content;
+        }
+
+        return $returnHtml;
+    }
+
+    /**
+     * Apply the give nonce value to the given prepared HTML.
+     */
+    public static function apply(string $html, string $nonce): string
+    {
+        return str_replace(static::$placeholder, $nonce, $html);
+    }
+
+    protected static function addNonceAttributes(DOMNodeList $nodes, string $attrValue): void
+    {
+        /** @var DOMElement $node */
+        foreach ($nodes as $node) {
+            $node->setAttribute('nonce', $attrValue);
+        }
+    }
+}
index 7362a085dc1d7912196048688157ab786e4aec82..fa2c0c2b51b124eb2283b9531133e1aca8ec686d 100644 (file)
         "barryvdh/laravel-dompdf": "^0.9.0",
         "barryvdh/laravel-snappy": "^0.4.8",
         "doctrine/dbal": "^2.12.1",
-        "facade/ignition": "^1.16.4",
         "fideloper/proxy": "^4.4.1",
+        "filp/whoops": "^2.14",
         "intervention/image": "^2.5.1",
-        "laravel/framework": "^6.20.16",
+        "laravel/framework": "^6.20.33",
         "laravel/socialite": "^5.1",
         "league/commonmark": "^1.5",
         "league/flysystem-aws-s3-v3": "^1.0.29",
         "league/html-to-markdown": "^5.0.0",
+        "league/oauth2-client": "^2.6",
         "nunomaduro/collision": "^3.1",
         "onelogin/php-saml": "^4.0",
+        "phpseclib/phpseclib": "~3.0",
         "pragmarx/google2fa": "^8.0",
         "predis/predis": "^1.1.6",
         "socialiteproviders/discord": "^4.1",
@@ -41,9 +43,9 @@
         "barryvdh/laravel-debugbar": "^3.5.1",
         "barryvdh/laravel-ide-helper": "^2.8.2",
         "fakerphp/faker": "^1.13.0",
-        "laravel/browser-kit-testing": "^5.2",
         "mockery/mockery": "^1.3.3",
-        "phpunit/phpunit": "^9.5.3"
+        "phpunit/phpunit": "^9.5.3",
+        "symfony/dom-crawler": "^5.3"
     },
     "autoload": {
         "classmap": [
index de772a2157cec886cc35627305a0de8af8bf9605..f8a13ba8b58208397348ee805a00a95b8f96e1d9 100644 (file)
@@ -4,23 +4,74 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "4d845f3c8b77c8d73bf92c9223ddd805",
+    "content-hash": "fc6d8f731e3975127a9101802cc4bb3a",
     "packages": [
+        {
+            "name": "aws/aws-crt-php",
+            "version": "v1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/awslabs/aws-crt-php.git",
+                "reference": "3942776a8c99209908ee0b287746263725685732"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/3942776a8c99209908ee0b287746263725685732",
+                "reference": "3942776a8c99209908ee0b287746263725685732",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.5"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.8.35|^5.4.3"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "authors": [
+                {
+                    "name": "AWS SDK Common Runtime Team",
+                    "email": "[email protected]"
+                }
+            ],
+            "description": "AWS Common Runtime for PHP",
+            "homepage": "http://aws.amazon.com/sdkforphp",
+            "keywords": [
+                "amazon",
+                "aws",
+                "crt",
+                "sdk"
+            ],
+            "support": {
+                "issues": "https://github.com/awslabs/aws-crt-php/issues",
+                "source": "https://github.com/awslabs/aws-crt-php/tree/v1.0.2"
+            },
+            "time": "2021-09-03T22:57:30+00:00"
+        },
         {
             "name": "aws/aws-sdk-php",
-            "version": "3.191.6",
+            "version": "3.198.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/aws/aws-sdk-php.git",
-                "reference": "3b01e0c7c1d9858e5d2f0ee9aa216d621f731765"
+                "reference": "821b8db50dd39be8ec94f286050a500b5f8a0142"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/3b01e0c7c1d9858e5d2f0ee9aa216d621f731765",
-                "reference": "3b01e0c7c1d9858e5d2f0ee9aa216d621f731765",
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/821b8db50dd39be8ec94f286050a500b5f8a0142",
+                "reference": "821b8db50dd39be8ec94f286050a500b5f8a0142",
                 "shasum": ""
             },
             "require": {
+                "aws/aws-crt-php": "^1.0.2",
                 "ext-json": "*",
                 "ext-pcre": "*",
                 "ext-simplexml": "*",
             "support": {
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
-                "source": "https://github.com/aws/aws-sdk-php/tree/3.191.6"
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.198.6"
             },
-            "time": "2021-08-27T18:14:01+00:00"
+            "time": "2021-10-15T18:38:53+00:00"
         },
         {
             "name": "bacon/bacon-qr-code",
         },
         {
             "name": "doctrine/dbal",
-            "version": "2.13.2",
+            "version": "2.13.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/dbal.git",
-                "reference": "8dd39d2ead4409ce652fd4f02621060f009ea5e4"
+                "reference": "2411a55a2a628e6d8dd598388ab13474802c7b6e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/dbal/zipball/8dd39d2ead4409ce652fd4f02621060f009ea5e4",
-                "reference": "8dd39d2ead4409ce652fd4f02621060f009ea5e4",
+                "url": "https://api.github.com/repos/doctrine/dbal/zipball/2411a55a2a628e6d8dd598388ab13474802c7b6e",
+                "reference": "2411a55a2a628e6d8dd598388ab13474802c7b6e",
                 "shasum": ""
             },
             "require": {
             },
             "require-dev": {
                 "doctrine/coding-standard": "9.0.0",
-                "jetbrains/phpstorm-stubs": "2020.2",
-                "phpstan/phpstan": "0.12.81",
-                "phpunit/phpunit": "^7.5.20|^8.5|9.5.5",
+                "jetbrains/phpstorm-stubs": "2021.1",
+                "phpstan/phpstan": "0.12.99",
+                "phpunit/phpunit": "^7.5.20|^8.5|9.5.10",
+                "psalm/plugin-phpunit": "0.16.1",
                 "squizlabs/php_codesniffer": "3.6.0",
                 "symfony/cache": "^4.4",
                 "symfony/console": "^2.0.5|^3.0|^4.0|^5.0",
-                "vimeo/psalm": "4.6.4"
+                "vimeo/psalm": "4.10.0"
             },
             "suggest": {
                 "symfony/console": "For helpful console commands such as SQL execution and import of files."
             ],
             "support": {
                 "issues": "https://github.com/doctrine/dbal/issues",
-                "source": "https://github.com/doctrine/dbal/tree/2.13.2"
+                "source": "https://github.com/doctrine/dbal/tree/2.13.4"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-06-18T21:48:39+00:00"
+            "time": "2021-10-02T15:59:26+00:00"
         },
         {
             "name": "doctrine/deprecations",
             ],
             "time": "2020-12-29T14:50:06+00:00"
         },
-        {
-            "name": "facade/flare-client-php",
-            "version": "1.8.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/facade/flare-client-php.git",
-                "reference": "47b639dc02bcfdfc4ebb83de703856fa01e35f5f"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/facade/flare-client-php/zipball/47b639dc02bcfdfc4ebb83de703856fa01e35f5f",
-                "reference": "47b639dc02bcfdfc4ebb83de703856fa01e35f5f",
-                "shasum": ""
-            },
-            "require": {
-                "facade/ignition-contracts": "~1.0",
-                "illuminate/pipeline": "^5.5|^6.0|^7.0|^8.0",
-                "php": "^7.1|^8.0",
-                "symfony/http-foundation": "^3.3|^4.1|^5.0",
-                "symfony/mime": "^3.4|^4.0|^5.1",
-                "symfony/var-dumper": "^3.4|^4.0|^5.0"
-            },
-            "require-dev": {
-                "friendsofphp/php-cs-fixer": "^2.14",
-                "phpunit/phpunit": "^7.5.16",
-                "spatie/phpunit-snapshot-assertions": "^2.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Facade\\FlareClient\\": "src"
-                },
-                "files": [
-                    "src/helpers.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "description": "Send PHP errors to Flare",
-            "homepage": "https://github.com/facade/flare-client-php",
-            "keywords": [
-                "exception",
-                "facade",
-                "flare",
-                "reporting"
-            ],
-            "support": {
-                "issues": "https://github.com/facade/flare-client-php/issues",
-                "source": "https://github.com/facade/flare-client-php/tree/1.8.1"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/spatie",
-                    "type": "github"
-                }
-            ],
-            "time": "2021-05-31T19:23:29+00:00"
-        },
-        {
-            "name": "facade/ignition",
-            "version": "1.18.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/facade/ignition.git",
-                "reference": "fca0cbe5f900f94773d821b481c16d4ea3503491"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/facade/ignition/zipball/fca0cbe5f900f94773d821b481c16d4ea3503491",
-                "reference": "fca0cbe5f900f94773d821b481c16d4ea3503491",
-                "shasum": ""
-            },
-            "require": {
-                "ext-json": "*",
-                "ext-mbstring": "*",
-                "facade/flare-client-php": "^1.3",
-                "facade/ignition-contracts": "^1.0",
-                "filp/whoops": "^2.4",
-                "illuminate/support": "~5.5.0 || ~5.6.0 || ~5.7.0 || ~5.8.0 || ^6.0",
-                "monolog/monolog": "^1.12 || ^2.0",
-                "php": "^7.1|^8.0",
-                "scrivo/highlight.php": "^9.15",
-                "symfony/console": "^3.4 || ^4.0",
-                "symfony/var-dumper": "^3.4 || ^4.0"
-            },
-            "require-dev": {
-                "mockery/mockery": "~1.3.3|^1.4.2",
-                "orchestra/testbench": "^3.5 || ^3.6 || ^3.7 || ^3.8 || ^4.0"
-            },
-            "suggest": {
-                "laravel/telescope": "^2.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.x-dev"
-                },
-                "laravel": {
-                    "providers": [
-                        "Facade\\Ignition\\IgnitionServiceProvider"
-                    ],
-                    "aliases": {
-                        "Flare": "Facade\\Ignition\\Facades\\Flare"
-                    }
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Facade\\Ignition\\": "src"
-                },
-                "files": [
-                    "src/helpers.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "description": "A beautiful error page for Laravel applications.",
-            "homepage": "https://github.com/facade/ignition",
-            "keywords": [
-                "error",
-                "flare",
-                "laravel",
-                "page"
-            ],
-            "support": {
-                "docs": "https://flareapp.io/docs/ignition-for-laravel/introduction",
-                "forum": "https://twitter.com/flareappio",
-                "issues": "https://github.com/facade/ignition/issues",
-                "source": "https://github.com/facade/ignition"
-            },
-            "time": "2021-08-02T07:45:03+00:00"
-        },
-        {
-            "name": "facade/ignition-contracts",
-            "version": "1.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/facade/ignition-contracts.git",
-                "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
-                "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.3|^8.0"
-            },
-            "require-dev": {
-                "friendsofphp/php-cs-fixer": "^v2.15.8",
-                "phpunit/phpunit": "^9.3.11",
-                "vimeo/psalm": "^3.17.1"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Facade\\IgnitionContracts\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Freek Van der Herten",
-                    "email": "[email protected]",
-                    "homepage": "https://flareapp.io",
-                    "role": "Developer"
-                }
-            ],
-            "description": "Solution contracts for Ignition",
-            "homepage": "https://github.com/facade/ignition-contracts",
-            "keywords": [
-                "contracts",
-                "flare",
-                "ignition"
-            ],
-            "support": {
-                "issues": "https://github.com/facade/ignition-contracts/issues",
-                "source": "https://github.com/facade/ignition-contracts/tree/1.0.2"
-            },
-            "time": "2020-10-16T08:27:54+00:00"
-        },
         {
             "name": "fideloper/proxy",
             "version": "4.4.1",
         },
         {
             "name": "filp/whoops",
-            "version": "2.14.1",
+            "version": "2.14.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/filp/whoops.git",
-                "reference": "15ead64e9828f0fc90932114429c4f7923570cb1"
+                "reference": "f056f1fe935d9ed86e698905a957334029899895"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/filp/whoops/zipball/15ead64e9828f0fc90932114429c4f7923570cb1",
-                "reference": "15ead64e9828f0fc90932114429c4f7923570cb1",
+                "url": "https://api.github.com/repos/filp/whoops/zipball/f056f1fe935d9ed86e698905a957334029899895",
+                "reference": "f056f1fe935d9ed86e698905a957334029899895",
                 "shasum": ""
             },
             "require": {
                 "php": "^5.5.9 || ^7.0 || ^8.0",
-                "psr/log": "^1.0.1"
+                "psr/log": "^1.0.1 || ^2.0 || ^3.0"
             },
             "require-dev": {
                 "mockery/mockery": "^0.9 || ^1.0",
             ],
             "support": {
                 "issues": "https://github.com/filp/whoops/issues",
-                "source": "https://github.com/filp/whoops/tree/2.14.1"
+                "source": "https://github.com/filp/whoops/tree/2.14.4"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2021-08-29T12:00:00+00:00"
+            "time": "2021-10-03T12:00:00+00:00"
         },
         {
             "name": "guzzlehttp/guzzle",
         },
         {
             "name": "guzzlehttp/promises",
-            "version": "1.4.1",
+            "version": "1.5.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/promises.git",
-                "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d"
+                "reference": "136a635e2b4a49b9d79e9c8fee267ffb257fdba0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/promises/zipball/8e7d04f1f6450fef59366c399cfad4b9383aa30d",
-                "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d",
+                "url": "https://api.github.com/repos/guzzle/promises/zipball/136a635e2b4a49b9d79e9c8fee267ffb257fdba0",
+                "reference": "136a635e2b4a49b9d79e9c8fee267ffb257fdba0",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.4-dev"
+                    "dev-master": "1.5-dev"
                 }
             },
             "autoload": {
                 "MIT"
             ],
             "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
                 {
                     "name": "Michael Dowling",
                     "email": "[email protected]",
                     "homepage": "https://github.com/mtdowling"
+                },
+                {
+                    "name": "Tobias Nyholm",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/Nyholm"
+                },
+                {
+                    "name": "Tobias Schultze",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/Tobion"
                 }
             ],
             "description": "Guzzle promises library",
             ],
             "support": {
                 "issues": "https://github.com/guzzle/promises/issues",
-                "source": "https://github.com/guzzle/promises/tree/1.4.1"
+                "source": "https://github.com/guzzle/promises/tree/1.5.0"
             },
-            "time": "2021-03-07T09:25:29+00:00"
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/Nyholm",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-10-07T13:05:22+00:00"
         },
         {
             "name": "guzzlehttp/psr7",
-            "version": "1.8.2",
+            "version": "1.8.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/psr7.git",
-                "reference": "dc960a912984efb74d0a90222870c72c87f10c91"
+                "reference": "1afdd860a2566ed3c2b0b4a3de6e23434a79ec85"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/psr7/zipball/dc960a912984efb74d0a90222870c72c87f10c91",
-                "reference": "dc960a912984efb74d0a90222870c72c87f10c91",
+                "url": "https://api.github.com/repos/guzzle/psr7/zipball/1afdd860a2566ed3c2b0b4a3de6e23434a79ec85",
+                "reference": "1afdd860a2566ed3c2b0b4a3de6e23434a79ec85",
                 "shasum": ""
             },
             "require": {
                 "MIT"
             ],
             "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
                 {
                     "name": "Michael Dowling",
                     "email": "[email protected]",
                     "homepage": "https://github.com/mtdowling"
                 },
+                {
+                    "name": "George Mponos",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/gmponos"
+                },
+                {
+                    "name": "Tobias Nyholm",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/Nyholm"
+                },
+                {
+                    "name": "Márk Sági-Kazár",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/sagikazarmark"
+                },
                 {
                     "name": "Tobias Schultze",
+                    "email": "[email protected]",
                     "homepage": "https://github.com/Tobion"
                 }
             ],
             ],
             "support": {
                 "issues": "https://github.com/guzzle/psr7/issues",
-                "source": "https://github.com/guzzle/psr7/tree/1.8.2"
+                "source": "https://github.com/guzzle/psr7/tree/1.8.3"
             },
-            "time": "2021-04-26T09:17:50+00:00"
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/Nyholm",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-10-05T13:56:00+00:00"
         },
         {
             "name": "intervention/image",
-            "version": "2.6.1",
+            "version": "2.7.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Intervention/image.git",
-                "reference": "0925f10b259679b5d8ca58f3a2add9255ffcda45"
+                "reference": "9a8cc99d30415ec0b3f7649e1647d03a55698545"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Intervention/image/zipball/0925f10b259679b5d8ca58f3a2add9255ffcda45",
-                "reference": "0925f10b259679b5d8ca58f3a2add9255ffcda45",
+                "url": "https://api.github.com/repos/Intervention/image/zipball/9a8cc99d30415ec0b3f7649e1647d03a55698545",
+                "reference": "9a8cc99d30415ec0b3f7649e1647d03a55698545",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/Intervention/image/issues",
-                "source": "https://github.com/Intervention/image/tree/2.6.1"
+                "source": "https://github.com/Intervention/image/tree/2.7.0"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2021-07-22T14:31:53+00:00"
+            "time": "2021-10-03T14:17:12+00:00"
         },
         {
             "name": "knplabs/knp-snappy",
         },
         {
             "name": "laravel/framework",
-            "version": "v6.20.32",
+            "version": "v6.20.35",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/framework.git",
-                "reference": "04d4fa31d10ed344c9d1cf30a761b0c4e468aaf2"
+                "reference": "5e55aa4063b9f7cf3249bfebcc37a6fbad4f159a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/framework/zipball/04d4fa31d10ed344c9d1cf30a761b0c4e468aaf2",
-                "reference": "04d4fa31d10ed344c9d1cf30a761b0c4e468aaf2",
+                "url": "https://api.github.com/repos/laravel/framework/zipball/5e55aa4063b9f7cf3249bfebcc37a6fbad4f159a",
+                "reference": "5e55aa4063b9f7cf3249bfebcc37a6fbad4f159a",
                 "shasum": ""
             },
             "require": {
                 "issues": "https://github.com/laravel/framework/issues",
                 "source": "https://github.com/laravel/framework"
             },
-            "time": "2021-08-10T14:25:21+00:00"
+            "time": "2021-10-05T14:05:19+00:00"
         },
         {
             "name": "laravel/socialite",
-            "version": "v5.2.4",
+            "version": "v5.2.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/socialite.git",
-                "reference": "59e2f8d9d9663029c7746a92d60bbb7697953bb9"
+                "reference": "fd0f6a3dd963ca480b598649b54f92d81a43617f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/socialite/zipball/59e2f8d9d9663029c7746a92d60bbb7697953bb9",
-                "reference": "59e2f8d9d9663029c7746a92d60bbb7697953bb9",
+                "url": "https://api.github.com/repos/laravel/socialite/zipball/fd0f6a3dd963ca480b598649b54f92d81a43617f",
+                "reference": "fd0f6a3dd963ca480b598649b54f92d81a43617f",
                 "shasum": ""
             },
             "require": {
                 "issues": "https://github.com/laravel/socialite/issues",
                 "source": "https://github.com/laravel/socialite"
             },
-            "time": "2021-08-10T17:44:52+00:00"
+            "time": "2021-08-31T15:16:26+00:00"
         },
         {
             "name": "league/commonmark",
         },
         {
             "name": "league/html-to-markdown",
-            "version": "5.0.0",
+            "version": "5.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/html-to-markdown.git",
-                "reference": "c4dbebbebe0fe454b6b38e6c683a977615bd7dc2"
+                "reference": "e5600a2c5ce7b7571b16732c7086940f56f7abec"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/c4dbebbebe0fe454b6b38e6c683a977615bd7dc2",
-                "reference": "c4dbebbebe0fe454b6b38e6c683a977615bd7dc2",
+                "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/e5600a2c5ce7b7571b16732c7086940f56f7abec",
+                "reference": "e5600a2c5ce7b7571b16732c7086940f56f7abec",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/thephpleague/html-to-markdown/issues",
-                "source": "https://github.com/thephpleague/html-to-markdown/tree/5.0.0"
+                "source": "https://github.com/thephpleague/html-to-markdown/tree/5.0.1"
             },
             "funding": [
                 {
                     "type": "github"
                 },
                 {
-                    "url": "https://www.patreon.com/colinodell",
-                    "type": "patreon"
+                    "url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2021-03-29T01:29:08+00:00"
+            "time": "2021-09-17T20:00:27+00:00"
         },
         {
             "name": "league/mime-type-detection",
-            "version": "1.7.0",
+            "version": "1.8.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/mime-type-detection.git",
-                "reference": "3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3"
+                "reference": "b38b25d7b372e9fddb00335400467b223349fd7e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3",
-                "reference": "3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3",
+                "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/b38b25d7b372e9fddb00335400467b223349fd7e",
+                "reference": "b38b25d7b372e9fddb00335400467b223349fd7e",
                 "shasum": ""
             },
             "require": {
             "description": "Mime-type detection for Flysystem",
             "support": {
                 "issues": "https://github.com/thephpleague/mime-type-detection/issues",
-                "source": "https://github.com/thephpleague/mime-type-detection/tree/1.7.0"
+                "source": "https://github.com/thephpleague/mime-type-detection/tree/1.8.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-18T20:58:21+00:00"
+            "time": "2021-09-25T08:23:19+00:00"
         },
         {
             "name": "league/oauth1-client",
             },
             "time": "2021-08-15T23:05:49+00:00"
         },
+        {
+            "name": "league/oauth2-client",
+            "version": "2.6.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/thephpleague/oauth2-client.git",
+                "reference": "badb01e62383430706433191b82506b6df24ad98"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98",
+                "reference": "badb01e62383430706433191b82506b6df24ad98",
+                "shasum": ""
+            },
+            "require": {
+                "guzzlehttp/guzzle": "^6.0 || ^7.0",
+                "paragonie/random_compat": "^1 || ^2 || ^9.99",
+                "php": "^5.6 || ^7.0 || ^8.0"
+            },
+            "require-dev": {
+                "mockery/mockery": "^1.3",
+                "php-parallel-lint/php-parallel-lint": "^1.2",
+                "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3",
+                "squizlabs/php_codesniffer": "^2.3 || ^3.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-2.x": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "League\\OAuth2\\Client\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Alex Bilbie",
+                    "email": "[email protected]",
+                    "homepage": "http://www.alexbilbie.com",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Woody Gilk",
+                    "homepage": "https://github.com/shadowhand",
+                    "role": "Contributor"
+                }
+            ],
+            "description": "OAuth 2.0 Client Library",
+            "keywords": [
+                "Authentication",
+                "SSO",
+                "authorization",
+                "identity",
+                "idp",
+                "oauth",
+                "oauth2",
+                "single sign on"
+            ],
+            "support": {
+                "issues": "https://github.com/thephpleague/oauth2-client/issues",
+                "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0"
+            },
+            "time": "2020-10-28T02:03:40+00:00"
+        },
         {
             "name": "monolog/monolog",
-            "version": "2.3.2",
+            "version": "2.3.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Seldaek/monolog.git",
-                "reference": "71312564759a7db5b789296369c1a264efc43aad"
+                "reference": "fd4380d6fc37626e2f799f29d91195040137eba9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/71312564759a7db5b789296369c1a264efc43aad",
-                "reference": "71312564759a7db5b789296369c1a264efc43aad",
+                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/fd4380d6fc37626e2f799f29d91195040137eba9",
+                "reference": "fd4380d6fc37626e2f799f29d91195040137eba9",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.2",
-                "psr/log": "^1.0.1"
+                "psr/log": "^1.0.1 || ^2.0 || ^3.0"
             },
             "provide": {
-                "psr/log-implementation": "1.0.0"
+                "psr/log-implementation": "1.0.0 || 2.0.0 || 3.0.0"
             },
             "require-dev": {
                 "aws/aws-sdk-php": "^2.4.9 || ^3.0",
                 "elasticsearch/elasticsearch": "^7",
                 "graylog2/gelf-php": "^1.4.2",
                 "mongodb/mongodb": "^1.8",
-                "php-amqplib/php-amqplib": "~2.4",
+                "php-amqplib/php-amqplib": "~2.4 || ^3",
                 "php-console/php-console": "^3.1.3",
                 "phpspec/prophecy": "^1.6.1",
                 "phpstan/phpstan": "^0.12.91",
                 "phpunit/phpunit": "^8.5",
                 "predis/predis": "^1.1",
                 "rollbar/rollbar": "^1.3",
-                "ruflin/elastica": ">=0.90 <7.0.1",
+                "ruflin/elastica": ">=0.90@dev",
                 "swiftmailer/swiftmailer": "^5.3|^6.0"
             },
             "suggest": {
                 "doctrine/couchdb": "Allow sending log messages to a CouchDB server",
                 "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
                 "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
+                "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
                 "ext-mbstring": "Allow to work properly with unicode symbols",
                 "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
+                "ext-openssl": "Required to send log messages using SSL",
+                "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
                 "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
                 "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
                 "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
             ],
             "support": {
                 "issues": "https://github.com/Seldaek/monolog/issues",
-                "source": "https://github.com/Seldaek/monolog/tree/2.3.2"
+                "source": "https://github.com/Seldaek/monolog/tree/2.3.5"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-23T07:42:52+00:00"
+            "time": "2021-10-01T21:08:31+00:00"
         },
         {
             "name": "mtdowling/jmespath.php",
         },
         {
             "name": "nesbot/carbon",
-            "version": "2.52.0",
+            "version": "2.53.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/briannesbitt/Carbon.git",
-                "reference": "369c0e2737c56a0f39c946dd261855255a6fccbe"
+                "reference": "f4655858a784988f880c1b8c7feabbf02dfdf045"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/369c0e2737c56a0f39c946dd261855255a6fccbe",
-                "reference": "369c0e2737c56a0f39c946dd261855255a6fccbe",
+                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/f4655858a784988f880c1b8c7feabbf02dfdf045",
+                "reference": "f4655858a784988f880c1b8c7feabbf02dfdf045",
                 "shasum": ""
             },
             "require": {
             },
             "require-dev": {
                 "doctrine/orm": "^2.7",
-                "friendsofphp/php-cs-fixer": "^2.14 || ^3.0",
+                "friendsofphp/php-cs-fixer": "^3.0",
                 "kylekatarnls/multi-tester": "^2.0",
                 "phpmd/phpmd": "^2.9",
                 "phpstan/extension-installer": "^1.0",
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-08-14T19:10:52+00:00"
+            "time": "2021-09-06T09:29:23+00:00"
         },
         {
             "name": "nunomaduro/collision",
             ],
             "time": "2021-08-28T21:27:29+00:00"
         },
+        {
+            "name": "phpseclib/phpseclib",
+            "version": "3.0.10",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpseclib/phpseclib.git",
+                "reference": "62fcc5a94ac83b1506f52d7558d828617fac9187"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/62fcc5a94ac83b1506f52d7558d828617fac9187",
+                "reference": "62fcc5a94ac83b1506f52d7558d828617fac9187",
+                "shasum": ""
+            },
+            "require": {
+                "paragonie/constant_time_encoding": "^1|^2",
+                "paragonie/random_compat": "^1.4|^2.0|^9.99.99",
+                "php": ">=5.6.1"
+            },
+            "require-dev": {
+                "phing/phing": "~2.7",
+                "phpunit/phpunit": "^5.7|^6.0|^9.4",
+                "squizlabs/php_codesniffer": "~2.0"
+            },
+            "suggest": {
+                "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
+                "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
+                "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
+                "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "phpseclib/bootstrap.php"
+                ],
+                "psr-4": {
+                    "phpseclib3\\": "phpseclib/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jim Wigginton",
+                    "email": "[email protected]",
+                    "role": "Lead Developer"
+                },
+                {
+                    "name": "Patrick Monnerat",
+                    "email": "[email protected]",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Andreas Fischer",
+                    "email": "[email protected]",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Hans-Jürgen Petrich",
+                    "email": "[email protected]",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Graham Campbell",
+                    "email": "[email protected]",
+                    "role": "Developer"
+                }
+            ],
+            "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
+            "homepage": "http://phpseclib.sourceforge.net",
+            "keywords": [
+                "BigInteger",
+                "aes",
+                "asn.1",
+                "asn1",
+                "blowfish",
+                "crypto",
+                "cryptography",
+                "encryption",
+                "rsa",
+                "security",
+                "sftp",
+                "signature",
+                "signing",
+                "ssh",
+                "twofish",
+                "x.509",
+                "x509"
+            ],
+            "support": {
+                "issues": "https://github.com/phpseclib/phpseclib/issues",
+                "source": "https://github.com/phpseclib/phpseclib/tree/3.0.10"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/terrafrost",
+                    "type": "github"
+                },
+                {
+                    "url": "https://www.patreon.com/phpseclib",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-08-16T04:24:45+00:00"
+        },
         {
             "name": "pragmarx/google2fa",
             "version": "8.0.0",
         },
         {
             "name": "predis/predis",
-            "version": "v1.1.7",
+            "version": "v1.1.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/predis/predis.git",
-                "reference": "b240daa106d4e02f0c5b7079b41e31ddf66fddf8"
+                "reference": "c50c3393bb9f47fa012d0cdfb727a266b0818259"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/predis/predis/zipball/b240daa106d4e02f0c5b7079b41e31ddf66fddf8",
-                "reference": "b240daa106d4e02f0c5b7079b41e31ddf66fddf8",
+                "url": "https://api.github.com/repos/predis/predis/zipball/c50c3393bb9f47fa012d0cdfb727a266b0818259",
+                "reference": "c50c3393bb9f47fa012d0cdfb727a266b0818259",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/predis/predis/issues",
-                "source": "https://github.com/predis/predis/tree/v1.1.7"
+                "source": "https://github.com/predis/predis/tree/v1.1.9"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2021-04-04T19:34:46+00:00"
+            "time": "2021-10-05T19:02:38+00:00"
         },
         {
             "name": "psr/container",
         },
         {
             "name": "ramsey/uuid",
-            "version": "3.9.4",
+            "version": "3.9.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/ramsey/uuid.git",
-                "reference": "be2451bef8147b7352a28fb4cddb08adc497ada3"
+                "reference": "ffa80ab953edd85d5b6c004f96181a538aad35a3"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/ramsey/uuid/zipball/be2451bef8147b7352a28fb4cddb08adc497ada3",
-                "reference": "be2451bef8147b7352a28fb4cddb08adc497ada3",
+                "url": "https://api.github.com/repos/ramsey/uuid/zipball/ffa80ab953edd85d5b6c004f96181a538aad35a3",
+                "reference": "ffa80ab953edd85d5b6c004f96181a538aad35a3",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
                 "paragonie/random_compat": "^1 | ^2 | ^9.99.99",
-                "php": "^5.4 | ^7 | ^8",
+                "php": "^5.4 | ^7.0 | ^8.0",
                 "symfony/polyfill-ctype": "^1.8"
             },
             "replace": {
             "require-dev": {
                 "codeception/aspect-mock": "^1 | ^2",
                 "doctrine/annotations": "^1.2",
-                "goaop/framework": "1.0.0-alpha.2 | ^1 | ^2.1",
-                "jakub-onderka/php-parallel-lint": "^1",
+                "goaop/framework": "1.0.0-alpha.2 | ^1 | >=2.1.0 <=2.3.2",
                 "mockery/mockery": "^0.9.11 | ^1",
                 "moontoast/math": "^1.1",
+                "nikic/php-parser": "<=4.5.0",
                 "paragonie/random-lib": "^2",
-                "php-mock/php-mock-phpunit": "^0.3 | ^1.1",
-                "phpunit/phpunit": "^4.8 | ^5.4 | ^6.5",
-                "squizlabs/php_codesniffer": "^3.5"
+                "php-mock/php-mock-phpunit": "^0.3 | ^1.1 | ^2.6",
+                "php-parallel-lint/php-parallel-lint": "^1.3",
+                "phpunit/phpunit": ">=4.8.36 <9.0.0 | >=9.3.0",
+                "squizlabs/php_codesniffer": "^3.5",
+                "yoast/phpunit-polyfills": "^1.0"
             },
             "suggest": {
                 "ext-ctype": "Provides support for PHP Ctype functions",
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-08-06T20:32:15+00:00"
+            "time": "2021-09-25T23:07:42+00:00"
         },
         {
             "name": "robrichards/xmlseclibs",
             },
             "time": "2020-06-01T09:10:00+00:00"
         },
-        {
-            "name": "scrivo/highlight.php",
-            "version": "v9.18.1.7",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/scrivo/highlight.php.git",
-                "reference": "05996fcc61e97978d76ca7d1ac14b65e7cd26f91"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/05996fcc61e97978d76ca7d1ac14b65e7cd26f91",
-                "reference": "05996fcc61e97978d76ca7d1ac14b65e7cd26f91",
-                "shasum": ""
-            },
-            "require": {
-                "ext-json": "*",
-                "ext-mbstring": "*",
-                "php": ">=5.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^4.8|^5.7",
-                "sabberworm/php-css-parser": "^8.3",
-                "symfony/finder": "^2.8|^3.4",
-                "symfony/var-dumper": "^2.8|^3.4"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-0": {
-                    "Highlight\\": "",
-                    "HighlightUtilities\\": ""
-                },
-                "files": [
-                    "HighlightUtilities/functions.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Geert Bergman",
-                    "homepage": "http://www.scrivo.org/",
-                    "role": "Project Author"
-                },
-                {
-                    "name": "Vladimir Jimenez",
-                    "homepage": "https://allejo.io",
-                    "role": "Maintainer"
-                },
-                {
-                    "name": "Martin Folkers",
-                    "homepage": "https://twobrain.io",
-                    "role": "Contributor"
-                }
-            ],
-            "description": "Server side syntax highlighter that supports 185 languages. It's a PHP port of highlight.js",
-            "keywords": [
-                "code",
-                "highlight",
-                "highlight.js",
-                "highlight.php",
-                "syntax"
-            ],
-            "support": {
-                "issues": "https://github.com/scrivo/highlight.php/issues",
-                "source": "https://github.com/scrivo/highlight.php"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/allejo",
-                    "type": "github"
-                }
-            ],
-            "time": "2021-07-09T00:30:39+00:00"
-        },
         {
             "name": "socialiteproviders/discord",
             "version": "4.1.1",
         },
         {
             "name": "symfony/console",
-            "version": "v4.4.29",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "8baf0bbcfddfde7d7225ae8e04705cfd1081cd7b"
+                "reference": "a3f7189a0665ee33b50e9e228c46f50f5acbed22"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/8baf0bbcfddfde7d7225ae8e04705cfd1081cd7b",
-                "reference": "8baf0bbcfddfde7d7225ae8e04705cfd1081cd7b",
+                "url": "https://api.github.com/repos/symfony/console/zipball/a3f7189a0665ee33b50e9e228c46f50f5acbed22",
+                "reference": "a3f7189a0665ee33b50e9e228c46f50f5acbed22",
                 "shasum": ""
             },
             "require": {
             "description": "Eases the creation of beautiful and testable command line interfaces",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/console/tree/v4.4.29"
+                "source": "https://github.com/symfony/console/tree/v4.4.30"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-27T19:04:53+00:00"
+            "time": "2021-08-25T19:27:26+00:00"
         },
         {
             "name": "symfony/css-selector",
-            "version": "v4.4.27",
+            "version": "v5.3.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/css-selector.git",
-                "reference": "5194f18bd80d106f11efa8f7cd0fbdcc3af96ce6"
+                "reference": "7fb120adc7f600a59027775b224c13a33530dd90"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/css-selector/zipball/5194f18bd80d106f11efa8f7cd0fbdcc3af96ce6",
-                "reference": "5194f18bd80d106f11efa8f7cd0fbdcc3af96ce6",
+                "url": "https://api.github.com/repos/symfony/css-selector/zipball/7fb120adc7f600a59027775b224c13a33530dd90",
+                "reference": "7fb120adc7f600a59027775b224c13a33530dd90",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3",
+                "php": ">=7.2.5",
                 "symfony/polyfill-php80": "^1.16"
             },
             "type": "library",
             "description": "Converts CSS selectors to XPath expressions",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/css-selector/tree/v4.4.27"
+                "source": "https://github.com/symfony/css-selector/tree/v5.3.4"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-21T12:19:41+00:00"
+            "time": "2021-07-21T12:38:00+00:00"
         },
         {
             "name": "symfony/debug",
-            "version": "v4.4.27",
+            "version": "v4.4.31",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/debug.git",
-                "reference": "2f9160e92eb64c95da7368c867b663a8e34e980c"
+                "reference": "43ede438d4cb52cd589ae5dc070e9323866ba8e0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/debug/zipball/2f9160e92eb64c95da7368c867b663a8e34e980c",
-                "reference": "2f9160e92eb64c95da7368c867b663a8e34e980c",
+                "url": "https://api.github.com/repos/symfony/debug/zipball/43ede438d4cb52cd589ae5dc070e9323866ba8e0",
+                "reference": "43ede438d4cb52cd589ae5dc070e9323866ba8e0",
                 "shasum": ""
             },
             "require": {
             "description": "Provides tools to ease debugging PHP code",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/debug/tree/v4.4.27"
+                "source": "https://github.com/symfony/debug/tree/v4.4.31"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-22T07:21:39+00:00"
+            "time": "2021-09-24T13:30:14+00:00"
         },
         {
             "name": "symfony/deprecation-contracts",
         },
         {
             "name": "symfony/error-handler",
-            "version": "v4.4.27",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/error-handler.git",
-                "reference": "16ac2be1c0f49d6d9eb9d3ce9324bde268717905"
+                "reference": "51f98f7aa99f00f3b1da6bafe934e67ae6ba6dc5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/error-handler/zipball/16ac2be1c0f49d6d9eb9d3ce9324bde268717905",
-                "reference": "16ac2be1c0f49d6d9eb9d3ce9324bde268717905",
+                "url": "https://api.github.com/repos/symfony/error-handler/zipball/51f98f7aa99f00f3b1da6bafe934e67ae6ba6dc5",
+                "reference": "51f98f7aa99f00f3b1da6bafe934e67ae6ba6dc5",
                 "shasum": ""
             },
             "require": {
             "description": "Provides tools to manage errors and ease debugging PHP code",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/error-handler/tree/v4.4.27"
+                "source": "https://github.com/symfony/error-handler/tree/v4.4.30"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-23T15:41:52+00:00"
+            "time": "2021-08-27T17:42:48+00:00"
         },
         {
             "name": "symfony/event-dispatcher",
-            "version": "v4.4.27",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "958a128b184fcf0ba45ec90c0e88554c9327c2e9"
+                "reference": "2fe81680070043c4c80e7cedceb797e34f377bac"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/958a128b184fcf0ba45ec90c0e88554c9327c2e9",
-                "reference": "958a128b184fcf0ba45ec90c0e88554c9327c2e9",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/2fe81680070043c4c80e7cedceb797e34f377bac",
+                "reference": "2fe81680070043c4c80e7cedceb797e34f377bac",
                 "shasum": ""
             },
             "require": {
             "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/event-dispatcher/tree/v4.4.27"
+                "source": "https://github.com/symfony/event-dispatcher/tree/v4.4.30"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-23T15:41:52+00:00"
+            "time": "2021-08-04T20:31:23+00:00"
         },
         {
             "name": "symfony/event-dispatcher-contracts",
         },
         {
             "name": "symfony/finder",
-            "version": "v4.4.27",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/finder.git",
-                "reference": "42414d7ac96fc2880a783b872185789dea0d4262"
+                "reference": "70362f1e112280d75b30087c7598b837c1b468b6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/42414d7ac96fc2880a783b872185789dea0d4262",
-                "reference": "42414d7ac96fc2880a783b872185789dea0d4262",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/70362f1e112280d75b30087c7598b837c1b468b6",
+                "reference": "70362f1e112280d75b30087c7598b837c1b468b6",
                 "shasum": ""
             },
             "require": {
             "description": "Finds files and directories via an intuitive fluent interface",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/finder/tree/v4.4.27"
+                "source": "https://github.com/symfony/finder/tree/v4.4.30"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-23T15:41:52+00:00"
+            "time": "2021-08-04T20:31:23+00:00"
         },
         {
             "name": "symfony/http-client-contracts",
         },
         {
             "name": "symfony/http-foundation",
-            "version": "v4.4.29",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-foundation.git",
-                "reference": "7016057b01f0ed3ec3ba1f31a580b6661667c2e1"
+                "reference": "09b3202651ab23ac8dcf455284a48a3500e56731"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7016057b01f0ed3ec3ba1f31a580b6661667c2e1",
-                "reference": "7016057b01f0ed3ec3ba1f31a580b6661667c2e1",
+                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/09b3202651ab23ac8dcf455284a48a3500e56731",
+                "reference": "09b3202651ab23ac8dcf455284a48a3500e56731",
                 "shasum": ""
             },
             "require": {
             "description": "Defines an object-oriented layer for the HTTP specification",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-foundation/tree/v4.4.29"
+                "source": "https://github.com/symfony/http-foundation/tree/v4.4.30"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-27T14:32:23+00:00"
+            "time": "2021-08-26T15:51:23+00:00"
         },
         {
             "name": "symfony/http-kernel",
-            "version": "v4.4.29",
+            "version": "v4.4.32",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-kernel.git",
-                "reference": "752b170e1ba0dd4104e7fa17c1cef1ec8a7fc506"
+                "reference": "f7bda3ea8f05ae90627400e58af5179b25ce0f38"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/752b170e1ba0dd4104e7fa17c1cef1ec8a7fc506",
-                "reference": "752b170e1ba0dd4104e7fa17c1cef1ec8a7fc506",
+                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f7bda3ea8f05ae90627400e58af5179b25ce0f38",
+                "reference": "f7bda3ea8f05ae90627400e58af5179b25ce0f38",
                 "shasum": ""
             },
             "require": {
                 "symfony/error-handler": "^4.4",
                 "symfony/event-dispatcher": "^4.4",
                 "symfony/http-client-contracts": "^1.1|^2",
-                "symfony/http-foundation": "^4.4|^5.0",
+                "symfony/http-foundation": "^4.4.30|^5.3.7",
                 "symfony/polyfill-ctype": "^1.8",
                 "symfony/polyfill-php73": "^1.9",
                 "symfony/polyfill-php80": "^1.16"
             "description": "Provides a structured process for converting a Request into a Response",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-kernel/tree/v4.4.29"
+                "source": "https://github.com/symfony/http-kernel/tree/v4.4.32"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-29T06:45:05+00:00"
+            "time": "2021-09-28T10:20:04+00:00"
         },
         {
             "name": "symfony/mime",
-            "version": "v5.3.4",
+            "version": "v5.3.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/mime.git",
-                "reference": "633e4e8afe9e529e5599d71238849a4218dd497b"
+                "reference": "a756033d0a7e53db389618653ae991eba5a19a11"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/mime/zipball/633e4e8afe9e529e5599d71238849a4218dd497b",
-                "reference": "633e4e8afe9e529e5599d71238849a4218dd497b",
+                "url": "https://api.github.com/repos/symfony/mime/zipball/a756033d0a7e53db389618653ae991eba5a19a11",
+                "reference": "a756033d0a7e53db389618653ae991eba5a19a11",
                 "shasum": ""
             },
             "require": {
                 "mime-type"
             ],
             "support": {
-                "source": "https://github.com/symfony/mime/tree/v5.3.4"
+                "source": "https://github.com/symfony/mime/tree/v5.3.8"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-21T12:40:44+00:00"
+            "time": "2021-09-10T12:30:38+00:00"
         },
         {
             "name": "symfony/polyfill-ctype",
         },
         {
             "name": "symfony/process",
-            "version": "v4.4.27",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/process.git",
-                "reference": "0b7dc5599ac4aa6d7b936c8f7d10abae64f6cf7f"
+                "reference": "13d3161ef63a8ec21eeccaaf9a4d7f784a87a97d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/0b7dc5599ac4aa6d7b936c8f7d10abae64f6cf7f",
-                "reference": "0b7dc5599ac4aa6d7b936c8f7d10abae64f6cf7f",
+                "url": "https://api.github.com/repos/symfony/process/zipball/13d3161ef63a8ec21eeccaaf9a4d7f784a87a97d",
+                "reference": "13d3161ef63a8ec21eeccaaf9a4d7f784a87a97d",
                 "shasum": ""
             },
             "require": {
             "description": "Executes commands in sub-processes",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/process/tree/v4.4.27"
+                "source": "https://github.com/symfony/process/tree/v4.4.30"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-23T15:41:52+00:00"
+            "time": "2021-08-04T20:31:23+00:00"
         },
         {
             "name": "symfony/routing",
-            "version": "v4.4.27",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/routing.git",
-                "reference": "244609821beece97167fa7ba4eef49d2a31862db"
+                "reference": "9ddf033927ad9f30ba2bfd167a7b342cafa13e8e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/routing/zipball/244609821beece97167fa7ba4eef49d2a31862db",
-                "reference": "244609821beece97167fa7ba4eef49d2a31862db",
+                "url": "https://api.github.com/repos/symfony/routing/zipball/9ddf033927ad9f30ba2bfd167a7b342cafa13e8e",
+                "reference": "9ddf033927ad9f30ba2bfd167a7b342cafa13e8e",
                 "shasum": ""
             },
             "require": {
                 "url"
             ],
             "support": {
-                "source": "https://github.com/symfony/routing/tree/v4.4.27"
+                "source": "https://github.com/symfony/routing/tree/v4.4.30"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-23T15:41:52+00:00"
+            "time": "2021-08-04T21:41:01+00:00"
         },
         {
             "name": "symfony/service-contracts",
         },
         {
             "name": "symfony/translation",
-            "version": "v4.4.27",
+            "version": "v4.4.32",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation.git",
-                "reference": "2e3c0f2bf704d635ba862e7198d72331a62d82ba"
+                "reference": "db0ba1e85280d8ff11e38d53c70f8814d4d740f5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation/zipball/2e3c0f2bf704d635ba862e7198d72331a62d82ba",
-                "reference": "2e3c0f2bf704d635ba862e7198d72331a62d82ba",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/db0ba1e85280d8ff11e38d53c70f8814d4d740f5",
+                "reference": "db0ba1e85280d8ff11e38d53c70f8814d4d740f5",
                 "shasum": ""
             },
             "require": {
             "description": "Provides tools to internationalize your application",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/translation/tree/v4.4.27"
+                "source": "https://github.com/symfony/translation/tree/v4.4.32"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-21T13:12:00+00:00"
+            "time": "2021-08-26T05:57:13+00:00"
         },
         {
             "name": "symfony/translation-contracts",
         },
         {
             "name": "symfony/var-dumper",
-            "version": "v4.4.27",
+            "version": "v4.4.31",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/var-dumper.git",
-                "reference": "391d6d0e7a06ab54eb7c38fab29b8d174471b3ba"
+                "reference": "1f12cc0c2e880a5f39575c19af81438464717839"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/391d6d0e7a06ab54eb7c38fab29b8d174471b3ba",
-                "reference": "391d6d0e7a06ab54eb7c38fab29b8d174471b3ba",
+                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/1f12cc0c2e880a5f39575c19af81438464717839",
+                "reference": "1f12cc0c2e880a5f39575c19af81438464717839",
                 "shasum": ""
             },
             "require": {
                 "dump"
             ],
             "support": {
-                "source": "https://github.com/symfony/var-dumper/tree/v4.4.27"
+                "source": "https://github.com/symfony/var-dumper/tree/v4.4.31"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-23T15:41:52+00:00"
+            "time": "2021-09-24T15:30:11+00:00"
         },
         {
             "name": "tijsverkoyen/css-to-inline-styles",
         },
         {
             "name": "vlucas/phpdotenv",
-            "version": "v3.6.8",
+            "version": "v3.6.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/vlucas/phpdotenv.git",
-                "reference": "5e679f7616db829358341e2d5cccbd18773bdab8"
+                "reference": "a1bf4c9853d90ade427b4efe35355fc41b3d6988"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/5e679f7616db829358341e2d5cccbd18773bdab8",
-                "reference": "5e679f7616db829358341e2d5cccbd18773bdab8",
+                "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a1bf4c9853d90ade427b4efe35355fc41b3d6988",
+                "reference": "a1bf4c9853d90ade427b4efe35355fc41b3d6988",
                 "shasum": ""
             },
             "require": {
             "require-dev": {
                 "ext-filter": "*",
                 "ext-pcre": "*",
-                "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20"
+                "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.21"
             },
             "suggest": {
                 "ext-filter": "Required to use the boolean validator.",
             "authors": [
                 {
                     "name": "Graham Campbell",
-                    "email": "[email protected]",
-                    "homepage": "https://gjcampbell.co.uk/"
+                    "email": "[email protected]"
                 },
                 {
                     "name": "Vance Lucas",
-                    "email": "[email protected]",
-                    "homepage": "https://vancelucas.com/"
+                    "email": "[email protected]"
                 }
             ],
             "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.",
             ],
             "support": {
                 "issues": "https://github.com/vlucas/phpdotenv/issues",
-                "source": "https://github.com/vlucas/phpdotenv/tree/v3.6.8"
+                "source": "https://github.com/vlucas/phpdotenv/tree/v3.6.9"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-20T14:39:46+00:00"
+            "time": "2021-10-02T19:07:56+00:00"
         }
     ],
     "packages-dev": [
         },
         {
             "name": "composer/ca-bundle",
-            "version": "1.2.10",
+            "version": "1.2.11",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/ca-bundle.git",
-                "reference": "9fdb22c2e97a614657716178093cd1da90a64aa8"
+                "reference": "0b072d51c5a9c6f3412f7ea3ab043d6603cb2582"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/ca-bundle/zipball/9fdb22c2e97a614657716178093cd1da90a64aa8",
-                "reference": "9fdb22c2e97a614657716178093cd1da90a64aa8",
+                "url": "https://api.github.com/repos/composer/ca-bundle/zipball/0b072d51c5a9c6f3412f7ea3ab043d6603cb2582",
+                "reference": "0b072d51c5a9c6f3412f7ea3ab043d6603cb2582",
                 "shasum": ""
             },
             "require": {
                 "phpstan/phpstan": "^0.12.55",
                 "psr/log": "^1.0",
                 "symfony/phpunit-bridge": "^4.2 || ^5",
-                "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0"
+                "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0 || ^6.0"
             },
             "type": "library",
             "extra": {
             "support": {
                 "irc": "irc://irc.freenode.org/composer",
                 "issues": "https://github.com/composer/ca-bundle/issues",
-                "source": "https://github.com/composer/ca-bundle/tree/1.2.10"
+                "source": "https://github.com/composer/ca-bundle/tree/1.2.11"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-06-07T13:58:28+00:00"
+            "time": "2021-09-25T20:32:43+00:00"
         },
         {
             "name": "composer/composer",
-            "version": "2.1.6",
+            "version": "2.1.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/composer.git",
-                "reference": "e5cac5f9d2354d08b67f1d21c664ae70d748c603"
+                "reference": "e558c88f28d102d497adec4852802c0dc14c7077"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/composer/zipball/e5cac5f9d2354d08b67f1d21c664ae70d748c603",
-                "reference": "e5cac5f9d2354d08b67f1d21c664ae70d748c603",
+                "url": "https://api.github.com/repos/composer/composer/zipball/e558c88f28d102d497adec4852802c0dc14c7077",
+                "reference": "e558c88f28d102d497adec4852802c0dc14c7077",
                 "shasum": ""
             },
             "require": {
             "support": {
                 "irc": "ircs://irc.libera.chat:6697/composer",
                 "issues": "https://github.com/composer/composer/issues",
-                "source": "https://github.com/composer/composer/tree/2.1.6"
+                "source": "https://github.com/composer/composer/tree/2.1.9"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-08-19T15:11:08+00:00"
+            "time": "2021-10-05T07:47:38+00:00"
         },
         {
             "name": "composer/metadata-minifier",
         },
         {
             "name": "fakerphp/faker",
-            "version": "v1.15.0",
+            "version": "v1.16.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/FakerPHP/Faker.git",
-                "reference": "89c6201c74db25fa759ff16e78a4d8f32547770e"
+                "reference": "271d384d216e5e5c468a6b28feedf95d49f83b35"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/89c6201c74db25fa759ff16e78a4d8f32547770e",
-                "reference": "89c6201c74db25fa759ff16e78a4d8f32547770e",
+                "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/271d384d216e5e5c468a6b28feedf95d49f83b35",
+                "reference": "271d384d216e5e5c468a6b28feedf95d49f83b35",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.1 || ^8.0",
-                "psr/container": "^1.0",
+                "psr/container": "^1.0 || ^2.0",
                 "symfony/deprecation-contracts": "^2.2"
             },
             "conflict": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "v1.15-dev"
+                    "dev-main": "v1.16-dev"
                 }
             },
             "autoload": {
             ],
             "support": {
                 "issues": "https://github.com/FakerPHP/Faker/issues",
-                "source": "https://github.com/FakerPHP/Faker/tree/v1.15.0"
+                "source": "https://github.com/FakerPHP/Faker/tree/v1.16.0"
             },
-            "time": "2021-07-06T20:39:40+00:00"
+            "time": "2021-09-06T14:53:37+00:00"
         },
         {
             "name": "hamcrest/hamcrest-php",
             },
             "time": "2021-07-22T09:24:00+00:00"
         },
-        {
-            "name": "laravel/browser-kit-testing",
-            "version": "v5.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/laravel/browser-kit-testing.git",
-                "reference": "fa0efb279c009e2a276f934f8aff946caf66edc7"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/laravel/browser-kit-testing/zipball/fa0efb279c009e2a276f934f8aff946caf66edc7",
-                "reference": "fa0efb279c009e2a276f934f8aff946caf66edc7",
-                "shasum": ""
-            },
-            "require": {
-                "ext-dom": "*",
-                "ext-json": "*",
-                "illuminate/contracts": "~5.7.0|~5.8.0|^6.0",
-                "illuminate/database": "~5.7.0|~5.8.0|^6.0",
-                "illuminate/http": "~5.7.0|~5.8.0|^6.0",
-                "illuminate/support": "~5.7.0|~5.8.0|^6.0",
-                "mockery/mockery": "^1.0",
-                "php": "^7.1.3|^8.0",
-                "phpunit/phpunit": "^7.5|^8.0|^9.3",
-                "symfony/console": "^4.2",
-                "symfony/css-selector": "^4.2",
-                "symfony/dom-crawler": "^4.2",
-                "symfony/http-foundation": "^4.2",
-                "symfony/http-kernel": "^4.2"
-            },
-            "require-dev": {
-                "laravel/framework": "~5.7.0|~5.8.0|^6.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "5.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Laravel\\BrowserKitTesting\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Taylor Otwell",
-                    "email": "[email protected]"
-                }
-            ],
-            "description": "Provides backwards compatibility for BrowserKit testing in the latest Laravel release.",
-            "keywords": [
-                "laravel",
-                "testing"
-            ],
-            "support": {
-                "issues": "https://github.com/laravel/browser-kit-testing/issues",
-                "source": "https://github.com/laravel/browser-kit-testing/tree/v5.2.0"
-            },
-            "time": "2020-10-30T08:49:09+00:00"
-        },
         {
             "name": "maximebf/debugbar",
             "version": "v1.17.1",
         },
         {
             "name": "mockery/mockery",
-            "version": "1.4.3",
+            "version": "1.4.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/mockery/mockery.git",
-                "reference": "d1339f64479af1bee0e82a0413813fe5345a54ea"
+                "reference": "e01123a0e847d52d186c5eb4b9bf58b0c6d00346"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/mockery/mockery/zipball/d1339f64479af1bee0e82a0413813fe5345a54ea",
-                "reference": "d1339f64479af1bee0e82a0413813fe5345a54ea",
+                "url": "https://api.github.com/repos/mockery/mockery/zipball/e01123a0e847d52d186c5eb4b9bf58b0c6d00346",
+                "reference": "e01123a0e847d52d186c5eb4b9bf58b0c6d00346",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/mockery/mockery/issues",
-                "source": "https://github.com/mockery/mockery/tree/1.4.3"
+                "source": "https://github.com/mockery/mockery/tree/1.4.4"
             },
-            "time": "2021-02-24T09:51:49+00:00"
+            "time": "2021-09-13T15:28:59+00:00"
         },
         {
             "name": "myclabs/deep-copy",
         },
         {
             "name": "nikic/php-parser",
-            "version": "v4.12.0",
+            "version": "v4.13.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nikic/PHP-Parser.git",
-                "reference": "6608f01670c3cc5079e18c1dab1104e002579143"
+                "reference": "50953a2691a922aa1769461637869a0a2faa3f53"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/6608f01670c3cc5079e18c1dab1104e002579143",
-                "reference": "6608f01670c3cc5079e18c1dab1104e002579143",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/50953a2691a922aa1769461637869a0a2faa3f53",
+                "reference": "50953a2691a922aa1769461637869a0a2faa3f53",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/nikic/PHP-Parser/issues",
-                "source": "https://github.com/nikic/PHP-Parser/tree/v4.12.0"
+                "source": "https://github.com/nikic/PHP-Parser/tree/v4.13.0"
             },
-            "time": "2021-07-21T10:44:31+00:00"
+            "time": "2021-09-20T12:20:58+00:00"
         },
         {
             "name": "phar-io/manifest",
         },
         {
             "name": "phpdocumentor/type-resolver",
-            "version": "1.4.0",
+            "version": "1.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/TypeResolver.git",
-                "reference": "6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0"
+                "reference": "a12f7e301eb7258bb68acd89d4aefa05c2906cae"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0",
-                "reference": "6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0",
+                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/a12f7e301eb7258bb68acd89d4aefa05c2906cae",
+                "reference": "a12f7e301eb7258bb68acd89d4aefa05c2906cae",
                 "shasum": ""
             },
             "require": {
                 "phpdocumentor/reflection-common": "^2.0"
             },
             "require-dev": {
-                "ext-tokenizer": "*"
+                "ext-tokenizer": "*",
+                "psalm/phar": "^4.8"
             },
             "type": "library",
             "extra": {
             "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
             "support": {
                 "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
-                "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.4.0"
+                "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.5.1"
             },
-            "time": "2020-09-17T18:55:26+00:00"
+            "time": "2021-10-02T14:08:47+00:00"
         },
         {
             "name": "phpspec/prophecy",
-            "version": "1.13.0",
+            "version": "1.14.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpspec/prophecy.git",
-                "reference": "be1996ed8adc35c3fd795488a653f4b518be70ea"
+                "reference": "d86dfc2e2a3cd366cee475e52c6bb3bbc371aa0e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/be1996ed8adc35c3fd795488a653f4b518be70ea",
-                "reference": "be1996ed8adc35c3fd795488a653f4b518be70ea",
+                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/d86dfc2e2a3cd366cee475e52c6bb3bbc371aa0e",
+                "reference": "d86dfc2e2a3cd366cee475e52c6bb3bbc371aa0e",
                 "shasum": ""
             },
             "require": {
                 "doctrine/instantiator": "^1.2",
-                "php": "^7.2 || ~8.0, <8.1",
+                "php": "^7.2 || ~8.0, <8.2",
                 "phpdocumentor/reflection-docblock": "^5.2",
                 "sebastian/comparator": "^3.0 || ^4.0",
                 "sebastian/recursion-context": "^3.0 || ^4.0"
             },
             "require-dev": {
-                "phpspec/phpspec": "^6.0",
+                "phpspec/phpspec": "^6.0 || ^7.0",
                 "phpunit/phpunit": "^8.0 || ^9.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.11.x-dev"
+                    "dev-master": "1.x-dev"
                 }
             },
             "autoload": {
             ],
             "support": {
                 "issues": "https://github.com/phpspec/prophecy/issues",
-                "source": "https://github.com/phpspec/prophecy/tree/1.13.0"
+                "source": "https://github.com/phpspec/prophecy/tree/1.14.0"
             },
-            "time": "2021-03-17T13:42:18+00:00"
+            "time": "2021-09-10T09:02:12+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
-            "version": "9.2.6",
+            "version": "9.2.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
-                "reference": "f6293e1b30a2354e8428e004689671b83871edde"
+                "reference": "d4c798ed8d51506800b441f7a13ecb0f76f12218"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f6293e1b30a2354e8428e004689671b83871edde",
-                "reference": "f6293e1b30a2354e8428e004689671b83871edde",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d4c798ed8d51506800b441f7a13ecb0f76f12218",
+                "reference": "d4c798ed8d51506800b441f7a13ecb0f76f12218",
                 "shasum": ""
             },
             "require": {
                 "ext-dom": "*",
                 "ext-libxml": "*",
                 "ext-xmlwriter": "*",
-                "nikic/php-parser": "^4.10.2",
+                "nikic/php-parser": "^4.12.0",
                 "php": ">=7.3",
                 "phpunit/php-file-iterator": "^3.0.3",
                 "phpunit/php-text-template": "^2.0.2",
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
-                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.6"
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.7"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2021-03-28T07:26:59+00:00"
+            "time": "2021-09-17T05:39:03+00:00"
         },
         {
             "name": "phpunit/php-file-iterator",
         },
         {
             "name": "phpunit/phpunit",
-            "version": "9.5.8",
+            "version": "9.5.10",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "191768ccd5c85513b4068bdbe99bb6390c7d54fb"
+                "reference": "c814a05837f2edb0d1471d6e3f4ab3501ca3899a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/191768ccd5c85513b4068bdbe99bb6390c7d54fb",
-                "reference": "191768ccd5c85513b4068bdbe99bb6390c7d54fb",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c814a05837f2edb0d1471d6e3f4ab3501ca3899a",
+                "reference": "c814a05837f2edb0d1471d6e3f4ab3501ca3899a",
                 "shasum": ""
             },
             "require": {
                 "phar-io/version": "^3.0.2",
                 "php": ">=7.3",
                 "phpspec/prophecy": "^1.12.1",
-                "phpunit/php-code-coverage": "^9.2.3",
+                "phpunit/php-code-coverage": "^9.2.7",
                 "phpunit/php-file-iterator": "^3.0.5",
                 "phpunit/php-invoker": "^3.1.1",
                 "phpunit/php-text-template": "^2.0.3",
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.8"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.10"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2021-07-31T15:17:34+00:00"
+            "time": "2021-09-25T07:38:51+00:00"
         },
         {
             "name": "react/promise",
                     "type": "github"
                 }
             ],
+            "abandoned": true,
             "time": "2020-09-28T06:45:17+00:00"
         },
         {
         },
         {
             "name": "symfony/dom-crawler",
-            "version": "v4.4.27",
+            "version": "v5.3.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dom-crawler.git",
-                "reference": "86aa075c9e0b13ac7db8d73d1f9d8b656143881a"
+                "reference": "c7eef3a60ccfdd8eafe07f81652e769ac9c7146c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/86aa075c9e0b13ac7db8d73d1f9d8b656143881a",
-                "reference": "86aa075c9e0b13ac7db8d73d1f9d8b656143881a",
+                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/c7eef3a60ccfdd8eafe07f81652e769ac9c7146c",
+                "reference": "c7eef3a60ccfdd8eafe07f81652e769ac9c7146c",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3",
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1",
                 "symfony/polyfill-ctype": "~1.8",
                 "symfony/polyfill-mbstring": "~1.0",
                 "symfony/polyfill-php80": "^1.16"
             },
             "require-dev": {
                 "masterminds/html5": "^2.6",
-                "symfony/css-selector": "^3.4|^4.0|^5.0"
+                "symfony/css-selector": "^4.4|^5.0"
             },
             "suggest": {
                 "symfony/css-selector": ""
             "description": "Eases DOM navigation for HTML and XML documents",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/dom-crawler/tree/v4.4.27"
+                "source": "https://github.com/symfony/dom-crawler/tree/v5.3.7"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-07-23T15:41:52+00:00"
+            "time": "2021-08-29T19:32:13+00:00"
         },
         {
             "name": "symfony/filesystem",
index 5b43c7d549de1265fd5fee0eea91f4fd5b499ebd..8c3d9124c75e4067b9041288b3eb11ac856a32c4 100644 (file)
@@ -2,6 +2,7 @@
 
 use Illuminate\Database\Migrations\Migration;
 use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Str;
 
 class CreateJointPermissionsTable extends Migration
 {
@@ -53,7 +54,7 @@ class CreateJointPermissionsTable extends Migration
 
         // Ensure unique name
         while (DB::table('roles')->where('name', '=', $publicRoleData['display_name'])->count() > 0) {
-            $publicRoleData['display_name'] = $publicRoleData['display_name'] . str_random(2);
+            $publicRoleData['display_name'] = $publicRoleData['display_name'] . Str::random(2);
         }
         $publicRoleId = DB::table('roles')->insertGetId($publicRoleData);
 
diff --git a/database/migrations/2021_09_26_044614_add_activities_ip_column.php b/database/migrations/2021_09_26_044614_add_activities_ip_column.php
new file mode 100644 (file)
index 0000000..68391b1
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddActivitiesIpColumn extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('activities', function (Blueprint $table) {
+            $table->string('ip', 45)->after('user_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('activities', function (Blueprint $table) {
+            $table->dropColumn('ip');
+        });
+    }
+}
diff --git a/dev/api/requests/attachments-create.json b/dev/api/requests/attachments-create.json
new file mode 100644 (file)
index 0000000..8ed34b2
--- /dev/null
@@ -0,0 +1,5 @@
+{
+  "name": "My uploaded attachment",
+  "uploaded_to": 8,
+  "link": "https://link.example.com"
+}
\ No newline at end of file
diff --git a/dev/api/requests/attachments-update.json b/dev/api/requests/attachments-update.json
new file mode 100644 (file)
index 0000000..062050b
--- /dev/null
@@ -0,0 +1,5 @@
+{
+  "name": "My updated attachment",
+  "uploaded_to": 4,
+  "link": "https://link.example.com/updated"
+}
\ No newline at end of file
diff --git a/dev/api/responses/attachments-create.json b/dev/api/responses/attachments-create.json
new file mode 100644 (file)
index 0000000..5af524e
--- /dev/null
@@ -0,0 +1,12 @@
+{
+  "id": 5,
+  "name": "My uploaded attachment",
+  "extension": "",
+  "uploaded_to": 8,
+  "external": true,
+  "order": 2,
+  "created_by": 1,
+  "updated_by": 1,
+  "created_at": "2021-10-20 06:35:46",
+  "updated_at": "2021-10-20 06:35:46"
+}
\ No newline at end of file
diff --git a/dev/api/responses/attachments-list.json b/dev/api/responses/attachments-list.json
new file mode 100644 (file)
index 0000000..946dd54
--- /dev/null
@@ -0,0 +1,29 @@
+{
+  "data": [
+    {
+      "id": 3,
+      "name": "datasheet.pdf",
+      "extension": "pdf",
+      "uploaded_to": 8,
+      "external": false,
+      "order": 1,
+      "created_at": "2021-10-11 06:18:49",
+      "updated_at": "2021-10-20 06:31:10",
+      "created_by": 1,
+      "updated_by": 1
+    },
+    {
+      "id": 4,
+      "name": "Cat reference",
+      "extension": "",
+      "uploaded_to": 9,
+      "external": true,
+      "order": 1,
+      "created_at": "2021-10-20 06:30:11",
+      "updated_at": "2021-10-20 06:30:11",
+      "created_by": 1,
+      "updated_by": 1
+    }
+  ],
+  "total": 2
+}
\ No newline at end of file
diff --git a/dev/api/responses/attachments-read.json b/dev/api/responses/attachments-read.json
new file mode 100644 (file)
index 0000000..e22f4e5
--- /dev/null
@@ -0,0 +1,25 @@
+{
+  "id": 5,
+  "name": "My link attachment",
+  "extension": "",
+  "uploaded_to": 4,
+  "external": true,
+  "order": 2,
+  "created_by": {
+    "id": 1,
+    "name": "Admin",
+    "slug": "admin"
+  },
+  "updated_by": {
+    "id": 1,
+    "name": "Admin",
+    "slug": "admin"
+  },
+  "created_at": "2021-10-20 06:35:46",
+  "updated_at": "2021-10-20 06:37:11",
+  "links": {
+    "html": "<a target=\"_blank\" href=\"https://bookstack.local/attachments/5\">My updated attachment</a>",
+    "markdown": "[My updated attachment](https://bookstack.local/attachments/5)"
+  },
+  "content": "https://link.example.com/updated"
+}
\ No newline at end of file
diff --git a/dev/api/responses/attachments-update.json b/dev/api/responses/attachments-update.json
new file mode 100644 (file)
index 0000000..8054b0e
--- /dev/null
@@ -0,0 +1,12 @@
+{
+  "id": 5,
+  "name": "My updated attachment",
+  "extension": "",
+  "uploaded_to": 4,
+  "external": true,
+  "order": 2,
+  "created_by": 1,
+  "updated_by": 1,
+  "created_at": "2021-10-20 06:35:46",
+  "updated_at": "2021-10-20 06:37:11"
+}
\ No newline at end of file
index 75c89ec335fb8cb1e7e3b42d5a483edf0b77dd25..7e0da05d42fb33a8a401af585609c53ee1af6659 100644 (file)
@@ -37,6 +37,7 @@
     <server name="LOG_CHANNEL" value="single"/>
     <server name="AUTH_METHOD" value="standard"/>
     <server name="DISABLE_EXTERNAL_SERVICES" value="true"/>
+    <server name="ALLOW_UNTRUSTED_SERVER_FETCHING" value="false"/>
     <server name="AVATAR_URL" value=""/>
     <server name="LDAP_START_TLS" value="false"/>
     <server name="LDAP_VERSION" value="3"/>
index cb17a1aae478819e7c92ad1b21df36d2a0ae6183..1ab54de6e51c13f8fd5dec4f63210fac0da5de7c 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -14,17 +14,18 @@ A platform for storing and organising information and documentation. Details for
 * [Documentation](https://www.bookstackapp.com/docs)
 * [Demo Instance](https://demo.bookstackapp.com)
     * [Admin Login](https://demo.bookstackapp.com/[email protected]&password=password)
+* [Screenshots](https://www.bookstackapp.com/#screenshots) 
 * [BookStack Blog](https://www.bookstackapp.com/blog)
 * [Issue List](https://github.com/BookStackApp/BookStack/issues)
 * [Discord Chat](https://discord.gg/ztkBqR2)
 
 ## 📚 Project Definition
 
-BookStack is an opinionated wiki system that provides a pleasant and simple out of the box experience. New users to an instance should find the experience intuitive and only basic word-processing skills should be required to get involved in creating content on BookStack. The platform should provide advanced power features to those that desire it but they should not interfere with the core simple user experience.
+BookStack is an opinionated wiki system that provides a pleasant and simple out-of-the-box experience. New users to an instance should find the experience intuitive and only basic word-processing skills should be required to get involved in creating content on BookStack. The platform should provide advanced power features to those that desire it but they should not interfere with the core simple user experience.
 
 BookStack is not designed as an extensible platform to be used for purposes that differ to the statement above.
 
-In regards to development philosophy, BookStack has a relaxed, open & positive approach. At the end of the day this is free software developed and maintained by people donating their own free time.
+In regard to development philosophy, BookStack has a relaxed, open & positive approach. At the end of the day this is free software developed and maintained by people donating their own free time.
 
 ## 🛣️ Road Map
 
@@ -41,17 +42,23 @@ Below is a high-level road map view for BookStack to provide a sense of directio
 
 ## 🚀 Release Versioning & Process
 
-BookStack releases are each assigned a version number, such as "v0.25.2", in the format `v<phase>.<feature>.<patch>`. A change only in the `patch` number indicates a fairly minor release that mainly contains fixes and therefore is very unlikely to cause breakages upon update. A change in the `feature` number indicates a release which will generally bring new features in addition to fixes and enhancements. These releases have a small chance of introducing breaking changes upon update so it's worth checking for any notes in the [update guide](https://www.bookstackapp.com/docs/admin/updates/). A change in the `phase` indicates a much large change in BookStack that will likely incur breakages requiring manual intervention.
+BookStack releases are each assigned a date-based version number in the format `v<year>.<month>[.<optional_patch_number>]`. For example:
+
+- `v20.12` - New feature released launched during December 2020. 
+- `v21.06.2` - Second patch release upon the June 2021 feature release.
+
+Patch releases are generally fairly minor, primarily intended for fixes and therefore is fairly unlikely to cause breakages upon update.
+Feature releases are generally larger, bringing new features in addition to fixes and enhancements. These releases have a greater chance of introducing breaking changes upon update, so it's worth checking for any notes in the [update guide](https://www.bookstackapp.com/docs/admin/updates/).
 
 Each BookStack release will have a [milestone](https://github.com/BookStackApp/BookStack/milestones) created with issues & pull requests assigned to it to define what will be in that release. Milestones are built up then worked through until complete at which point, after some testing and documentation updates, the release will be deployed.
 
-For feature releases, and some patch releases, the release will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blogs posts (once per week maximum) [at this link](https://updates.bookstackapp.com/signup/bookstack-news-and-updates).
+Feature releases, and some patch releases, will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blogs posts (once per week maximum) [at this link](https://updates.bookstackapp.com/signup/bookstack-news-and-updates).
 
 ## 🛠️ Development & Testing
 
 All development on BookStack is currently done on the master branch. When it's time for a release the master branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
 
-* [Node.js](https://nodejs.org/en/) v12.0+
+* [Node.js](https://nodejs.org/en/) v14.0+
 
 This project uses SASS for CSS development and this is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks:
 
@@ -192,4 +199,5 @@ These are the great open-source projects used to help build BookStack:
 * [League/Flysystem](https://flysystem.thephpleague.com)
 * [StyleCI](https://styleci.io/)
 * [pragmarx/google2fa](https://github.com/antonioribeiro/google2fa)
-* [Bacon/BaconQrCode](https://github.com/Bacon/BaconQrCode)
\ No newline at end of file
+* [Bacon/BaconQrCode](https://github.com/Bacon/BaconQrCode)
+* [phpseclib](https://github.com/phpseclib/phpseclib)
\ No newline at end of file
diff --git a/resources/icons/oidc.svg b/resources/icons/oidc.svg
new file mode 100644 (file)
index 0000000..a9a2994
--- /dev/null
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
+</svg>
\ No newline at end of file
index f66e23b19923d2814b12ea5e2da844892c563557..5f35e64992c50b793bdebf33edcb46a634fea4b9 100644 (file)
@@ -40,6 +40,7 @@ class PageEditor {
             frequency: 30000,
             last: 0,
         };
+        this.shownWarningsCache = new Set();
 
         if (this.pageId !== 0 && this.draftsEnabled) {
             window.setTimeout(() => {
@@ -119,6 +120,10 @@ class PageEditor {
             }
             this.draftNotifyChange(`${resp.data.message} ${Dates.utcTimeStampToLocalTime(resp.data.timestamp)}`);
             this.autoSave.last = Date.now();
+            if (resp.data.warning && !this.shownWarningsCache.has(resp.data.warning)) {
+                window.$events.emit('warning', resp.data.warning);
+                this.shownWarningsCache.add(resp.data.warning);
+            }
         } catch (err) {
             // Save the editor content in LocalStorage as a last resort, just in case.
             try {
index 0648758e6bb42261027d353b6aaa1204a0704417..c9e3f322c9623c5d6a2ab65e7fa9a638747dad92 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'أذونات رف الكتب',
     'shelves_permissions_updated' => 'تم تحديث أذونات رف الكتب',
     'shelves_permissions_active' => 'أذونات رف الكتب نشطة',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'نسخ أذونات الوصول إلى الكتب',
     'shelves_copy_permissions' => 'نسخ الأذونات',
     'shelves_copy_permissions_explain' => 'سيؤدي هذا إلى تطبيق إعدادات الأذونات الحالية لهذا الرف على جميع الكتب المتضمنة فيه. قبل التفعيل، تأكد من حفظ أي تغييرات في أذونات هذا الرف.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'صفحة جديدة',
     'pages_editing_draft_notification' => 'جارٍ تعديل مسودة لم يتم حفظها من :timeDiff.',
     'pages_draft_edited_notification' => 'تم تحديث هذه الصفحة منذ ذلك الوقت. من الأفضل التخلص من هذه المسودة.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count من المستخدمين بدأوا بتعديل هذه الصفحة',
         'start_b' => ':userName بدأ بتعديل هذه الصفحة',
index 01a199179989357fdcc58103e2832d76e050ab10..2ceb849bc256e1b1a3be4dae8d17bfecf58ed425 100755 (executable)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'المستخدم',
     'audit_table_event' => 'الحدث',
     'audit_table_related' => 'العنصر أو التفاصيل ذات الصلة',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'تاريخ النشاط',
     'audit_date_from' => 'نطاق التاريخ من',
     'audit_date_to' => 'نطاق التاريخ إلى',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'إدارة قوالب الصفحة',
     'role_access_api' => 'الوصول إلى واجهة برمجة تطبيقات النظام API',
     'role_manage_settings' => 'إدارة إعدادات التطبيق',
+    'role_export_content' => 'Export content',
     'role_asset' => 'أذونات الأصول',
     'roles_system_warning' => 'اعلم أن الوصول إلى أي من الأذونات الثلاثة المذكورة أعلاه يمكن أن يسمح للمستخدم بتغيير امتيازاته الخاصة أو امتيازات الآخرين في النظام. قم بتعيين الأدوار مع هذه الأذونات فقط للمستخدمين الموثوق بهم.',
     'role_asset_desc' => 'تتحكم هذه الأذونات في الوصول الافتراضي إلى الأصول داخل النظام. ستتجاوز الأذونات الخاصة بالكتب والفصول والصفحات هذه الأذونات.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index fc04feec5c518804c306c79c2a492da87fc73969..bfcfb27589fb5422b586b0139a738bf67aed6e4e 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Настройки за достъп до рафта с книги',
     'shelves_permissions_updated' => 'Настройките за достъп до рафта с книги е обновен',
     'shelves_permissions_active' => 'Настройките за достъп до рафта с книги е активен',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Копирай настойките за достъп към книгите',
     'shelves_copy_permissions' => 'Копирай настройките за достъп',
     'shelves_copy_permissions_explain' => 'Това ще приложи настоящите настройки за достъп на този рафт с книги за всички книги, съдържащи се в него. Преди да активирате, уверете се, че всички промени в настройките за достъп на този рафт са запазени.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Нова страница',
     'pages_editing_draft_notification' => 'В момента редактирате чернова, която беше последно обновена :timeDiff.',
     'pages_draft_edited_notification' => 'Тази страница беше актуализирана от тогава. Препоръчително е да изтриете настоящата чернова.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count потребителя започнаха да редактират настоящата страница',
         'start_b' => ':userName в момента редактира тази страница',
index 928ed8672ca98956d2d477d2a3290406d2fd84fc..5c1e1c9033d5bcf2ed6a725287645045f668e9d3 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Потребител',
     'audit_table_event' => 'Събитие',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Дата на активност',
     'audit_date_from' => 'Време от',
     'audit_date_to' => 'Време до',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Управление на шаблони на страници',
     'role_access_api' => 'Достъп до API на системата',
     'role_manage_settings' => 'Управление на настройките на приложението',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Настройки за достъп до активи',
     'roles_system_warning' => 'Важно: Добавянето на потребител в някое от горните три роли може да му позволи да промени собствените си права или правата на другите в системата. Възлагайте тези роли само на доверени потребители.',
     'role_asset_desc' => 'Тези настройки за достъп контролират достъпа по подразбиране до активите в системата. Настройките за достъп до книги, глави и страници ще отменят тези настройки.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index aaf9fb1921d8ab8e89aeb7b2ecd351cddfedcb45..102019d2a011125a1b8ed502acce22e9444f6a64 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Bookshelf Permissions',
     'shelves_permissions_updated' => 'Bookshelf Permissions Updated',
     'shelves_permissions_active' => 'Bookshelf Permissions Active',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
     'shelves_copy_permissions' => 'Copy Permissions',
     'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Nova stranica',
     'pages_editing_draft_notification' => 'Trenutno uređujete skicu koja je posljednji put snimljena :timeDiff.',
     'pages_draft_edited_notification' => 'Ova stranica je ažurirana nakon tog vremena. Preporučujemo da odbacite ovu skicu.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count korisnika je počelo sa uređivanjem ove stranice',
         'start_b' => ':userName je počeo/la sa uređivanjem ove stranice',
index 87f672e4d41937793dc71a4dd1be5ef56317e83e..0ab168b66998bca0de4807f94d181b9b12ed1683 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'User',
     'audit_table_event' => 'Event',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Activity Date',
     'audit_date_from' => 'Date Range From',
     'audit_date_to' => 'Date Range To',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Manage page templates',
     'role_access_api' => 'Access system API',
     'role_manage_settings' => 'Manage app settings',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Asset Permissions',
     'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
     'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 59003eac09062e1d398d157086060cc3ee76b49e..36466e920e180bdec0e609f4019751c813881a5e 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Permisos del prestatge',
     'shelves_permissions_updated' => 'S\'han actualitzat els permisos del prestatge',
     'shelves_permissions_active' => 'S\'han activat els permisos del prestatge',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Copia els permisos als llibres',
     'shelves_copy_permissions' => 'Copia els permisos',
     'shelves_copy_permissions_explain' => 'Això aplicarà la configuració de permisos actual d\'aquest prestatge a tots els llibres que contingui. Abans d\'activar-ho, assegureu-vos que hàgiu desat qualsevol canvi als permisos d\'aquest prestatge.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Pàgina nova',
     'pages_editing_draft_notification' => 'Esteu editant un esborrany que es va desar per darrer cop :timeDiff.',
     'pages_draft_edited_notification' => 'Aquesta pàgina s\'ha actualitzat d\'ençà d\'aleshores. Us recomanem que descarteu aquest esborrany.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count usuaris han començat a editar aquesta pàgina',
         'start_b' => ':userName ha començat a editar aquesta pàgina',
index 69180df2c32762b4b8390d63b0e85ede26579fb2..3a3fdddc1aff5a60194dda551858821c8a3b565e 100755 (executable)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Usuari',
     'audit_table_event' => 'Esdeveniment',
     'audit_table_related' => 'Element relacionat o detall',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Data de l\'activitat',
     'audit_date_from' => 'Rang de dates a partir de',
     'audit_date_to' => 'Rang de rates fins a',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Gestiona les plantilles de pàgines',
     'role_access_api' => 'Accedeix a l\'API del sistema',
     'role_manage_settings' => 'Gestiona la configuració de l\'aplicació',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Permisos de recursos',
     'roles_system_warning' => 'Tingueu en compte que l\'accés a qualsevol dels tres permisos de dalt pot permetre que un usuari alteri els seus propis permisos o els privilegis d\'altres usuaris del sistema. Assigneu rols amb aquests permisos només a usuaris de confiança.',
     'role_asset_desc' => 'Aquests permisos controlen l\'accés per defecte als recursos del sistema. Els permisos de llibres, capítols i pàgines tindran més importància que aquests permisos.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 7178147025868db5a2878b1240c84f793e261da8..0b244730abbd31689ee9c18f73ab9adfa5664284 100644 (file)
@@ -48,8 +48,8 @@ return [
     'favourite_remove_notification' => '":name" byla odstraněna z Vašich oblíbených',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => 'Vícefaktorová metoda byla úspěšně nakonfigurována',
+    'mfa_remove_method_notification' => 'Vícefaktorová metoda byla úspěšně odstraněna',
 
     // Other
     'commented_on'                => 'okomentoval/a',
index f8cdb77479d3da618dec296cb5eb484c0d4b73db..ad83a692603972e7d401725b3ae77977f8602e50 100644 (file)
@@ -83,16 +83,16 @@ return [
     'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
     'mfa_setup_action' => 'Setup',
     'mfa_backup_codes_usage_limit_warning' => '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.',
-    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_title' => 'Mobilní aplikace',
     'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
     'mfa_option_backup_codes_title' => 'Backup Codes',
     'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
-    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_confirm_and_enable' => 'Potvrdit a povolit',
     'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
     'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
     'mfa_gen_backup_codes_download' => 'Download Codes',
     'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
-    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_title' => 'Nastavení mobilní aplikace',
     'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
     'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
     'mfa_gen_totp_verify_setup' => 'Verify Setup',
index 88821ecf9b9616962296d003f873e137cccc49fa..14555a91241911ef67271fff4b32f6d3ba7f59d4 100644 (file)
@@ -39,7 +39,7 @@ return [
     'reset' => 'Obnovit',
     'remove' => 'Odebrat',
     'add' => 'Přidat',
-    'configure' => 'Configure',
+    'configure' => 'Nastavit',
     'fullscreen' => 'Celá obrazovka',
     'favourite' => 'Přidat do oblíbených',
     'unfavourite' => 'Odebrat z oblíbených',
index ba07d6b6a0399d9f50e80cd3e1835bd4f32e688c..d82d4c2ea36ae75219803f09a8b75b85bac0bc7e 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Oprávnění knihovny',
     'shelves_permissions_updated' => 'Oprávnění knihovny byla aktualizována',
     'shelves_permissions_active' => 'Oprávnění knihovny byla aktivována',
+    'shelves_permissions_cascade_warning' => 'Oprávnění v Knihovnách nejsou automaticky kaskádována do obsažených knih. To proto, že kniha může existovat ve více Knihovnách. Oprávnění však lze zkopírovat do podřízených knih pomocí níže uvedené možnosti.',
     'shelves_copy_permissions_to_books' => 'Kopírovat oprávnění na knihy',
     'shelves_copy_permissions' => 'Kopírovat oprávnění',
     'shelves_copy_permissions_explain' => 'Toto použije aktuální nastavení oprávnění knihovny na všechny knihy v ní obsažené. Před aktivací se ujistěte, že byly uloženy všechny změny oprávnění této knihovny.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Nová stránka',
     'pages_editing_draft_notification' => 'Právě upravujete koncept, který byl uložen před :timeDiff.',
     'pages_draft_edited_notification' => 'Tato stránka se od té doby změnila. Je doporučeno aktuální koncept zahodit.',
+    'pages_draft_page_changed_since_creation' => 'Tato stránka byla aktualizována od vytvoření tohoto konceptu. Doporučuje se zrušit tento koncept nebo se postarat o to, abyste si nepřepsali žádné již zadané změny.',
     'pages_draft_edit_active' => [
         'start_a' => 'Uživatelé začali upravovat tuto stránku (celkem :count)',
         'start_b' => ':userName začal/a upravovat tuto stránku',
index 3281114f78380f39b8bf2b919f7710b5fe34c6ca..8ca0d538f746e0e187b1609c3f49b5ad875a5c3a 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Uživatel',
     'audit_table_event' => 'Událost',
     'audit_table_related' => 'Související položka nebo detail',
+    'audit_table_ip' => 'IP adresa',
     'audit_table_date' => 'Datum aktivity',
     'audit_date_from' => 'Časový rozsah od',
     'audit_date_to' => 'Časový rozsah do',
@@ -138,7 +139,7 @@ return [
     'role_details' => 'Detaily role',
     'role_name' => 'Název role',
     'role_desc' => 'Stručný popis role',
-    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_mfa_enforced' => 'Vyžaduje Vícefaktorové ověření',
     'role_external_auth_id' => 'Přihlašovací identifikátory třetích stran',
     'role_system' => 'Systémová oprávnění',
     'role_manage_users' => 'Správa uživatelů',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Správa šablon stránek',
     'role_access_api' => 'Přístup k systémovému API',
     'role_manage_settings' => 'Správa nastavení aplikace',
+    'role_export_content' => 'Exportovat obsah',
     'role_asset' => 'Obsahová oprávnění',
     'roles_system_warning' => 'Berte na vědomí, že přístup k některému ze tří výše uvedených oprávnění může uživateli umožnit změnit svá vlastní oprávnění nebo oprávnění ostatních uživatelů v systému. Přiřazujte role s těmito oprávněními pouze důvěryhodným uživatelům.',
     'role_asset_desc' => 'Tato oprávnění řídí přístup k obsahu napříč systémem. Specifická oprávnění na knihách, kapitolách a stránkách převáží tato nastavení.',
@@ -205,10 +207,10 @@ return [
     'users_api_tokens_create' => 'Vytvořit Token',
     'users_api_tokens_expires' => 'Vyprší',
     'users_api_tokens_docs' => 'API Dokumentace',
-    'users_mfa' => 'Multi-Factor Authentication',
-    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa' => 'Vícefázové ověření',
+    'users_mfa_desc' => 'Nastavit vícefaktorové ověřování jako další vrstvu zabezpečení vašeho uživatelského účtu.',
     'users_mfa_x_methods' => ':count method configured|:count methods configured',
-    'users_mfa_configure' => 'Configure Methods',
+    'users_mfa_configure' => 'Konfigurovat metody',
 
     // API Tokens
     'user_api_token_create' => 'Vytvořit API Token',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index ea7eebdf988d5a2d6c2cd7300f6bca16be1f099b..e4e33bc0c21dabcaa4c562ed2c541efd416b89cc 100644 (file)
@@ -15,7 +15,7 @@ return [
     'alpha_dash'           => ':attribute může obsahovat pouze písmena, číslice, pomlčky a podtržítka. České znaky (á, é, í, ó, ú, ů, ž, š, č, ř, ď, ť, ň) nejsou podporovány.',
     'alpha_num'            => ':attribute může obsahovat pouze písmena a číslice.',
     'array'                => ':attribute musí být pole.',
-    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'backup_codes'         => 'Zadaný kód není platný nebo již byl použit.',
     'before'               => ':attribute musí být datum před :date.',
     'between'              => [
         'numeric' => ':attribute musí být hodnota mezi :min a :max.',
@@ -99,7 +99,7 @@ return [
     ],
     'string'               => ':attribute musí být řetězec znaků.',
     'timezone'             => ':attribute musí být platná časová zóna.',
-    'totp'                 => 'The provided code is not valid or has expired.',
+    'totp'                 => 'Zadaný kód je neplatný nebo vypršel.',
     'unique'               => ':attribute musí být unikátní.',
     'url'                  => 'Formát :attribute je neplatný.',
     'uploaded'             => 'Nahrávání :attribute se nezdařilo.',
index a81dde3ecc24152e5c3363016ab68a6b947f1368..23c13b2d8444d0c6770df52f1b6503c060947ad0 100644 (file)
@@ -44,12 +44,12 @@ return [
     'bookshelf_delete_notification'    => 'Bogreolen blev opdateret',
 
     // Favourites
-    'favourite_add_notification' => '":name" has been added to your favourites',
-    'favourite_remove_notification' => '":name" has been removed from your favourites',
+    'favourite_add_notification' => '":name" er blevet tilføjet til dine favoritter',
+    'favourite_remove_notification' => '":name" er blevet fjernet fra dine favoritter',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => 'Multi-faktor metode konfigureret',
+    'mfa_remove_method_notification' => 'Multi-faktor metode fjernet',
 
     // Other
     'commented_on'                => 'kommenterede til',
index f16bbf47f23b167648fcdd1b2bcfaa806397d6d4..8c9d86ea69c6706419992a35312abe8700abb456 100644 (file)
@@ -76,19 +76,19 @@ return [
     'user_invite_success' => 'Adgangskode indstillet, du har nu adgang til :appName!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Setup Multi-Factor Authentication',
-    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'mfa_setup_configured' => 'Already configured',
-    'mfa_setup_reconfigure' => 'Reconfigure',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
-    'mfa_setup_action' => 'Setup',
-    'mfa_backup_codes_usage_limit_warning' => '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.',
-    'mfa_option_totp_title' => 'Mobile App',
-    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_option_backup_codes_title' => 'Backup Codes',
-    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
-    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
-    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_setup' => 'Opsætning af Multi-faktor godkendelse',
+    'mfa_setup_desc' => 'Opsæt multi-faktor godkendelse som et ekstra lag af sikkerhed for din brugerkonto.',
+    'mfa_setup_configured' => 'Allerede konfigureret',
+    'mfa_setup_reconfigure' => 'Genkonfigurer',
+    'mfa_setup_remove_confirmation' => 'Er du sikker på, at du vil fjerne denne multi-faktor godkendelsesmetode?',
+    'mfa_setup_action' => 'Opsætning',
+    'mfa_backup_codes_usage_limit_warning' => 'Du har mindre end 5 backup koder tilbage, generere og gem et nyt sæt før du løber tør for koder, for at forhindre at blive lukket ude af din konto.',
+    'mfa_option_totp_title' => 'Mobil app',
+    'mfa_option_totp_desc' => 'For at bruge multi-faktor godkendelse, skal du bruge en mobil app, der understøtter TOTP såsom Google Authenticator, Authy eller Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup koder',
+    'mfa_option_backup_codes_desc' => 'Gem sikkert et sæt af engangs backup koder, som du kan indtaste for at bekræfte din identitet.',
+    'mfa_gen_confirm_and_enable' => 'Bekræft og aktivér',
+    'mfa_gen_backup_codes_title' => 'Backup koder opsætning',
     'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
     'mfa_gen_backup_codes_download' => 'Download Codes',
     'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
index 829ab93828096435c739560758ce89e9311428cf..0e426973467deb69384cc925acfe8f1f19d4965c 100644 (file)
@@ -39,12 +39,12 @@ return [
     'reset' => 'Nulstil',
     'remove' => 'Fjern',
     'add' => 'Tilføj',
-    'configure' => 'Configure',
+    'configure' => 'Konfigurer',
     'fullscreen' => 'Fuld skærm',
-    'favourite' => 'Favourite',
-    'unfavourite' => 'Unfavourite',
-    'next' => 'Next',
-    'previous' => 'Previous',
+    'favourite' => 'Foretrukken',
+    'unfavourite' => 'Fjern som foretrukken',
+    'next' => 'Næste',
+    'previous' => 'Forrige',
 
     // Sort Options
     'sort_options' => 'Sorteringsindstillinger',
@@ -61,7 +61,7 @@ return [
     'no_activity' => 'Ingen aktivitet at vise',
     'no_items' => 'Intet indhold tilgængeligt',
     'back_to_top' => 'Tilbage til toppen',
-    'skip_to_main_content' => 'Skip to main content',
+    'skip_to_main_content' => 'Spring til indhold',
     'toggle_details' => 'Vis/skjul detaljer',
     'toggle_thumbnails' => 'Vis/skjul miniaturer',
     'details' => 'Detaljer',
index 752976ba81a769f2241106761ca0d2d7e1ed2205..0d9d67cb4712f0a4b022210647e1e21a52d8b7b6 100644 (file)
@@ -27,8 +27,8 @@ return [
     'images' => 'Billeder',
     'my_recent_drafts' => 'Mine seneste kladder',
     'my_recently_viewed' => 'Mine senest viste',
-    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
-    'my_favourites' => 'My Favourites',
+    'my_most_viewed_favourites' => 'Mine mest viste favoritter',
+    'my_favourites' => 'Mine favoritter',
     'no_pages_viewed' => 'Du har ikke besøgt nogle sider',
     'no_pages_recently_created' => 'Ingen sider er blevet oprettet for nyligt',
     'no_pages_recently_updated' => 'Ingen sider er blevet opdateret for nyligt',
@@ -36,7 +36,7 @@ return [
     'export_html' => 'Indeholdt webfil',
     'export_pdf' => 'PDF-fil',
     'export_text' => 'Almindelig tekstfil',
-    'export_md' => 'Markdown File',
+    'export_md' => 'Markdown Fil',
 
     // Permissions and restrictions
     'permissions' => 'Rettigheder',
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Reoltilladelser',
     'shelves_permissions_updated' => 'Reoltilladelser opdateret',
     'shelves_permissions_active' => 'Aktive reoltilladelser',
+    'shelves_permissions_cascade_warning' => 'Tilladelser på reoler nedarves ikke automatisk til indeholdte bøger. Dette skyldes, at en bog kan eksistere på flere hylder. Tilladelser kan dog kopieres ned til underliggende bøger ved hjælp af muligheden, der findes nedenfor.',
     'shelves_copy_permissions_to_books' => 'Kopier tilladelser til bøger',
     'shelves_copy_permissions' => 'Kopier tilladelser',
     'shelves_copy_permissions_explain' => 'Dette vil anvende de aktuelle tilladelsesindstillinger på denne boghylde på alle bøger indeholdt i. Før aktivering skal du sikre dig, at ændringer i tilladelserne til denne boghylde er blevet gemt.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Ny side',
     'pages_editing_draft_notification' => 'Du redigerer en kladde der sidst var gemt :timeDiff.',
     'pages_draft_edited_notification' => 'Siden har været opdateret siden da. Det er anbefalet at du kasserer denne kladde.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count brugerer har begyndt at redigere denne side',
         'start_b' => ':userName er begyndt at redigere denne side',
index 806ac8177c2f1a3071bff928db2c1f26497d7884..d54cac243b56e31126e82cfe688288a778efde92 100644 (file)
@@ -83,9 +83,9 @@ return [
     '404_page_not_found' => 'Siden blev ikke fundet',
     'sorry_page_not_found' => 'Beklager, siden du leder efter blev ikke fundet.',
     'sorry_page_not_found_permission_warning' => 'Hvis du forventede, at denne side skulle eksistere, har du muligvis ikke tilladelse til at se den.',
-    'image_not_found' => 'Image Not Found',
-    'image_not_found_subtitle' => 'Sorry, The image file you were looking for could not be found.',
-    'image_not_found_details' => 'If you expected this image to exist it might have been deleted.',
+    'image_not_found' => 'Billede ikke fundet',
+    'image_not_found_subtitle' => 'Beklager, billedet du ledte efter kunne ikke findes.',
+    'image_not_found_details' => 'Hvis du forventede, at dette billede skulle eksistere, kan det være blevet slettet.',
     'return_home' => 'Gå tilbage til hjem',
     'error_occurred' => 'Der opstod en fejl',
     'app_down' => ':appName er nede lige nu',
index ec5e2b6f177492597a2b6299298b0901e38446b5..cfb4ed908204eb609f391325a795a92316c2a9f7 100644 (file)
@@ -92,7 +92,7 @@ return [
     'recycle_bin' => 'Papirkurv',
     'recycle_bin_desc' => 'Her kan du gendanne elementer, der er blevet slettet eller vælge at permanent fjerne dem fra systemet. Denne liste er ufiltreret, i modsætning til lignende aktivitetslister i systemet, hvor tilladelsesfiltre anvendes.',
     'recycle_bin_deleted_item' => 'Slettet element',
-    'recycle_bin_deleted_parent' => 'Parent',
+    'recycle_bin_deleted_parent' => 'Overordnet',
     'recycle_bin_deleted_by' => 'Slettet af',
     'recycle_bin_deleted_at' => 'Sletningstidspunkt',
     'recycle_bin_permanently_delete' => 'Slet permanent',
@@ -105,7 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Elementer der skal gendannes',
     'recycle_bin_restore_confirm' => 'Denne handling vil gendanne det slettede element, herunder alle underelementer, til deres oprindelige placering. Hvis den oprindelige placering siden er blevet slettet, og nu er i papirkurven, vil det overordnede element også skulle gendannes.',
     'recycle_bin_restore_deleted_parent' => 'Det overordnede element til dette element er også blevet slettet. Disse vil forblive slettet indtil det overordnede også er gendannet.',
-    'recycle_bin_restore_parent' => 'Restore Parent',
+    'recycle_bin_restore_parent' => 'Gendan Overordnet',
     'recycle_bin_destroy_notification' => 'Slettede :count elementer fra papirkurven.',
     'recycle_bin_restore_notification' => 'Gendannede :count elementer fra papirkurven.',
 
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Bruger',
     'audit_table_event' => 'Hændelse',
     'audit_table_related' => 'Relateret element eller detalje',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Aktivitetsdato',
     'audit_date_from' => 'Datointerval fra',
     'audit_date_to' => 'Datointerval til',
@@ -138,7 +139,7 @@ return [
     'role_details' => 'Rolledetaljer',
     'role_name' => 'Rollenavn',
     'role_desc' => 'Kort beskrivelse af rolle',
-    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_mfa_enforced' => 'Kræver multifaktor godkendelse',
     'role_external_auth_id' => 'Eksterne godkendelses-IDer',
     'role_system' => 'Systemtilladelser',
     'role_manage_users' => 'Administrere brugere',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Administrer side-skabeloner',
     'role_access_api' => 'Tilgå system-API',
     'role_manage_settings' => 'Administrer app-indstillinger',
+    'role_export_content' => 'Eksporter indhold',
     'role_asset' => 'Tilladelser for medier og "assets"',
     'roles_system_warning' => 'Vær opmærksom på, at adgang til alle af de ovennævnte tre tilladelser, kan give en bruger mulighed for at ændre deres egne brugerrettigheder eller brugerrettigheder for andre i systemet. Tildel kun roller med disse tilladelser til betroede brugere.',
     'role_asset_desc' => 'Disse tilladelser kontrollerer standardadgang til medier og "assets" i systemet. Tilladelser til bøger, kapitler og sider tilsidesætter disse tilladelser.',
@@ -205,10 +207,10 @@ return [
     'users_api_tokens_create' => 'Opret Token',
     'users_api_tokens_expires' => 'Udløber',
     'users_api_tokens_docs' => 'API-dokumentation',
-    'users_mfa' => 'Multi-Factor Authentication',
-    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'users_mfa_x_methods' => ':count method configured|:count methods configured',
-    'users_mfa_configure' => 'Configure Methods',
+    'users_mfa' => 'Multi-faktor godkendelse',
+    'users_mfa_desc' => 'Opsæt multi-faktor godkendelse som et ekstra lag af sikkerhed for din brugerkonto.',
+    'users_mfa_x_methods' => ':count metode konfigureret|:count metoder konfigureret',
+    'users_mfa_configure' => 'Konfigurer metoder',
 
     // API Tokens
     'user_api_token_create' => 'Opret API-token',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 0635f14a7a7bcc15a095f1424f3fd2b44ad044a9..c54b07a6eb529ced79a9d26bbde3ffc8efbbe921 100644 (file)
@@ -15,7 +15,7 @@ return [
     'alpha_dash'           => ':attribute må kun bestå af bogstaver, tal, binde- og under-streger.',
     'alpha_num'            => ':attribute må kun indeholde bogstaver og tal.',
     'array'                => ':attribute skal være et array.',
-    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'backup_codes'         => 'Den angivne kode er ikke gyldig eller er allerede brugt.',
     'before'               => ':attribute skal være en dato før :date.',
     'between'              => [
         'numeric' => ':attribute skal være mellem :min og :max.',
@@ -99,7 +99,7 @@ return [
     ],
     'string'               => ':attribute skal være tekst.',
     'timezone'             => ':attribute skal være en gyldig zone.',
-    'totp'                 => 'The provided code is not valid or has expired.',
+    'totp'                 => 'Den angivne kode er ikke gyldig eller er udløbet.',
     'unique'               => ':attribute er allerede i brug.',
     'url'                  => ':attribute-formatet er ugyldigt.',
     'uploaded'             => 'Filen kunne ikke oploades. Serveren accepterer muligvis ikke filer af denne størrelse.',
index 249bfc45379076d43ed61990bffc0be25da08441..87dd3ee8ba534157be8a9e8d2c969dbb1d709112 100644 (file)
@@ -48,8 +48,8 @@ return [
     'favourite_remove_notification' => '":name" wurde aus Ihren Favoriten entfernt',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => 'Multi-Faktor-Methode erfolgreich konfiguriert',
+    'mfa_remove_method_notification' => 'Multi-Faktor-Methode erfolgreich entfernt',
 
     // Other
     'commented_on'                => 'hat einen Kommentar hinzugefügt',
index 117a56faed9f1aa983dab6edc3f128d44c0b2794..efe82680da55808c0e42fac0c28378ac5c172d55 100644 (file)
@@ -6,7 +6,7 @@
  */
 return [
 
-    'failed' => 'Die eingegebenen Anmeldedaten sind ungültig.',
+    'failed' => 'Diese Anmeldedaten stimmen nicht mit unseren Aufzeichnungen überein.',
     'throttle' => 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in :seconds Sekunden erneut.',
 
     // Login & Register
@@ -20,7 +20,7 @@ return [
     'username' => 'Benutzername',
     'email' => 'E-Mail',
     'password' => 'Passwort',
-    'password_confirm' => 'Passwort best&auml;tigen',
+    'password_confirm' => 'Passwort bestätigen',
     'password_hint' => 'Mindestlänge: 7 Zeichen',
     'forgot_password' => 'Passwort vergessen?',
     'remember_me' => 'Angemeldet bleiben',
@@ -76,37 +76,37 @@ return [
     'user_invite_success' => 'Passwort gesetzt, Sie haben nun Zugriff auf :appName!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Setup Multi-Factor Authentication',
-    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'mfa_setup_configured' => 'Already configured',
-    'mfa_setup_reconfigure' => 'Reconfigure',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
-    'mfa_setup_action' => 'Setup',
-    'mfa_backup_codes_usage_limit_warning' => '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.',
+    'mfa_setup' => 'Multi-Faktor-Authentifizierung einrichten',
+    'mfa_setup_desc' => 'Richten Sie Multi-Faktor-Authentifizierung als zusätzliche Sicherheitsstufe für Ihr Benutzerkonto ein.',
+    'mfa_setup_configured' => 'Bereits konfiguriert',
+    'mfa_setup_reconfigure' => 'Umkonfigurieren',
+    'mfa_setup_remove_confirmation' => 'Sind Sie sicher, dass Sie diese Multi-Faktor-Authentifizierungsmethode entfernen möchten?',
+    'mfa_setup_action' => 'Einrichtung',
+    'mfa_backup_codes_usage_limit_warning' => 'Sie haben weniger als 5 Backup-Codes übrig, Bitte erstellen und speichern Sie ein neues Set bevor Sie keine Codes mehr haben, um zu verhindern, dass Sie von Ihrem Konto gesperrt werden.',
     'mfa_option_totp_title' => 'Mobile App',
-    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_option_backup_codes_title' => 'Backup Codes',
-    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
-    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
-    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
-    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_option_totp_desc' => 'Um Mehrfach-Faktor-Authentifizierung nutzen zu können, benötigen Sie eine mobile Anwendung, die TOTP unterstützt, wie Google Authenticator, Authy oder Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Code',
+    'mfa_option_backup_codes_desc' => 'Speichern Sie sicher eine Reihe von einmaligen Backup-Codes, die Sie eingeben können, um Ihre Identität zu überprüfen.',
+    'mfa_gen_confirm_and_enable' => 'Bestätigen und aktivieren',
+    'mfa_gen_backup_codes_title' => 'Backup-Codes einrichten',
+    'mfa_gen_backup_codes_desc' => 'Speichern Sie die folgende Liste der Codes an einem sicheren Ort. Wenn Sie auf das System zugreifen, können Sie einen der Codes als zweiten Authentifizierungsmechanismus verwenden.',
     'mfa_gen_backup_codes_download' => 'Download Codes',
-    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
-    'mfa_gen_totp_title' => 'Mobile App Setup',
-    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
-    'mfa_gen_totp_verify_setup' => 'Verify Setup',
-    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
-    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
-    'mfa_verify_access' => 'Verify Access',
-    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
-    'mfa_verify_no_methods' => 'No Methods Configured',
-    'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
-    'mfa_verify_use_totp' => 'Verify using a mobile app',
-    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
-    'mfa_verify_backup_code' => 'Backup Code',
-    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
-    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
-    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
-    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+    'mfa_gen_backup_codes_usage_warning' => 'Jeder Code kann nur einmal verwendet werden',
+    'mfa_gen_totp_title' => 'Mobile App einrichten',
+    'mfa_gen_totp_desc' => 'Um Mehrfach-Faktor-Authentifizierung nutzen zu können, benötigen Sie eine mobile Anwendung, die TOTP unterstützt, wie Google Authenticator, Authy oder Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scannen Sie den QR-Code unten mit Ihrer bevorzugten Authentifizierungs-App, um loszulegen.',
+    'mfa_gen_totp_verify_setup' => 'Setup überprüfen',
+    'mfa_gen_totp_verify_setup_desc' => 'Überprüfen Sie, dass alles funktioniert, indem Sie einen Code in Ihrer Authentifizierungs-App in das Eingabefeld unten eingeben:',
+    'mfa_gen_totp_provide_code_here' => 'Geben Sie hier Ihre App generierten Code ein',
+    'mfa_verify_access' => 'Zugriff überprüfen',
+    'mfa_verify_access_desc' => 'Ihr Benutzerkonto erfordert, dass Sie Ihre Identität über eine zusätzliche Verifikationsebene bestätigen, bevor Sie den Zugriff gewähren. Überprüfen Sie mit einer Ihrer konfigurierten Methoden, um fortzufahren.',
+    'mfa_verify_no_methods' => 'Keine Methoden konfiguriert',
+    'mfa_verify_no_methods_desc' => 'Es konnten keine Mehrfach-Faktor-Authentifizierungsmethoden für Ihr Konto gefunden werden. Sie müssen mindestens eine Methode einrichten, bevor Sie Zugriff erhalten.',
+    'mfa_verify_use_totp' => 'Mit einer mobilen App verifizieren',
+    'mfa_verify_use_backup_codes' => 'Mit einem Backup-Code überprüfen',
+    'mfa_verify_backup_code' => 'Backup-Code',
+    'mfa_verify_backup_code_desc' => 'Geben Sie einen Ihrer verbleibenden Backup-Codes unten ein:',
+    'mfa_verify_backup_code_enter_here' => 'Backup-Code hier eingeben',
+    'mfa_verify_totp_desc' => 'Geben Sie den Code ein, der mit Ihrer mobilen App generiert wurde:',
+    'mfa_setup_login_notification' => 'Multi-Faktor-Methode konfiguriert. Bitte melden Sie sich jetzt erneut mit der konfigurierten Methode an.',
 ];
\ No newline at end of file
index c56e09044cc67ed4c483bd9889f82f5b6127e3a0..bd75e1737dc499330b4b5e7c6117ec5f4985734e 100644 (file)
@@ -33,15 +33,15 @@ return [
     'copy' => 'Kopieren',
     'reply' => 'Antworten',
     'delete' => 'Löschen',
-    'delete_confirm' => 'Löschen Bestätigen',
+    'delete_confirm' => 'Löschen bestätigen',
     'search' => 'Suchen',
     'search_clear' => 'Suche löschen',
     'reset' => 'Zurücksetzen',
     'remove' => 'Entfernen',
     'add' => 'Hinzufügen',
-    'configure' => 'Configure',
+    'configure' => 'Konfigurieren',
     'fullscreen' => 'Vollbild',
-    'favourite' => 'Favorit',
+    'favourite' => 'Favoriten',
     'unfavourite' => 'Kein Favorit',
     'next' => 'Nächste',
     'previous' => 'Vorheriges',
@@ -57,9 +57,9 @@ return [
     'sort_updated_at' => 'Aktualisierungsdatum',
 
     // Misc
-    'deleted_user' => 'Gelöschte Benutzer',
+    'deleted_user' => 'Gelöschter Benutzer',
     'no_activity' => 'Keine Aktivitäten zum Anzeigen',
-    'no_items' => 'Keine Einträge gefunden.',
+    'no_items' => 'Keine Einträge gefunden',
     'back_to_top' => 'nach oben',
     'skip_to_main_content' => 'Direkt zum Hauptinhalt',
     'toggle_details' => 'Details zeigen/verstecken',
index a5180c44955d22c55705cbf9e1f81827f7d3c993..200961c97fd02fcc238708046911f40e060635bc 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Regal-Berechtigungen',
     'shelves_permissions_updated' => 'Regal-Berechtigungen aktualisiert',
     'shelves_permissions_active' => 'Regal-Berechtigungen aktiv',
+    'shelves_permissions_cascade_warning' => 'Die Berechtigungen in Bücherregalen werden nicht automatisch auf enthaltene Bücher kaskadiert, weil ein Buch in mehreren Regalen existieren kann. Berechtigungen können jedoch mit der unten stehenden Option in untergeordnete Bücher kopiert werden.',
     'shelves_copy_permissions_to_books' => 'Kopiere die Berechtigungen zum Buch',
     'shelves_copy_permissions' => 'Berechtigungen kopieren',
     'shelves_copy_permissions_explain' => 'Hiermit werden die Berechtigungen des aktuellen Regals auf alle enthaltenen Bücher übertragen. Überprüfen Sie vor der Aktivierung, ob alle Berechtigungsänderungen am aktuellen Regal gespeichert wurden.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Neue Seite',
     'pages_editing_draft_notification' => 'Sie bearbeiten momenten einen Entwurf, der zuletzt :timeDiff gespeichert wurde.',
     'pages_draft_edited_notification' => 'Diese Seite wurde seit diesem Zeitpunkt verändert. Wir empfehlen Ihnen, diesen Entwurf zu verwerfen.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count Benutzer bearbeiten derzeit diese Seite.',
         'start_b' => ':userName bearbeitet jetzt diese Seite.',
index 0a21857df001085189190dcf1304df634ae20d98..65174eada10d69421c1be5e7becf3f6f02ebab62 100644 (file)
@@ -5,7 +5,7 @@
 return [
 
     // Permissions
-    'permission' => 'Sie haben keine Berechtigung, auf diese Seite zuzugreifen.',
+    'permission' => 'Sie haben keine Zugriffsberechtigung auf die angeforderte Seite.',
     'permissionJson' => 'Sie haben keine Berechtigung, die angeforderte Aktion auszuführen.',
 
     // Auth
@@ -14,7 +14,7 @@ return [
     'email_confirmation_invalid' => 'Der Bestätigungslink ist nicht gültig oder wurde bereits verwendet. Bitte registrieren Sie sich erneut.',
     'email_confirmation_expired' => 'Der Bestätigungslink ist abgelaufen. Es wurde eine neue Bestätigungs-E-Mail gesendet.',
     'email_confirmation_awaiting' => 'Die E-Mail-Adresse für das verwendete Konto muss bestätigt werden',
-    'ldap_fail_anonymous' => 'Anonymer LDAP-Zugriff ist fehlgeschlafgen',
+    'ldap_fail_anonymous' => 'Anonymer LDAP-Zugriff ist fehlgeschlagen',
     'ldap_fail_authed' => 'LDAP-Zugriff mit DN und Passwort ist fehlgeschlagen',
     'ldap_extension_not_installed' => 'LDAP-PHP-Erweiterung ist nicht installiert.',
     'ldap_cannot_connect' => 'Die Verbindung zum LDAP-Server ist fehlgeschlagen. Beim initialen Verbindungsaufbau trat ein Fehler auf.',
@@ -43,14 +43,14 @@ return [
     'uploaded'  => 'Der Server verbietet das Hochladen von Dateien mit dieser Dateigröße. Bitte versuchen Sie es mit einer kleineren Datei.',
     'image_upload_error' => 'Beim Hochladen des Bildes trat ein Fehler auf.',
     'image_upload_type_error' => 'Der Bildtyp der hochgeladenen Datei ist ungültig.',
-    'file_upload_timeout' => 'Der Upload der Datei ist abgelaufen.',
+    'file_upload_timeout' => 'Der Datei-Upload hat das Zeitlimit überschritten.',
 
     // Attachments
     'attachment_not_found' => 'Anhang konnte nicht gefunden werden.',
 
     // Pages
     'page_draft_autosave_fail' => 'Fehler beim Speichern des Entwurfs. Stellen Sie sicher, dass Sie mit dem Internet verbunden sind, bevor Sie den Entwurf dieser Seite speichern.',
-    'page_custom_home_deletion' => 'Eine als Startseite gesetzte Seite kann nicht gelöscht werden.',
+    'page_custom_home_deletion' => 'Eine als Startseite gesetzte Seite kann nicht gelöscht werden',
 
     // Entities
     'entity_not_found' => 'Eintrag nicht gefunden',
@@ -58,48 +58,48 @@ return [
     'book_not_found' => 'Buch nicht gefunden',
     'page_not_found' => 'Seite nicht gefunden',
     'chapter_not_found' => 'Kapitel nicht gefunden',
-    'selected_book_not_found' => 'Das gewählte Buch wurde nicht gefunden.',
+    'selected_book_not_found' => 'Das gewählte Buch wurde nicht gefunden',
     'selected_book_chapter_not_found' => 'Das gewählte Buch oder Kapitel wurde nicht gefunden.',
     'guests_cannot_save_drafts' => 'Gäste können keine Entwürfe speichern',
 
     // Users
-    'users_cannot_delete_only_admin' => 'Sie können den einzigen Administrator nicht löschen.',
+    'users_cannot_delete_only_admin' => 'Sie können den einzigen Administrator nicht löschen',
     'users_cannot_delete_guest' => 'Sie können den Gast-Benutzer nicht löschen',
 
     // Roles
-    'role_cannot_be_edited' => 'Diese Rolle kann nicht bearbeitet werden.',
+    'role_cannot_be_edited' => 'Diese Rolle kann nicht bearbeitet werden',
     'role_system_cannot_be_deleted' => 'Dies ist eine Systemrolle und kann nicht gelöscht werden',
     'role_registration_default_cannot_delete' => 'Diese Rolle kann nicht gelöscht werden, solange sie als Standardrolle für neue Registrierungen gesetzt ist',
-    'role_cannot_remove_only_admin' => 'Dieser Benutzer ist der einzige Benutzer, welchem die Administratorrolle zugeordnet ist. Ordnen Sie die Administratorrolle einem anderen Benutzer zu, bevor Sie versuchen, sie hier zu entfernen.',
+    'role_cannot_remove_only_admin' => 'Dieser Benutzer ist der einzige Benutzer, welchem die Administratorrolle zugeordnet ist. Ordnen Sie die Administratorrolle einem anderen Benutzer zu bevor Sie versuchen sie hier zu entfernen.',
 
     // Comments
     'comment_list' => 'Beim Abrufen der Kommentare ist ein Fehler aufgetreten.',
     'cannot_add_comment_to_draft' => 'Du kannst keine Kommentare zu einem Entwurf hinzufügen.',
     'comment_add' => 'Beim Hinzufügen des Kommentars ist ein Fehler aufgetreten.',
     'comment_delete' => 'Beim Löschen des Kommentars ist ein Fehler aufgetreten.',
-    'empty_comment' => 'Kann keinen leeren Kommentar hinzufügen',
+    'empty_comment' => 'Kann keinen leeren Kommentar hinzufügen.',
 
     // Error pages
     '404_page_not_found' => 'Seite nicht gefunden',
-    'sorry_page_not_found' => 'Entschuldigung. Die Seite, die Sie angefordert haben, wurde nicht gefunden.',
+    'sorry_page_not_found' => 'Entschuldigung. Die angeforderte Seite wurde nicht gefunden.',
     'sorry_page_not_found_permission_warning' => 'Wenn Sie erwartet haben, dass diese Seite existiert, haben Sie möglicherweise nicht die Berechtigung, sie anzuzeigen.',
     'image_not_found' => 'Bild nicht gefunden',
-    'image_not_found_subtitle' => 'Entschuldigung. Das Bild, die Sie angefordert haben, wurde nicht gefunden.',
+    'image_not_found_subtitle' => 'Entschuldigung. Das angeforderte Bild wurde nicht gefunden.',
     'image_not_found_details' => 'Wenn Sie erwartet haben, dass dieses Bild existiert, könnte es gelöscht worden sein.',
     'return_home' => 'Zurück zur Startseite',
     'error_occurred' => 'Es ist ein Fehler aufgetreten',
-    'app_down' => ':appName befindet sich aktuell im Wartungsmodus.',
+    'app_down' => ':appName befindet sich aktuell im Wartungsmodus',
     'back_soon' => 'Wir werden so schnell wie möglich wieder online sein.',
 
     // API errors
-    'api_no_authorization_found' => 'Kein Autorisierungs-Token für die Anfrage gefunden',
-    'api_bad_authorization_format' => 'Ein Autorisierungs-Token wurde auf die Anfrage gefunden, aber das Format schien falsch zu sein',
-    'api_user_token_not_found' => 'Es wurde kein passender API-Token für den angegebenen Autorisierungs-Token gefunden',
-    'api_incorrect_token_secret' => 'Das für den angegebenen API-Token angegebene Kennwort ist falsch',
-    'api_user_no_api_permission' => 'Der Besitzer des verwendeten API-Token hat keine Berechtigung für API-Aufrufe',
-    'api_user_token_expired' => 'Das verwendete Autorisierungs-Token ist abgelaufen',
+    'api_no_authorization_found' => 'Kein Autorisierungstoken für die Anfrage gefunden',
+    'api_bad_authorization_format' => 'Ein Autorisierungstoken wurde auf die Anfrage gefunden, aber das Format schien falsch zu sein',
+    'api_user_token_not_found' => 'Es wurde kein passender API-Token für den angegebenen Autorisierungstoken gefunden',
+    'api_incorrect_token_secret' => 'Das Kennwort für das angegebene API-Token ist falsch',
+    'api_user_no_api_permission' => 'Der Besitzer des verwendeten API-Tokens hat keine Berechtigung für API-Aufrufe',
+    'api_user_token_expired' => 'Das verwendete Autorisierungstoken ist abgelaufen',
 
     // Settings & Maintenance
-    'maintenance_test_email_failure' => 'Fehler beim Senden einer Test E-Mail:',
+    'maintenance_test_email_failure' => 'Fehler beim Versenden einer Test E-Mail:',
 
 ];
index 3795cbaffaf61d504e51c193b102a00fe73e80d6..d24319c18ba1168c887ecfa1243f108b3ad0a27e 100644 (file)
@@ -18,7 +18,7 @@ return [
     'app_name_desc' => 'Dieser Name wird im Header und in E-Mails angezeigt.',
     'app_name_header' => 'Anwendungsname im Header anzeigen?',
     'app_public_access' => 'Öffentlicher Zugriff',
-    'app_public_access_desc' => 'Wenn Sie diese Option aktivieren, können Besucher, die nicht angemeldet sind, auf Inhalte in Ihrer BookStack-Instanz zugreifen.',
+    'app_public_access_desc' => 'Wenn Sie diese Option aktivieren können Besucher, die nicht angemeldet sind, auf Inhalte in Ihrer BookStack-Instanz zugreifen.',
     'app_public_access_desc_guest' => 'Der Zugang für öffentliche Besucher kann über den Benutzer "Guest" gesteuert werden.',
     'app_public_access_toggle' => 'Öffentlichen Zugriff erlauben',
     'app_public_viewing' => 'Öffentliche Ansicht erlauben?',
@@ -40,7 +40,7 @@ Wenn Sie nicht eingeben, wird die Anwendung auf die Standardfarbe zurückgesetzt
     'app_homepage_desc' => 'Wählen Sie eine Seite als Startseite aus, die statt der Standardansicht angezeigt werden soll. Seitenberechtigungen werden für die ausgewählten Seiten ignoriert.',
     'app_homepage_select' => 'Wählen Sie eine Seite aus',
     'app_footer_links' => 'Fußzeilen-Links',
-    'app_footer_links_desc' => 'Fügen Sie Links hinzu, die innerhalb der Seitenfußzeile angezeigt werden. Diese werden am unteren Ende der meisten Seiten angezeigt, einschließlich derjenigen, die keinen Login benötigen. Sie können die Bezeichnung "trans::<key>" verwenden, um systemdefinierte Übersetzungen zu verwenden. Beispiel: Mit "trans::common.privacy_policy" wird der übersetzte Text "Privacy Policy" bereitgestellt, und "trans::common.terms_of_service" liefert den übersetzten Text "Terms of Service".',
+    'app_footer_links_desc' => 'Fügen Sie Links hinzu, die innerhalb der Seitenfußzeile angezeigt werden. Diese werden am unteren Ende der meisten Seiten angezeigt, einschließlich derjenigen, die keinen Login benötigen. Sie können die Bezeichnung "trans::<key>" verwenden, um systemdefinierte Übersetzungen zu verwenden. Beispiel: Mit "trans::common.privacy_policy" wird der übersetzte Text "Privacy Policy" bereitgestellt und "trans::common.terms_of_service" liefert den übersetzten Text "Terms of Service".',
     'app_footer_links_label' => 'Link-Label',
     'app_footer_links_url' => 'Link-URL',
     'app_footer_links_add' => 'Fußzeilen-Link hinzufügen',
@@ -59,7 +59,7 @@ Wenn Sie nicht eingeben, wird die Anwendung auf die Standardfarbe zurückgesetzt
 
     // Registration Settings
     'reg_settings' => 'Registrierungseinstellungen',
-    'reg_enable' => 'Registrierung erlauben?',
+    'reg_enable' => 'Registrierung erlauben',
     'reg_enable_toggle' => 'Registrierung erlauben',
     'reg_enable_desc' => 'Wenn die Registrierung erlaubt ist, kann sich der Benutzer als Anwendungsbenutzer anmelden. Bei der Registrierung erhält er eine einzige, voreingestellte Benutzerrolle.',
     'reg_default_role' => 'Standard-Benutzerrolle nach Registrierung',
@@ -108,7 +108,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'recycle_bin_restore_list' => 'Zu wiederherzustellende Elemente',
     'recycle_bin_restore_confirm' => 'Mit dieser Aktion wird das gelöschte Element einschließlich aller untergeordneten Elemente an seinen ursprünglichen Ort wiederherstellen. Wenn der ursprüngliche Ort gelöscht wurde und sich nun im Papierkorb befindet, muss auch das übergeordnete Element wiederhergestellt werden.',
     'recycle_bin_restore_deleted_parent' => 'Das übergeordnete Elements wurde ebenfalls gelöscht. Dieses Element wird weiterhin als gelöscht zählen, bis auch das übergeordnete Element wiederhergestellt wurde.',
-    'recycle_bin_restore_parent' => 'Elternteil wiederherstellen',
+    'recycle_bin_restore_parent' => 'Übergeordneter Eintrag wiederherstellen',
     'recycle_bin_destroy_notification' => ':count Elemente wurden aus dem Papierkorb gelöscht.',
     'recycle_bin_restore_notification' => ':count Elemente wurden aus dem Papierkorb wiederhergestellt.',
 
@@ -122,6 +122,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'audit_table_user' => 'Benutzer',
     'audit_table_event' => 'Ereignis',
     'audit_table_related' => 'Verknüpftes Element oder Detail',
+    'audit_table_ip' => 'IP Adresse',
     'audit_table_date' => 'Aktivitätsdatum',
     'audit_date_from' => 'Zeitraum von',
     'audit_date_to' => 'Zeitraum bis',
@@ -141,7 +142,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'role_details' => 'Rollendetails',
     'role_name' => 'Rollenname',
     'role_desc' => 'Kurzbeschreibung der Rolle',
-    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_mfa_enforced' => 'Benötigt Mehrfach-Faktor-Authentifizierung',
     'role_external_auth_id' => 'Externe Authentifizierungs-IDs',
     'role_system' => 'System-Berechtigungen',
     'role_manage_users' => 'Benutzer verwalten',
@@ -151,6 +152,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'role_manage_page_templates' => 'Seitenvorlagen verwalten',
     'role_access_api' => 'Systemzugriffs-API',
     'role_manage_settings' => 'Globaleinstellungen verwalten',
+    'role_export_content' => 'Inhalt exportieren',
     'role_asset' => 'Berechtigungen',
     'roles_system_warning' => 'Beachten Sie, dass der Zugriff auf eine der oben genannten drei Berechtigungen einem Benutzer erlauben kann, seine eigenen Berechtigungen oder die Rechte anderer im System zu ändern. Weisen Sie nur Rollen, mit diesen Berechtigungen, vertrauenswürdigen Benutzern zu.',
     'role_asset_desc' => 'Diese Berechtigungen gelten für den Standard-Zugriff innerhalb des Systems. Berechtigungen für Bücher, Kapitel und Seiten überschreiben diese Berechtigungenen.',
@@ -208,10 +210,10 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'users_api_tokens_create' => 'Token erstellen',
     'users_api_tokens_expires' => 'Endet',
     'users_api_tokens_docs' => 'API Dokumentation',
-    'users_mfa' => 'Multi-Factor Authentication',
-    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'users_mfa_x_methods' => ':count method configured|:count methods configured',
-    'users_mfa_configure' => 'Configure Methods',
+    'users_mfa' => 'Multi-Faktor-Authentifizierung',
+    'users_mfa_desc' => 'Richten Sie Multi-Faktor-Authentifizierung als zusätzliche Sicherheitsstufe für Ihr Benutzerkonto ein.',
+    'users_mfa_x_methods' => ':count Methode konfiguriert|:count Methoden konfiguriert',
+    'users_mfa_configure' => 'Methoden konfigurieren',
 
     // API Tokens
     'user_api_token_create' => 'Neuen API-Token erstellen',
@@ -257,6 +259,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 5a076279d7e5b4ceedc8ad3d02e0d99ab9c369ee..5d08c241a6871e79899916bd2614a5ffe5177806 100644 (file)
@@ -15,7 +15,7 @@ return [
     'alpha_dash'           => ':attribute kann nur Buchstaben, Zahlen und Bindestriche enthalten.',
     'alpha_num'            => ':attribute kann nur Buchstaben und Zahlen enthalten.',
     'array'                => ':attribute muss ein Array sein.',
-    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'backup_codes'         => 'Der angegebene Code ist ungültig oder wurde bereits verwendet.',
     'before'               => ':attribute muss ein Datum vor :date sein.',
     'between'              => [
         'numeric' => ':attribute muss zwischen :min und :max liegen.',
@@ -99,7 +99,7 @@ return [
     ],
     'string'               => ':attribute muss eine Zeichenkette sein.',
     'timezone'             => ':attribute muss eine valide zeitzone sein.',
-    'totp'                 => 'The provided code is not valid or has expired.',
+    'totp'                 => 'Der angegebene Code ist ungültig oder abgelaufen.',
     'unique'               => ':attribute wird bereits verwendet.',
     'url'                  => ':attribute ist kein valides Format.',
     'uploaded'             => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.',
index 594ee519d9adc45f5d360bc7de822e02e7be35ce..898df928e5421c881957963e0c6236b85f57945d 100644 (file)
@@ -33,15 +33,15 @@ return [
     'copy' => 'Kopieren',
     'reply' => 'Antworten',
     'delete' => 'Löschen',
-    'delete_confirm' => 'Löschen Bestätigen',
+    'delete_confirm' => 'Löschen bestätigen',
     'search' => 'Suchen',
     'search_clear' => 'Suche löschen',
     'reset' => 'Zurücksetzen',
     'remove' => 'Entfernen',
     'add' => 'Hinzufügen',
-    'configure' => 'Configure',
+    'configure' => 'Konfigurieren',
     'fullscreen' => 'Vollbild',
-    'favourite' => 'Favorit',
+    'favourite' => 'Favoriten',
     'unfavourite' => 'Kein Favorit',
     'next' => 'Nächste',
     'previous' => 'Vorheriges',
@@ -57,7 +57,7 @@ return [
     'sort_updated_at' => 'Aktualisierungsdatum',
 
     // Misc
-    'deleted_user' => 'Gelöschte Benutzer',
+    'deleted_user' => 'Gelöschter Benutzer',
     'no_activity' => 'Keine Aktivitäten zum Anzeigen',
     'no_items' => 'Keine Einträge gefunden.',
     'back_to_top' => 'nach oben',
index 82936ca8912e66beaa0832c9c69fa1c0f45f150f..712421b1fb8cbcf76cec0798486b6e33e179603a 100644 (file)
@@ -36,7 +36,7 @@ return [
     'export_html' => 'HTML-Datei',
     'export_pdf' => 'PDF-Datei',
     'export_text' => 'Textdatei',
-    'export_md' => 'Markdown-Datei',
+    'export_md' => 'Markdown-Dateir',
 
     // Permissions and restrictions
     'permissions' => 'Berechtigungen',
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Regal-Berechtigungen',
     'shelves_permissions_updated' => 'Regal-Berechtigungen aktualisiert',
     'shelves_permissions_active' => 'Regal-Berechtigungen aktiv',
+    'shelves_permissions_cascade_warning' => 'Die Berechtigungen in Bücherregalen werden nicht automatisch auf enthaltene Bücher kaskadiert, weil ein Buch in mehreren Regalen existieren kann. Berechtigungen können jedoch mit der unten stehenden Option in untergeordnete Bücher kopiert werden.',
     'shelves_copy_permissions_to_books' => 'Kopiere die Berechtigungen zum Buch',
     'shelves_copy_permissions' => 'Berechtigungen kopieren',
     'shelves_copy_permissions_explain' => 'Hiermit werden die Berechtigungen des aktuellen Regals auf alle enthaltenen Bücher übertragen. Überprüfe vor der Aktivierung, ob alle Berechtigungsänderungen am aktuellen Regal gespeichert wurden.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Neue Seite',
     'pages_editing_draft_notification' => 'Du bearbeitest momenten einen Entwurf, der zuletzt :timeDiff gespeichert wurde.',
     'pages_draft_edited_notification' => 'Diese Seite wurde seit diesem Zeitpunkt verändert. Wir empfehlen Ihnen, diesen Entwurf zu verwerfen.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count Benutzer bearbeiten derzeit diese Seite.',
         'start_b' => ':userName bearbeitet jetzt diese Seite.',
index 78e69373274665d66066477e2b095bc838367138..53d8f8359a26ef802eebc0a64f1e1ac3512a3699 100644 (file)
@@ -108,7 +108,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'recycle_bin_restore_list' => 'Wiederherzustellende Einträge',
     'recycle_bin_restore_confirm' => 'Mit dieser Aktion wird der gelöschte Eintrag einschließlich aller untergeordneten Einträge an seinen ursprünglichen Ort wiederherstellen. Wenn der ursprüngliche Ort gelöscht wurde und sich nun im Papierkorb befindet, muss auch der übergeordnete Eintrag wiederhergestellt werden.',
     'recycle_bin_restore_deleted_parent' => 'Der übergeordnete Eintrag wurde ebenfalls gelöscht. Dieser Eintrag wird weiterhin als gelöscht zählen, bis auch der übergeordnete Eintrag wiederhergestellt wurde.',
-    'recycle_bin_restore_parent' => 'Elternteil wiederherstellen',
+    'recycle_bin_restore_parent' => 'Übergeordneter Eintrag wiederherstellen',
     'recycle_bin_destroy_notification' => ':count Einträge wurden aus dem Papierkorb gelöscht.',
     'recycle_bin_restore_notification' => ':count Einträge wurden aus dem Papierkorb wiederhergestellt.',
 
@@ -122,6 +122,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'audit_table_user' => 'Benutzer',
     'audit_table_event' => 'Ereignis',
     'audit_table_related' => 'Verknüpfter Eintrag oder Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Aktivitätsdatum',
     'audit_date_from' => 'Zeitraum von',
     'audit_date_to' => 'Zeitraum bis',
@@ -141,7 +142,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'role_details' => 'Rollendetails',
     'role_name' => 'Rollenname',
     'role_desc' => 'Kurzbeschreibung der Rolle',
-    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_mfa_enforced' => 'Benötigt Mehrfach-Faktor-Authentifizierung',
     'role_external_auth_id' => 'Externe Authentifizierungs-IDs',
     'role_system' => 'System-Berechtigungen',
     'role_manage_users' => 'Benutzer verwalten',
@@ -151,6 +152,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'role_manage_page_templates' => 'Seitenvorlagen verwalten',
     'role_access_api' => 'Systemzugriffs-API',
     'role_manage_settings' => 'Globaleinstellungen verwalten',
+    'role_export_content' => 'Inhalt exportieren',
     'role_asset' => 'Berechtigungen',
     'roles_system_warning' => 'Beachten Sie, dass der Zugriff auf eine der oben genannten drei Berechtigungen einem Benutzer erlauben kann, seine eigenen Berechtigungen oder die Rechte anderer im System zu ändern. Weisen Sie nur Rollen, mit diesen Berechtigungen, vertrauenswürdigen Benutzern zu.',
     'role_asset_desc' => 'Diese Berechtigungen gelten für den Standard-Zugriff innerhalb des Systems. Berechtigungen für Bücher, Kapitel und Seiten überschreiben diese Berechtigungenen.',
@@ -208,10 +210,10 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'users_api_tokens_create' => 'Token erstellen',
     'users_api_tokens_expires' => 'Endet',
     'users_api_tokens_docs' => 'API Dokumentation',
-    'users_mfa' => 'Multi-Factor Authentication',
-    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'users_mfa_x_methods' => ':count method configured|:count methods configured',
-    'users_mfa_configure' => 'Configure Methods',
+    'users_mfa' => 'Multi-Faktor-Authentifizierung',
+    'users_mfa_desc' => 'Richte die Multi-Faktor-Authentifizierung als zusätzliche Sicherheitsstufe für dein Benutzerkonto ein.',
+    'users_mfa_x_methods' => ':count Methode konfiguriert|:count Methoden konfiguriert',
+    'users_mfa_configure' => 'Methoden konfigurieren',
 
     // API Tokens
     'user_api_token_create' => 'Neuen API-Token erstellen',
@@ -257,6 +259,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 1be9c18e041818ad34df7c6bcc06563bf272f708..0a4068eea9f24e2b4b7a8dbaecbabbdb5b3fe099 100644 (file)
@@ -234,6 +234,7 @@ return [
     'pages_initial_name' => 'New Page',
     'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.',
     'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count users have started editing this page',
         'start_b' => ':userName has started editing this page',
index eb8ba54ea81506519a4958769d54086cc3b3d7be..f023b6bdf67871ce3830d39ed540a7232510973a 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
     'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.',
     'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
+    'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
     'social_no_action_defined' => 'No action defined',
     'social_login_bad_response' => "Error received during :socialAccount login: \n:error",
     'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.',
index 4c1ae134527f068e244ed6081c2b3ad8af6f987d..0ab168b66998bca0de4807f94d181b9b12ed1683 100755 (executable)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'User',
     'audit_table_event' => 'Event',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Activity Date',
     'audit_date_from' => 'Date Range From',
     'audit_date_to' => 'Date Range To',
index 96f0b384e96f48efb6e6ca03ba2f006f0631b240..70f069a12fe6d44326bcebf03837fffbedece7a7 100644 (file)
@@ -80,11 +80,11 @@ return [
     'mfa_setup_desc' => 'La autenticación en dos pasos añade una capa de seguridad adicional a tu cuenta de usuario.',
     'mfa_setup_configured' => 'Ya está configurado',
     'mfa_setup_reconfigure' => 'Reconfigurar',
-    'mfa_setup_remove_confirmation' => '¿Está seguro de que desea eliminar este método de autenticación de dos pasos?',
+    'mfa_setup_remove_confirmation' => '¿Está seguro de que desea eliminar este método de autenticación en dos pasos?',
     'mfa_setup_action' => 'Configuración',
     'mfa_backup_codes_usage_limit_warning' => 'Quedan menos de 5 códigos de respaldo, Por favor, genera y almacena un nuevo conjunto antes de que te quedes sin códigos para evitar que te bloquees fuera de tu cuenta.',
     'mfa_option_totp_title' => 'Aplicación para móviles',
-    'mfa_option_totp_desc' => 'Para utilizar la autenticación de dos pasos necesitarás una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
+    'mfa_option_totp_desc' => 'Para utilizar la autenticación en dos pasos necesitarás una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
     'mfa_option_backup_codes_title' => 'Códigos de Respaldo',
     'mfa_option_backup_codes_desc' => 'Almacena de forma segura un conjunto de códigos de respaldo de un solo uso que puedes introducir para verificar tu identidad.',
     'mfa_gen_confirm_and_enable' => 'Confirmar y Activar',
@@ -93,7 +93,7 @@ return [
     'mfa_gen_backup_codes_download' => 'Descargar Códigos',
     'mfa_gen_backup_codes_usage_warning' => 'Cada código sólo puede utilizarse una vez',
     'mfa_gen_totp_title' => 'Configuración de Aplicación móvil',
-    'mfa_gen_totp_desc' => 'Para utilizar la autenticación de dos pasos necesitarás una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
+    'mfa_gen_totp_desc' => 'Para utilizar la autenticación en dos pasos necesitarás una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
     'mfa_gen_totp_scan' => 'Escanea el código QR mostrado a continuación usando tu aplicación de autenticación preferida para empezar.',
     'mfa_gen_totp_verify_setup' => 'Verificar Configuración',
     'mfa_gen_totp_verify_setup_desc' => 'Verifica que todo está funcionando introduciendo un código, generado en tu aplicación de autenticación, en el campo de texto a continuación:',
@@ -101,7 +101,7 @@ return [
     'mfa_verify_access' => 'Verificar Acceso',
     'mfa_verify_access_desc' => 'Tu cuenta de usuario requiere que confirmes tu identidad a través de un nivel adicional de verificación antes de que te conceda el acceso. Verifica tu identidad usando uno de los métodos configurados para continuar.',
     'mfa_verify_no_methods' => 'No hay Métodos Configurados',
-    'mfa_verify_no_methods_desc' => 'No se han encontrado métodos de autenticación de dos pasos para tu cuenta. Tendrás que configurar al menos un método antes de obtener acceso.',
+    'mfa_verify_no_methods_desc' => 'No se han encontrado métodos de autenticación en dos pasos para tu cuenta. Tendrás que configurar al menos un método antes de obtener acceso.',
     'mfa_verify_use_totp' => 'Verificar usando una aplicación móvil',
     'mfa_verify_use_backup_codes' => 'Verificar usando un código de respaldo',
     'mfa_verify_backup_code' => 'Códigos de Respaldo',
index 5986bfab3c29d2d8c551cfcd225fc0b59fb9b65e..1a7b6df4f2b3e3e6cc06baacae9388204f6c157a 100644 (file)
@@ -6,9 +6,9 @@
 return [
 
     // Shared
-    'recently_created' => 'Recientemente creado',
-    'recently_created_pages' => 'Páginas recientemente creadas',
-    'recently_updated_pages' => 'Páginas recientemente actualizadas',
+    'recently_created' => 'Creado Recientemente',
+    'recently_created_pages' => 'Páginas creadas recientemente',
+    'recently_updated_pages' => 'Páginas actualizadas recientemente',
     'recently_created_chapters' => 'Capítulos recientemente creados',
     'recently_created_books' => 'Libros recientemente creados',
     'recently_created_shelves' => 'Estantes recientemente creados',
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Permisos del estante',
     'shelves_permissions_updated' => 'Permisos del estante actualizados',
     'shelves_permissions_active' => 'Permisos del estante activos',
+    'shelves_permissions_cascade_warning' => 'Los permisos en los estantes no se aplican automáticamente a los libros contenidos. Esto se debe a que un libro puede existir en múltiples estantes. Sin embargo, los permisos pueden ser aplicados a los libros del estante utilizando la opción a continuación.',
     'shelves_copy_permissions_to_books' => 'Copiar permisos a los libros',
     'shelves_copy_permissions' => 'Copiar permisos',
     'shelves_copy_permissions_explain' => 'Esto aplicará los ajustes de permisos de este estante para todos sus libros. Antes de activarlo, asegúrese de que todos los cambios de permisos para este estante han sido guardados.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Página nueva',
     'pages_editing_draft_notification' => 'Está actualmente editando un borrador que fue guardado por última vez el :timeDiff.',
     'pages_draft_edited_notification' => 'Esta página ha sido actualizada desde ese momento. Se recomienda que cancele este borrador.',
+    'pages_draft_page_changed_since_creation' => 'Esta página ha sido actualizada desde que se creó este borrador. Se recomienda descartar este borrador o tener cuidado de no sobrescribir ningún cambio en la página.',
     'pages_draft_edit_active' => [
         'start_a' => ':count usuarios han comenzado a editar esta página',
         'start_b' => ':userName ha comenzado a editar esta página',
index 8015a45aa941951e90758087d1e921013519a8bf..bf9c89a63c79a99a320223ce1504e2abac676989 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Usuario',
     'audit_table_event' => 'Evento',
     'audit_table_related' => 'Elemento o detalle relacionados',
+    'audit_table_ip' => 'Dirección IP',
     'audit_table_date' => 'Fecha de la actividad',
     'audit_date_from' => 'Rango de fecha desde',
     'audit_date_to' => 'Rango de fecha hasta',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Administrar plantillas',
     'role_access_api' => 'API de sistema de acceso',
     'role_manage_settings' => 'Gestionar ajustes de la aplicación',
+    'role_export_content' => 'Exportar contenido',
     'role_asset' => 'Permisos de contenido',
     'roles_system_warning' => 'Tenga en cuenta que el acceso a cualquiera de los tres permisos anteriores puede permitir a un usuario alterar sus propios privilegios o los privilegios de otros en el sistema. Sólo asignar roles con estos permisos a usuarios de confianza.',
     'role_asset_desc' => 'Estos permisos controlan el acceso por defecto a los contenidos del sistema. Los permisos de Libros, Capítulos y Páginas sobreescribiran estos permisos.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 861115fc54604f3b59a317594a51ae4923864ca2..540bd4b82aaff3418e26080f99b783f2e841b5d4 100644 (file)
@@ -48,8 +48,8 @@ return [
     'favourite_remove_notification' => '".name" se eliminó de sus favoritos',
 
     // MFA
-    'mfa_setup_method_notification' => 'Método de Autenticación en Dos Pasos configurado correctamente',
-    'mfa_remove_method_notification' => 'Método de Autenticación en Dos Pasos eliminado correctamente',
+    'mfa_setup_method_notification' => 'Método de autenticación de múltiples factores configurado satisfactoriamente',
+    'mfa_remove_method_notification' => 'Método de autenticación de múltiples factores eliminado satisfactoriamente',
 
     // Other
     'commented_on'                => 'comentado',
index c57b267469582e8c03ef0bf4d960297ca5338198..cb18a9405ad75f0b2777251241289387acec4c16 100644 (file)
@@ -76,37 +76,37 @@ return [
     'user_invite_success' => 'Contraseña establecida, ahora tiene acceso a :appName!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Configurar Autenticación en Dos Pasos',
-    'mfa_setup_desc' => 'La autenticación en dos pasos añade una capa de seguridad adicional a tu cuenta de usuario.',
+    'mfa_setup' => 'Configurar autenticación de múltiples factores',
+    'mfa_setup_desc' => 'Configure la autenticación de múltiples factores como una capa extra de seguridad para su cuenta de usuario.',
     'mfa_setup_configured' => 'Ya está configurado',
     'mfa_setup_reconfigure' => 'Reconfigurar',
-    'mfa_setup_remove_confirmation' => '¿Está seguro de que desea eliminar este método de autenticación de dos pasos?',
+    'mfa_setup_remove_confirmation' => '¿Está seguro que desea eliminar este método de autenticación de múltiples factores?',
     'mfa_setup_action' => 'Configuración',
-    'mfa_backup_codes_usage_limit_warning' => 'Quedan menos de 5 códigos de respaldo, Por favor, genera y almacena un nuevo conjunto antes de que te quedes sin códigos para evitar que te bloquees fuera de tu cuenta.',
-    'mfa_option_totp_title' => 'Aplicación para móviles',
-    'mfa_option_totp_desc' => 'Para utilizar la autenticación de dos pasos necesitarás una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
+    'mfa_backup_codes_usage_limit_warning' => 'Quedan menos de 5 códigos de respaldo, Por favor, genere y guarde un nuevo conjunto antes de que se quede sin códigos para evitar que se bloquee su cuenta.',
+    'mfa_option_totp_title' => 'Aplicación móvil',
+    'mfa_option_totp_desc' => 'Para utilizar la autenticación en dos pasos necesitará una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
     'mfa_option_backup_codes_title' => 'Códigos de Respaldo',
-    'mfa_option_backup_codes_desc' => 'Almacena de forma segura un conjunto de códigos de respaldo de un solo uso que puedes introducir para verificar tu identidad.',
+    'mfa_option_backup_codes_desc' => 'Almacene de forma segura un conjunto de códigos de respaldo de un solo uso que pueda introducir para verificar su identidad.',
     'mfa_gen_confirm_and_enable' => 'Confirmar y Activar',
     'mfa_gen_backup_codes_title' => 'Configuración de Códigos de Respaldo',
-    'mfa_gen_backup_codes_desc' => 'Guarda la siguiente lista de códigos en un lugar seguro. Al acceder al sistema podrás usar uno de los códigos como un segundo mecanismo de autenticación.',
+    'mfa_gen_backup_codes_desc' => 'Guarde la siguiente lista de códigos en un lugar seguro. Al acceder al sistema podrá usar uno de los códigos como un segundo mecanismo de autenticación.',
     'mfa_gen_backup_codes_download' => 'Descargar Códigos',
-    'mfa_gen_backup_codes_usage_warning' => 'Cada código sólo puede utilizarse una vez',
+    'mfa_gen_backup_codes_usage_warning' => 'Cada código puede utilizarse sólo una vez',
     'mfa_gen_totp_title' => 'Configuración de Aplicación móvil',
-    'mfa_gen_totp_desc' => 'Para utilizar la autenticación de dos pasos necesitarás una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
+    'mfa_gen_totp_desc' => 'Para utilizar la autenticación en dos pasos necesitará una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
     'mfa_gen_totp_scan' => 'Escanea el código QR mostrado a continuación usando tu aplicación de autenticación preferida para empezar.',
     'mfa_gen_totp_verify_setup' => 'Verificar Configuración',
     'mfa_gen_totp_verify_setup_desc' => 'Verifica que todo está funcionando introduciendo un código, generado en tu aplicación de autenticación, en el campo de texto a continuación:',
     'mfa_gen_totp_provide_code_here' => 'Introduce aquí tu código generado por la aplicación',
     'mfa_verify_access' => 'Verificar Acceso',
-    'mfa_verify_access_desc' => 'Tu cuenta de usuario requiere que confirmes tu identidad a través de un nivel adicional de verificación antes de que te conceda el acceso. Verifica tu identidad usando uno de los métodos configurados para continuar.',
+    'mfa_verify_access_desc' => 'Su cuenta de usuario requiere que confirme su identidad a través de un nivel adicional de verificación antes de que se le conceda el acceso. Verifique su identidad usando uno de los métodos configurados para continuar.',
     'mfa_verify_no_methods' => 'No hay Métodos Configurados',
-    'mfa_verify_no_methods_desc' => 'No se han encontrado métodos de autenticación de dos pasos para tu cuenta. Tendrás que configurar al menos un método antes de obtener acceso.',
+    'mfa_verify_no_methods_desc' => 'No se han encontrado métodos de autenticación de múltiples factores para su cuenta. Tendrá que configurar al menos un método antes de obtener acceso.',
     'mfa_verify_use_totp' => 'Verificar usando una aplicación móvil',
     'mfa_verify_use_backup_codes' => 'Verificar usando un código de respaldo',
-    'mfa_verify_backup_code' => 'Códigos de Respaldo',
+    'mfa_verify_backup_code' => 'Código de Respaldo',
     'mfa_verify_backup_code_desc' => 'Introduzca uno de sus códigos de respaldo restantes a continuación:',
-    'mfa_verify_backup_code_enter_here' => 'Introduce el código de respaldo aquí',
-    'mfa_verify_totp_desc' => 'Introduzca el código, generado con tu aplicación móvil, a continuación:',
-    'mfa_setup_login_notification' => 'Método de dos factores configurado. Por favor, inicia sesión de nuevo utilizando el método configurado.',
+    'mfa_verify_backup_code_enter_here' => 'Introduzca el código de respaldo aquí',
+    'mfa_verify_totp_desc' => 'A continuación, introduzca el código generado con su aplicación móvil:',
+    'mfa_setup_login_notification' => 'Método de dos factores configurado. Por favor, inicie sesión nuevamente utilizando el método configurado.',
 ];
\ No newline at end of file
index 1809cd828289012d9d8da3905468f15eeb44e39d..bf08bdb512a53c7dbc051a7cfc1ce3c10b8ac0ba 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Permisos del Estante',
     'shelves_permissions_updated' => 'Permisos del Estante actualizados',
     'shelves_permissions_active' => 'Permisos Activos del Estante',
+    'shelves_permissions_cascade_warning' => 'Los permisos en los estantes no se aplican automáticamente a los libros contenidos. Esto se debe a que un libro puede existir en múltiples estantes. Sin embargo, los permisos pueden ser aplicados a los libros del estante utilizando la opción a continuación.',
     'shelves_copy_permissions_to_books' => 'Copiar Permisos a los Libros',
     'shelves_copy_permissions' => 'Copiar Permisos',
     'shelves_copy_permissions_explain' => 'Esta acción aplicará los permisos de este estante a todos los libros contenidos en él. Antes de activarlos, asegúrese que los cambios a los permisos de este estante estén guardados.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Página nueva',
     'pages_editing_draft_notification' => 'Usted está actualmente editando un borrador que fue guardado por última vez el :timeDiff.',
     'pages_draft_edited_notification' => 'Esta página ha sido actualizada desde aquel momento. Se recomienda que cancele este borrador.',
+    'pages_draft_page_changed_since_creation' => 'Esta página fue actualizada desde que se creó este borrador. Se recomienda descartar este borrador o tener cuidado de no sobrescribir ningún cambio en la página.',
     'pages_draft_edit_active' => [
         'start_a' => ':count usuarios han comenzado a editar esta página',
         'start_b' => ':userName ha comenzado a editar esta página',
index 31465bde124a7906e487fd9f90478d88f2d0cfe0..027f224211d0cad2d3c6a25ca0b6cf5400ffffc3 100644 (file)
@@ -92,7 +92,7 @@ return [
     'recycle_bin' => 'Papelera de Reciclaje',
     'recycle_bin_desc' => 'Aquí puede restaurar elementos que hayan sido eliminados o elegir eliminarlos permanentemente del sistema. Esta lista no está filtrada a diferencia de las listas de actividad similares en el sistema donde se aplican los filtros de permisos.',
     'recycle_bin_deleted_item' => 'Elemento Eliminado',
-    'recycle_bin_deleted_parent' => 'Superior',
+    'recycle_bin_deleted_parent' => 'Padre',
     'recycle_bin_deleted_by' => 'Eliminado por',
     'recycle_bin_deleted_at' => 'Fecha de eliminación',
     'recycle_bin_permanently_delete' => 'Eliminar permanentemente',
@@ -105,7 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Elementos a restaurar',
     'recycle_bin_restore_confirm' => 'Esta acción restaurará el elemento eliminado, incluyendo cualquier elemento secundario, a su ubicación original. Si la ubicación original ha sido eliminada, y ahora está en la papelera de reciclaje, el elemento padre también tendrá que ser restaurado.',
     'recycle_bin_restore_deleted_parent' => 'El padre de este elemento también ha sido eliminado. Estos permanecerán eliminados hasta que el padre también sea restaurado.',
-    'recycle_bin_restore_parent' => 'Restaurar Superior',
+    'recycle_bin_restore_parent' => 'Restaurar Padre',
     'recycle_bin_destroy_notification' => 'Eliminados :count elementos de la papelera de reciclaje.',
     'recycle_bin_restore_notification' => 'Restaurados :count elementos desde la papelera de reciclaje.',
 
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Usuario',
     'audit_table_event' => 'Evento',
     'audit_table_related' => 'Elemento o detalle relacionados',
+    'audit_table_ip' => 'Dirección IP',
     'audit_table_date' => 'Fecha de la Actividad',
     'audit_date_from' => 'Inicio del Rango de Fecha',
     'audit_date_to' => 'Final del Rango de Fecha',
@@ -138,7 +139,7 @@ return [
     'role_details' => 'Detalles de rol',
     'role_name' => 'Nombre de rol',
     'role_desc' => 'Descripción corta de rol',
-    'role_mfa_enforced' => 'Requiere Autenticación en Dos Pasos',
+    'role_mfa_enforced' => 'Requiere autenticación de múltiples factores',
     'role_external_auth_id' => 'IDs de Autenticación Externa',
     'role_system' => 'Permisos de sistema',
     'role_manage_users' => 'Gestionar usuarios',
@@ -149,6 +150,7 @@ return [
     'role_manage_page_templates' => 'Gestionar las plantillas de páginas',
     'role_access_api' => 'API de sistema de acceso',
     'role_manage_settings' => 'Gestionar ajustes de activos',
+    'role_export_content' => 'Exportar contenido',
     'role_asset' => 'Permisos de activos',
     'roles_system_warning' => 'Tenga en cuenta que el acceso a cualquiera de los tres permisos anteriores puede permitir a un usuario modificar sus propios privilegios o los privilegios de otros usuarios en el sistema. Asignar roles con estos permisos sólo a usuarios de comfianza.',
     'role_asset_desc' => 'Estos permisos controlan el acceso por defecto a los activos del sistema. Permisos definidos en Libros, Capítulos y Páginas ignorarán estos permisos.',
@@ -206,8 +208,8 @@ return [
     'users_api_tokens_create' => 'Crear token',
     'users_api_tokens_expires' => 'Expira',
     'users_api_tokens_docs' => 'Documentación API',
-    'users_mfa' => 'Autenticación en Dos Pasos',
-    'users_mfa_desc' => 'La autenticación en dos pasos añade una capa de seguridad adicional a tu cuenta.',
+    'users_mfa' => 'Autenticación de múltiples factores',
+    'users_mfa_desc' => 'Configure la autenticación de múltiples factores como una capa extra de seguridad para su cuenta de usuario.',
     'users_mfa_x_methods' => ':count método configurado|:count métodos configurados',
     'users_mfa_configure' => 'Configurar Métodos',
 
@@ -255,6 +257,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 2cc8ed9bf442d69a50a06743521f709b5790bbeb..6b72a65499d03c55c9982efd63fe5b93385422f6 100644 (file)
@@ -15,7 +15,7 @@ return [
     'alpha_dash'           => 'El :attribute solo puede contener letras, números y guiones.',
     'alpha_num'            => 'El :attribute solo puede contener letras y número.',
     'array'                => 'El :attribute debe de ser un array.',
-    'backup_codes'         => 'El código suministrado no es válido o ya ha sido utilizado.',
+    'backup_codes'         => 'El código suministrado no es válido o ya fue utilizado.',
     'before'               => 'El :attribute debe ser una fecha anterior a  :date.',
     'between'              => [
         'numeric' => 'El :attribute debe estar entre :min y :max.',
@@ -99,7 +99,7 @@ return [
     ],
     'string'               => 'El atributo :attribute debe ser una cadena.',
     'timezone'             => 'El atributo :attribute debe ser una zona válida.',
-    'totp'                 => 'El código suministrado no es válido o ya ha expirado.',
+    'totp'                 => 'El código suministrado no es válido o ya expiró.',
     'unique'               => 'El atributo :attribute ya ha sido tomado.',
     'url'                  => 'El atributo :attribute tiene un formato inválido.',
     'uploaded'             => 'El archivo no se pudo subir. Puede ser que el servidor no acepte archivos de este tamaño.',
index 138dbd739c76425497aa6c1bc1eee35c960574e1..43b6b4789c9bdb8039d9f12b2c949115c5d55578 100644 (file)
@@ -6,9 +6,9 @@
 return [
 
     // Pages
-    'page_create'                 => 'اÛ\8cجاد ØµÙ\81Ø­ه',
+    'page_create'                 => 'صÙ\81Ø­Ù\87 Ø§Û\8cجاد Ø´Ø¯ه',
     'page_create_notification'    => 'صفحه با موفقیت ایجاد شد',
-    'page_update'                 => 'بÙ\87 Ø±Ù\88زرساÙ\86Û\8c ØµÙ\81Ø­ه',
+    'page_update'                 => 'صÙ\81Ø­Ù\87 Ø¨Ø±Ù\88ز Ø´Ø¯ه',
     'page_update_notification'    => 'صفحه با موفقیت به روزرسانی شد',
     'page_delete'                 => 'حذف صفحه',
     'page_delete_notification'    => 'صفحه با موفقیت حذف شد',
index 23da673c3a520b21ea3fc25134aeba1f30822c0e..4f950b77f0482e8e28cfa77333d6821f6b1e18ec 100644 (file)
@@ -76,12 +76,12 @@ return [
     'user_invite_success' => 'کلمه عبور تنظیم شده است، شما اکنون به :appName دسترسی دارید!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Setup Multi-Factor Authentication',
-    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'mfa_setup_configured' => 'Already configured',
-    'mfa_setup_reconfigure' => 'Reconfigure',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
-    'mfa_setup_action' => 'Setup',
+    'mfa_setup' => 'تنظیم احراز هویت چند مرحله‌ای',
+    'mfa_setup_desc' => 'تنظیم احراز هویت چند مرحله ای یک لایه امنیتی دیگر به حساب شما اضافه میکند.',
+    'mfa_setup_configured' => 'هم اکنون تنظیم شده است.',
+    'mfa_setup_reconfigure' => 'تنظیم مجدد',
+    'mfa_setup_remove_confirmation' => 'از حذف احراز هویت چند مرحله ای اطمینان دارید؟',
+    'mfa_setup_action' => 'تنظیم',
     'mfa_backup_codes_usage_limit_warning' => '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.',
     'mfa_option_totp_title' => 'Mobile App',
     'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
index 8d2c052696e73ed5aa27f12cf84e69a9c472adf0..47335af8e1a664e591e0a601953dff8f1a4fbf50 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Bookshelf Permissions',
     'shelves_permissions_updated' => 'Bookshelf Permissions Updated',
     'shelves_permissions_active' => 'Bookshelf Permissions Active',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
     'shelves_copy_permissions' => 'Copy Permissions',
     'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'New Page',
     'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.',
     'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count users have started editing this page',
         'start_b' => ':userName has started editing this page',
index 87f672e4d41937793dc71a4dd1be5ef56317e83e..0ab168b66998bca0de4807f94d181b9b12ed1683 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'User',
     'audit_table_event' => 'Event',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Activity Date',
     'audit_date_from' => 'Date Range From',
     'audit_date_to' => 'Date Range To',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Manage page templates',
     'role_access_api' => 'Access system API',
     'role_manage_settings' => 'Manage app settings',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Asset Permissions',
     'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
     'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index df6cacf1747ec9b5c1b3cddea35fcedd8838dc5f..9850bc93cd23068bd5d14f23987dd5594323a325 100644 (file)
@@ -26,11 +26,11 @@ return [
     'chapter_move'                => 'a déplacé le chapitre',
 
     // Books
-    'book_create'                 => 'a créé le livre',
+    'book_create'                 => 'a créé un livre',
     'book_create_notification'    => 'Livre créé avec succès',
     'book_update'                 => 'a modifié le livre',
     'book_update_notification'    => 'Livre modifié avec succès',
-    'book_delete'                 => 'a supprimé le livre',
+    'book_delete'                 => 'a supprimé un livre',
     'book_delete_notification'    => 'Livre supprimé avec succès',
     'book_sort'                   => 'a réordonné le livre',
     'book_sort_notification'      => 'Livre réordonné avec succès',
@@ -48,10 +48,10 @@ return [
     'favourite_remove_notification' => '":name" a été supprimé de vos favoris',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => 'Méthode multi-facteurs configurée avec succès',
+    'mfa_remove_method_notification' => 'Méthode multi-facteurs supprimée avec succès',
 
     // Other
     'commented_on'                => 'a commenté',
-    'permissions_update'          => 'mettre à jour les autorisations',
+    'permissions_update'          => 'a mis à jour les autorisations sur',
 ];
index 630b152f1dfdf57cd0721c8a8bf72bd44cdcb9ff..c608f02fb043359e3d8bd7f05edbf490739b8a2a 100644 (file)
@@ -57,13 +57,13 @@ return [
     'email_confirm_action' => 'Confirmez votre adresse e-mail',
     'email_confirm_send_error' => 'La confirmation par e-mail est requise mais le système n\'a pas pu envoyer l\'e-mail. Contactez l\'administrateur système.',
     'email_confirm_success' => 'Votre adresse e-mail a été confirmée !',
-    'email_confirm_resent' => 'L\'e-mail de confirmation a été ré-envoyé. Vérifiez votre boîte de récéption.',
+    'email_confirm_resent' => 'L\'e-mail de confirmation a été ré-envoyé. Vérifiez votre boîte de réception.',
 
     'email_not_confirmed' => 'Adresse e-mail non confirmée',
     'email_not_confirmed_text' => 'Votre adresse e-mail n\'a pas été confirmée.',
     'email_not_confirmed_click_link' => 'Merci de cliquer sur le lien dans l\'e-mail qui vous a été envoyé après l\'enregistrement.',
     'email_not_confirmed_resend' => 'Si vous ne retrouvez plus l\'e-mail, vous pouvez renvoyer un e-mail de confirmation en utilisant le formulaire ci-dessous.',
-    'email_not_confirmed_resend_button' => 'Renvoyez l\'e-mail de confirmation',
+    'email_not_confirmed_resend_button' => 'Renvoyer l\'e-mail de confirmation',
 
     // User Invite
     'user_invite_email_subject' => 'Vous avez été invité(e) à rejoindre :appName !',
@@ -76,37 +76,37 @@ return [
     'user_invite_success' => 'Mot de passe renseigné, vous avez maintenant accès à :appName !',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Setup Multi-Factor Authentication',
-    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'mfa_setup_configured' => 'Already configured',
-    'mfa_setup_reconfigure' => 'Reconfigure',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
-    'mfa_setup_action' => 'Setup',
-    'mfa_backup_codes_usage_limit_warning' => '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.',
-    'mfa_option_totp_title' => 'Mobile App',
-    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_option_backup_codes_title' => 'Backup Codes',
-    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
-    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
-    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
-    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
-    'mfa_gen_backup_codes_download' => 'Download Codes',
-    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
-    'mfa_gen_totp_title' => 'Mobile App Setup',
-    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
-    'mfa_gen_totp_verify_setup' => 'Verify Setup',
-    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
-    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
-    'mfa_verify_access' => 'Verify Access',
-    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
-    'mfa_verify_no_methods' => 'No Methods Configured',
-    'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
-    'mfa_verify_use_totp' => 'Verify using a mobile app',
-    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
-    'mfa_verify_backup_code' => 'Backup Code',
-    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
-    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
-    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
-    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+    'mfa_setup' => 'Authentification multi-facteurs',
+    'mfa_setup_desc' => 'Configurer l\'authentification multi-facteurs ajoute une couche supplémentaire de sécurité à votre compte utilisateur.',
+    'mfa_setup_configured' => 'Déjà configuré',
+    'mfa_setup_reconfigure' => 'Reconfigurer',
+    'mfa_setup_remove_confirmation' => 'Êtes-vous sûr de vouloir supprimer cette méthode d\'authentification multi-facteurs ?',
+    'mfa_setup_action' => 'Configuration',
+    'mfa_backup_codes_usage_limit_warning' => 'Il vous reste moins de 5 codes de secours, veuillez générer et stocker un nouveau jeu de codes afin d\'éviter tout verrouillage de votre compte.',
+    'mfa_option_totp_title' => 'Application mobile',
+    'mfa_option_totp_desc' => 'Pour utiliser l\'authentification multi-facteurs, vous aurez besoin d\'une application mobile qui supporte TOTP comme Google Authenticator, Authy ou Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Codes de secours',
+    'mfa_option_backup_codes_desc' => 'Stockez en toute sécurité un jeu de codes de secours que vous pourrez utiliser pour vérifier votre identité.',
+    'mfa_gen_confirm_and_enable' => 'Confirmer et activer',
+    'mfa_gen_backup_codes_title' => 'Configuration des codes de secours',
+    'mfa_gen_backup_codes_desc' => 'Stockez la liste des codes ci-dessous dans un endroit sûr. Lorsque vous accédez au système, vous pourrez utiliser l\'un des codes comme un deuxième mécanisme d\'authentification.',
+    'mfa_gen_backup_codes_download' => 'Télécharger les codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Chaque code ne peut être utilisé qu\'une seule fois',
+    'mfa_gen_totp_title' => 'Configuration de l\'application mobile',
+    'mfa_gen_totp_desc' => 'Pour utiliser l\'authentification multi-facteurs, vous aurez besoin d\'une application mobile qui supporte TOTP comme Google Authenticator, Authy ou Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scannez le QR code ci-dessous avec votre application d\'authentification préférée pour débuter.',
+    'mfa_gen_totp_verify_setup' => 'Vérifier la configuration',
+    'mfa_gen_totp_verify_setup_desc' => 'Vérifiez que tout fonctionne en utilisant un code généré par votre application d\'authentification, dans la zone ci-dessous :',
+    'mfa_gen_totp_provide_code_here' => 'Fournissez le code généré par votre application ici',
+    'mfa_verify_access' => 'Vérifier l\'accès',
+    'mfa_verify_access_desc' => 'Votre compte d\'utilisateur vous demande de confirmer votre identité par un niveau supplémentaire de vérification avant que vous n\'ayez accès. Vérifiez-la en utilisant l\'une de vos méthodes configurées pour continuer.',
+    'mfa_verify_no_methods' => 'Aucune méthode configurée',
+    'mfa_verify_no_methods_desc' => 'Aucune méthode d\'authentification multi-facteurs n\'a pu être trouvée pour votre compte. Vous devez configurer au moins une méthode avant d\'obtenir l\'accès.',
+    'mfa_verify_use_totp' => 'Vérifier à l\'aide d\'une application mobile',
+    'mfa_verify_use_backup_codes' => 'Vérifier en utilisant un code de secours',
+    'mfa_verify_backup_code' => 'Code de secours',
+    'mfa_verify_backup_code_desc' => 'Entrez l\'un de vos codes de secours restants ci-dessous :',
+    'mfa_verify_backup_code_enter_here' => 'Saisissez un code de secours ici',
+    'mfa_verify_totp_desc' => 'Entrez ci-dessous le code généré à l\'aide de votre application mobile :',
+    'mfa_setup_login_notification' => 'Méthode multi-facteurs configurée. Veuillez maintenant vous reconnecter en utilisant la méthode configurée.',
 ];
\ No newline at end of file
index 1801aba410353459dc4c698c5cf8a859f4f8b865..e6e8bf1a272100e5c92e481de4292e1edc7f5bff 100644 (file)
@@ -27,19 +27,19 @@ return [
     'view_all' => 'Tout afficher',
     'create' => 'Créer',
     'update' => 'Modifier',
-    'edit' => 'Editer',
+    'edit' => 'Éditer',
     'sort' => 'Trier',
     'move' => 'Déplacer',
     'copy' => 'Copier',
     'reply' => 'Répondre',
     'delete' => 'Supprimer',
     'delete_confirm' => 'Confirmer la suppression',
-    'search' => 'Chercher',
+    'search' => 'Rechercher',
     'search_clear' => 'Réinitialiser la recherche',
     'reset' => 'Réinitialiser',
     'remove' => 'Enlever',
     'add' => 'Ajouter',
-    'configure' => 'Configure',
+    'configure' => 'Configurer',
     'fullscreen' => 'Plein écran',
     'favourite' => 'Favoris',
     'unfavourite' => 'Supprimer des favoris',
index 6cce4f80418f1e5967daceee7b8e72b30b0f28a6..fed157a4793ff589ffc975026859b157c2062a96 100644 (file)
@@ -26,7 +26,7 @@ return [
     'image_upload_remove' => 'Supprimer',
 
     // Code Editor
-    'code_editor' => 'Editer le code',
+    'code_editor' => 'Éditer le code',
     'code_language' => 'Langage du code',
     'code_content' => 'Contenu du code',
     'code_session_history' => 'Historique de session',
index 229e4229014dc35a914eeb963eafa610e4cbd897..360780a25aaf9d193830bb74e846e201b52d0d2d 100644 (file)
@@ -22,12 +22,12 @@ return [
     'meta_created_name' => 'Créé :timeLength par :user',
     'meta_updated' => 'Mis à jour :timeLength',
     'meta_updated_name' => 'Mis à jour :timeLength par :user',
-    'meta_owned_name' => 'Possédé par :user',
+    'meta_owned_name' => 'Appartient à :user',
     'entity_select' => 'Sélectionner l\'entité',
     'images' => 'Images',
     'my_recent_drafts' => 'Mes brouillons récents',
     'my_recently_viewed' => 'Vus récemment',
-    'my_most_viewed_favourites' => 'Mes Favoris les plus vus',
+    'my_most_viewed_favourites' => 'Mes favoris les plus vus',
     'my_favourites' => 'Mes favoris',
     'no_pages_viewed' => 'Vous n\'avez rien visité récemment',
     'no_pages_recently_created' => 'Aucune page créée récemment',
@@ -40,7 +40,7 @@ return [
 
     // Permissions and restrictions
     'permissions' => 'Autorisations',
-    'permissions_intro' => 'Une fois activées ces permissions prendront la priorité sur tous les sets de permissions préexistants.',
+    'permissions_intro' => 'Une fois activées, ces permissions auront la priorité sur tous les jeux de permissions préexistants.',
     'permissions_enable' => 'Activer les permissions personnalisées',
     'permissions_save' => 'Enregistrer les permissions',
     'permissions_owner' => 'Propriétaire',
@@ -80,8 +80,8 @@ return [
     'shelves_empty' => 'Aucune étagère n\'a été créée',
     'shelves_create' => 'Créer une nouvelle étagère',
     'shelves_popular' => 'Étagères populaires',
-    'shelves_new' => 'Nouvelles Ã\89tagères',
-    'shelves_new_action' => 'Nouvelle Ã\89tagère',
+    'shelves_new' => 'Nouvelles Ã©tagères',
+    'shelves_new_action' => 'Nouvelle Ã©tagère',
     'shelves_popular_empty' => 'Les étagères les plus populaires apparaîtront ici.',
     'shelves_new_empty' => 'Les étagères les plus récentes apparaitront ici.',
     'shelves_save' => 'Enregistrer l\'étagère',
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Permissions de l\'étagère',
     'shelves_permissions_updated' => 'Permissions de l\'étagère mises à jour',
     'shelves_permissions_active' => 'Permissions de l\'étagère activées',
+    'shelves_permissions_cascade_warning' => 'Les permissions sur les étagères ne sont pas automatiquement recopiées aux livres qu\'elles contiennent, car un livre peut exister dans plusieurs étagères. Les permissions peuvent cependant être recopiées vers les livres contenus en utilisant l\'option ci-dessous.',
     'shelves_copy_permissions_to_books' => 'Copier les permissions vers les livres',
     'shelves_copy_permissions' => 'Copier les permissions',
     'shelves_copy_permissions_explain' => 'Ceci va appliquer les permissions actuelles de cette étagère à tous les livres qu\'elle contient. Avant de  continuer, assurez-vous que toutes les permissions de cette étagère ont été sauvegardées.',
@@ -131,7 +132,7 @@ return [
     'books_empty_sort_current_book' => 'Trier les pages du livre',
     'books_empty_add_chapter' => 'Ajouter un chapitre',
     'books_permissions_active' => 'Permissions personnalisées activées',
-    'books_search_this' => 'Chercher dans le livre',
+    'books_search_this' => 'Rechercher dans ce livre',
     'books_navigation' => 'Navigation dans le livre',
     'books_sort' => 'Trier les contenus du livre',
     'books_sort_named' => 'Trier le livre :bookName',
@@ -173,7 +174,7 @@ return [
     'pages_popular' => 'Pages populaires',
     'pages_new' => 'Nouvelle page',
     'pages_attachments' => 'Fichiers joints',
-    'pages_navigation' => 'Navigation des pages',
+    'pages_navigation' => 'Navigation dans la page',
     'pages_delete' => 'Supprimer la page',
     'pages_delete_named' => 'Supprimer la page :pageName',
     'pages_delete_draft_named' => 'supprimer le brouillon de la page :pageName',
@@ -188,16 +189,16 @@ return [
     'pages_edit_draft' => 'Modifier le brouillon',
     'pages_editing_draft' => 'Modification du brouillon',
     'pages_editing_page' => 'Modification de la page',
-    'pages_edit_draft_save_at' => 'Brouillon sauvé le ',
+    'pages_edit_draft_save_at' => 'Brouillon enregistré le ',
     'pages_edit_delete_draft' => 'Supprimer le brouillon',
-    'pages_edit_discard_draft' => 'Ecarter le brouillon',
+    'pages_edit_discard_draft' => 'Jeter le brouillon',
     'pages_edit_set_changelog' => 'Remplir le journal des changements',
     'pages_edit_enter_changelog_desc' => 'Entrez une brève description des changements effectués',
-    'pages_edit_enter_changelog' => 'Entrer dans le journal des changements',
-    'pages_save' => 'Enregistrez la page',
+    'pages_edit_enter_changelog' => 'Ouvrir le journal des changements',
+    'pages_save' => 'Enregistrer la page',
     'pages_title' => 'Titre de la page',
     'pages_name' => 'Nom de la page',
-    'pages_md_editor' => 'Editeur',
+    'pages_md_editor' => 'Éditeur',
     'pages_md_preview' => 'Prévisualisation',
     'pages_md_insert_image' => 'Insérer une image',
     'pages_md_insert_link' => 'Insérer un lien',
@@ -222,7 +223,7 @@ return [
     'pages_revisions_numbered_changes' => 'Modification #:id',
     'pages_revisions_changelog' => 'Journal des changements',
     'pages_revisions_changes' => 'Changements',
-    'pages_revisions_current' => 'Version courante',
+    'pages_revisions_current' => 'Version actuelle',
     'pages_revisions_preview' => 'Prévisualisation',
     'pages_revisions_restore' => 'Restaurer',
     'pages_revisions_none' => 'Cette page n\'a aucune révision',
@@ -231,8 +232,9 @@ return [
     'pages_permissions_active' => 'Permissions de page actives',
     'pages_initial_revision' => 'Publication initiale',
     'pages_initial_name' => 'Nouvelle page',
-    'pages_editing_draft_notification' => 'Vous éditez actuellement un brouillon qui a été sauvé :timeDiff.',
-    'pages_draft_edited_notification' => 'La page a été mise à jour depuis votre dernière visite. Vous devriez écarter ce brouillon.',
+    'pages_editing_draft_notification' => 'Vous éditez actuellement un brouillon qui a été enregistré :timeDiff.',
+    'pages_draft_edited_notification' => 'La page a été mise à jour depuis votre dernière visite. Vous devriez jeter ce brouillon.',
+    'pages_draft_page_changed_since_creation' => 'Cette page a été mise à jour depuis que ce brouillon a été créé. Il est recommandé de supprimer ce brouillon ou de veiller à ne pas écraser toute modification de page.',
     'pages_draft_edit_active' => [
         'start_a' => ':count utilisateurs ont commencé à éditer cette page',
         'start_b' => ':userName a commencé à éditer cette page',
@@ -241,7 +243,7 @@ return [
         'message' => ':start :time. Attention à ne pas écraser les mises à jour de quelqu\'un d\'autre !',
     ],
     'pages_draft_discarded' => 'Brouillon écarté, la page est dans sa version actuelle.',
-    'pages_specific' => 'Page Spécifique',
+    'pages_specific' => 'Page spécifique',
     'pages_is_template' => 'Modèle de page',
 
     // Editor Sidebar
@@ -252,10 +254,10 @@ return [
     'tag' => 'Mot-clé',
     'tags' =>  'Mots-clés',
     'tag_name' =>  'Nom du tag',
-    'tag_value' => 'Valeur du mot-clé (Optionnel)',
+    'tag_value' => 'Valeur du mot-clé (optionnel)',
     'tags_explain' => "Ajouter des mots-clés pour catégoriser votre contenu.",
     'tags_add' => 'Ajouter un autre mot-clé',
-    'tags_remove' => 'Supprimer le tag',
+    'tags_remove' => 'Supprimer le mot-clé',
     'attachments' => 'Fichiers joints',
     'attachments_explain' => 'Ajouter des fichiers ou des liens pour les afficher sur votre page. Ils seront affichés dans la barre latérale',
     'attachments_explain_instant_save' => 'Ces changements sont enregistrés immédiatement.',
@@ -266,13 +268,13 @@ return [
     'attachments_delete' => 'Êtes-vous sûr de vouloir supprimer la pièce jointe ?',
     'attachments_dropzone' => 'Glissez des fichiers ou cliquez ici pour attacher des fichiers',
     'attachments_no_files' => 'Aucun fichier ajouté',
-    'attachments_explain_link' => 'Vous pouvez attacher un lien si vous ne souhaitez pas uploader un fichier.',
+    'attachments_explain_link' => 'Vous pouvez ajouter un lien si vous ne souhaitez pas uploader un fichier.',
     'attachments_link_name' => 'Nom du lien',
     'attachment_link' => 'Lien de l\'attachement',
     'attachments_link_url' => 'Lien sur un fichier',
     'attachments_link_url_hint' => 'URL du site ou du fichier',
-    'attach' => 'Attacher',
-    'attachments_insert_link' => 'Ajouter un lien de pièce jointe à la page',
+    'attach' => 'Ajouter',
+    'attachments_insert_link' => 'Ajouter un lien à la page',
     'attachments_edit_file' => 'Modifier le fichier',
     'attachments_edit_file_name' => 'Nom du fichier',
     'attachments_edit_drop_upload' => 'Glissez un fichier ou cliquer pour mettre à jour le fichier',
@@ -287,7 +289,7 @@ return [
     'templates_explain_set_as_template' => 'Vous pouvez définir cette page comme modèle pour que son contenu soit utilisé lors de la création d\'autres pages. Les autres utilisateurs pourront utiliser ce modèle s\'ils ont les permissions pour cette page.',
     'templates_replace_content' => 'Remplacer le contenu de la page',
     'templates_append_content' => 'Ajouter après le contenu de la page',
-    'templates_prepend_content' => 'Ajouter devant le contenu de la page',
+    'templates_prepend_content' => 'Ajouter avant le contenu de la page',
 
     // Profile View
     'profile_user_for_x' => 'Utilisateur depuis :time',
@@ -312,7 +314,7 @@ return [
     'comment_deleted_success' => 'Commentaire supprimé',
     'comment_created_success' => 'Commentaire ajouté',
     'comment_updated_success' => 'Commentaire mis à jour',
-    'comment_delete_confirm' => 'Etes-vous sûr de vouloir supprimer ce commentaire ?',
+    'comment_delete_confirm' => 'Êtes-vous sûr de vouloir supprimer ce commentaire ?',
     'comment_in_reply_to' => 'En réponse à :commentId',
 
     // Revision
index 511683285649e13aa9177451af9b85f8655400f2..d7f00d8e1891b149ad4615ecf73b2f077a9be42f 100644 (file)
@@ -16,24 +16,24 @@ return [
     'email_confirmation_awaiting' => 'L\'adresse e-mail du compte utilisé doit être confirmée',
     'ldap_fail_anonymous' => 'L\'accès LDAP anonyme n\'a pas abouti',
     'ldap_fail_authed' => 'L\'accès LDAP n\'a pas abouti avec cet utilisateur et ce mot de passe',
-    'ldap_extension_not_installed' => 'L\'extension LDAP PHP n\'est pas installée',
+    'ldap_extension_not_installed' => 'L\'extension PHP LDAP n\'est pas installée',
     'ldap_cannot_connect' => 'Impossible de se connecter au serveur LDAP, la connexion initiale a échoué',
     'saml_already_logged_in' => 'Déjà connecté',
     'saml_user_not_registered' => 'L\'utilisateur :name n\'est pas enregistré et l\'enregistrement automatique est désactivé',
     'saml_no_email_address' => 'Impossible de trouver une adresse e-mail, pour cet utilisateur, dans les données fournies par le système d\'authentification externe',
     'saml_invalid_response_id' => 'La requête du système d\'authentification externe n\'est pas reconnue par un processus démarré par cette application. Naviguer après une connexion peut causer ce problème.',
-    'saml_fail_authed' => 'Connexion avec :system échoue, le système n\'a pas fourni l\'autorisation réussie',
+    'saml_fail_authed' => 'Connexion avec :system échouée, le système n\'a pas fourni l\'autorisation réussie',
     'social_no_action_defined' => 'Pas d\'action définie',
     'social_login_bad_response' => "Erreur pendant la tentative de connexion à :socialAccount : \n:error",
     'social_account_in_use' => 'Ce compte :socialAccount est déjà utilisé. Essayez de vous connecter via :socialAccount.',
-    'social_account_email_in_use' => 'L\'email :email est déjà utilisé. Si vous avez déjà un compte :socialAccount, vous pouvez le joindre à votre profil existant.',
+    'social_account_email_in_use' => 'L\'email :email est déjà utilisé. Si vous avez déjà un compte :socialAccount, vous pouvez le rattacher à votre profil existant.',
     'social_account_existing' => 'Ce compte :socialAccount est déjà rattaché à votre profil.',
     'social_account_already_used_existing' => 'Ce compte :socialAccount est déjà utilisé par un autre utilisateur.',
     'social_account_not_used' => 'Ce compte :socialAccount n\'est lié à aucun utilisateur. ',
-    'social_account_register_instructions' => 'Si vous n\'avez pas encore de compte, vous pouvez le lier avec l\'option :socialAccount.',
-    'social_driver_not_found' => 'Pilote de compte social absent',
+    'social_account_register_instructions' => 'Si vous n\'avez pas encore de compte, vous pouvez en créer un avec l\'option :socialAccount.',
+    'social_driver_not_found' => 'Pilote de compte de réseaux sociaux absent',
     'social_driver_not_configured' => 'Vos préférences pour le compte :socialAccount sont incorrectes.',
-    'invite_token_expired' => 'Le lien de cette invitation a expiré. Vous pouvez essayer de réinitiliser votre mot de passe.',
+    'invite_token_expired' => 'Le lien de cette invitation a expiré. Vous pouvez essayer de réinitialiser votre mot de passe.',
 
     // System
     'path_not_writable' => 'Impossible d\'écrire dans :filePath. Assurez-vous d\'avoir les droits d\'écriture sur le serveur',
@@ -42,14 +42,14 @@ return [
     'server_upload_limit' => 'La taille du fichier est trop grande.',
     'uploaded'  => 'Le serveur n\'autorise pas l\'envoi d\'un fichier de cette taille. Veuillez essayer avec une taille de fichier réduite.',
     'image_upload_error' => 'Une erreur est survenue pendant l\'envoi de l\'image',
-    'image_upload_type_error' => 'LE format de l\'image envoyée n\'est pas valide',
+    'image_upload_type_error' => 'Le format de l\'image envoyée n\'est pas valide',
     'file_upload_timeout' => 'Le téléchargement du fichier a expiré.',
 
     // Attachments
     'attachment_not_found' => 'Fichier joint non trouvé',
 
     // Pages
-    'page_draft_autosave_fail' => 'Le brouillon n\'a pas pu être sauvé. Vérifiez votre connexion internet',
+    'page_draft_autosave_fail' => 'Le brouillon n\'a pas pu être enregistré. Vérifiez votre connexion internet',
     'page_custom_home_deletion' => 'Impossible de supprimer une page définie comme page d\'accueil',
 
     // Entities
@@ -60,10 +60,10 @@ return [
     'chapter_not_found' => 'Chapitre non trouvé',
     'selected_book_not_found' => 'Ce livre n\'a pas été trouvé',
     'selected_book_chapter_not_found' => 'Ce livre ou chapitre n\'a pas été trouvé',
-    'guests_cannot_save_drafts' => 'Les invités ne peuvent pas sauver de brouillons',
+    'guests_cannot_save_drafts' => 'Les invités ne peuvent pas enregistrer de brouillons',
 
     // Users
-    'users_cannot_delete_only_admin' => 'Vous ne pouvez pas supprimer le dernier admin',
+    'users_cannot_delete_only_admin' => 'Vous ne pouvez pas supprimer le dernier administrateur',
     'users_cannot_delete_guest' => 'Vous ne pouvez pas supprimer l\'utilisateur invité',
 
     // Roles
@@ -74,7 +74,7 @@ return [
 
     // Comments
     'comment_list' => 'Une erreur s\'est produite lors de la récupération des commentaires.',
-    'cannot_add_comment_to_draft' => 'Vous ne pouvez pas ajouter de commentaires à un projet.',
+    'cannot_add_comment_to_draft' => 'Vous ne pouvez pas ajouter de commentaires à un brouillon.',
     'comment_add' => 'Une erreur s\'est produite lors de l\'ajout du commentaire.',
     'comment_delete' => 'Une erreur s\'est produite lors de la suppression du commentaire.',
     'empty_comment' => 'Impossible d\'ajouter un commentaire vide.',
@@ -82,10 +82,10 @@ return [
     // Error pages
     '404_page_not_found' => 'Page non trouvée',
     'sorry_page_not_found' => 'Désolé, cette page n\'a pas pu être trouvée.',
-    'sorry_page_not_found_permission_warning' => 'Si vous vous attendiez à ce que cette page existe, il se peut que vous n\'ayez pas l\'autorisation de la consulter.',
+    'sorry_page_not_found_permission_warning' => 'Si cette page est censée exister, il se peut que vous n\'ayez pas l\'autorisation de la consulter.',
     'image_not_found' => 'Image non trouvée',
     'image_not_found_subtitle' => 'Désolé, l\'image que vous cherchez ne peut être trouvée.',
-    'image_not_found_details' => 'Si vous vous attendiez à ce que cette image existe, elle pourrait avoir été supprimée.',
+    'image_not_found_details' => 'Si cette image était censée exister, il se pourrait qu\'elle ait été supprimée.',
     'return_home' => 'Retour à l\'accueil',
     'error_occurred' => 'Une erreur est survenue',
     'app_down' => ':appName n\'est pas en service pour le moment',
@@ -96,7 +96,7 @@ return [
     'api_bad_authorization_format' => 'Un jeton d\'autorisation a été trouvé pour la requête, mais le format semble incorrect',
     'api_user_token_not_found' => 'Aucun jeton API correspondant n\'a été trouvé pour le jeton d\'autorisation fourni',
     'api_incorrect_token_secret' => 'Le secret fourni pour le jeton d\'API utilisé est incorrect',
-    'api_user_no_api_permission' => 'Le propriétaire du jeton API utilisé n\'a pas la permission de passer des appels API',
+    'api_user_no_api_permission' => 'Le propriétaire du jeton API utilisé n\'a pas la permission de passer des requêtes API',
     'api_user_token_expired' => 'Le jeton d\'autorisation utilisé a expiré',
 
     // Settings & Maintenance
index b0ff20e282989ec735bf278ba97baaf6d1477b2f..e209c21782343ffb746a66c60aad00b0d0c592bf 100644 (file)
@@ -6,10 +6,10 @@
  */
 return [
 
-    'password' => 'Les mots de passe doivent faire au moins 6 caractères et correspondre à la confirmation.',
+    'password' => 'Les mots de passe doivent faire au moins 8 caractères et correspondre à la confirmation.',
     'user' => "Nous n'avons pas trouvé d'utilisateur avec cette adresse.",
     'token' => 'Le mot de passe reset du token n\'est pas valide pour cette adresse e-mail.',
-    'sent' => 'Nous vous avons envoyé un lien de réinitialisation de mot de passe !',
+    'sent' => 'Nous vous avons envoyé un lien de réinitialisation de mot de passe par e-mail !',
     'reset' => 'Votre mot de passe a été réinitialisé !',
 
 ];
index 9cca9159e96358604904ba05c062ca1974c9bb9f..aa58aeee080d4016964876fa3e85faeb7c9ca760 100644 (file)
@@ -21,15 +21,15 @@ return [
     'app_public_access_desc' => 'L\'activation de cette option permettra aux visiteurs, qui ne sont pas connectés, d\'accéder au contenu de votre instance BookStack.',
     'app_public_access_desc_guest' => 'L\'accès pour les visiteurs publics peut être contrôlé par l\'utilisateur "Guest".',
     'app_public_access_toggle' => 'Autoriser l\'accès public',
-    'app_public_viewing' => 'Accepter le visionnage public des pages ?',
-    'app_secure_images' => 'Activer l\'ajout d\'image sécurisé ?',
+    'app_public_viewing' => 'Accepter l\'affichage public des pages ?',
+    'app_secure_images' => 'Ajout d\'image sécurisé',
     'app_secure_images_toggle' => 'Activer l\'ajout d\'image sécurisé',
     'app_secure_images_desc' => 'Pour des questions de performances, toutes les images sont publiques. Cette option ajoute une chaîne aléatoire difficile à deviner dans les URLs des images.',
     'app_editor' => 'Éditeur des pages',
     'app_editor_desc' => 'Sélectionnez l\'éditeur qui sera utilisé pour modifier les pages.',
     'app_custom_html' => 'HTML personnalisé dans l\'en-tête',
     'app_custom_html_desc' => 'Le contenu inséré ici sera ajouté en bas de la balise <head> de toutes les pages. Vous pouvez l\'utiliser pour ajouter du CSS personnalisé ou un tracker analytique.',
-    'app_custom_html_disabled_notice' => 'Le contenu de la tête HTML personnalisée est désactivé sur cette page de paramètres pour garantir que les modifications les plus récentes peuvent être annulées.',
+    'app_custom_html_disabled_notice' => 'Le contenu de l\'en-tête HTML personnalisé est désactivé sur cette page de paramètres pour garantir que les modifications les plus récentes puissent être annulées.',
     'app_logo' => 'Logo de l\'application',
     'app_logo_desc' => 'Cette image doit faire 43px de hauteur. <br>Les images plus larges seront réduites.',
     'app_primary_color' => 'Couleur principale de l\'application',
@@ -38,7 +38,7 @@ return [
     'app_homepage_desc' => 'Choisissez une page à afficher sur la page d\'accueil au lieu de la vue par défaut. Les permissions sont ignorées pour les pages sélectionnées.',
     'app_homepage_select' => 'Choisissez une page',
     'app_footer_links' => 'Liens de pied de page',
-    'app_footer_links_desc' => 'Ajoutez des liens dans le pied de page du site. Ils seront affichés en bas de la plupart des pages, incluant celles qui ne nécesittent pas de connexion. Vous pouvez utiliser l\'étiquette "trans::<key>" pour utiliser les traductions définies par le système. Par exemple, utiliser "trans::common.privacy_policy" fournira la traduction de "Politique de Confidentalité" et "trans::common.terms_of_service" fournira la traduction de "Conditions d\'utilisation".',
+    'app_footer_links_desc' => 'Ajouter des liens à afficher dans le pied de page du site. Ils seront affichés en bas de la plupart des pages, y compris celles qui ne nécessitent pas de connexion. Vous pouvez utiliser une étiquette de "trans::<key>" pour utiliser les traductions définies par le système. Par exemple : utiliser "trans::common.privacy_policy" fournira le texte traduit "Privacy Policy" et "trans::common.terms_of_service" fournira le texte traduit "Terms of Service".',
     'app_footer_links_label' => 'Libellé du lien',
     'app_footer_links_url' => 'URL du lien',
     'app_footer_links_add' => 'Ajouter un lien en pied de page',
@@ -49,11 +49,11 @@ return [
     // Color settings
     'content_colors' => 'Couleur du contenu',
     'content_colors_desc' => 'Définit les couleurs pour tous les éléments de la hiérarchie d\'organisation des pages. Choisir les couleurs avec une luminosité similaire aux couleurs par défaut est recommandé pour la lisibilité.',
-    'bookshelf_color' => 'Couleur de l\'étagère',
-    'book_color' => 'Couleur du livre',
-    'chapter_color' => 'Couleur du chapitre',
-    'page_color' => 'Couleur de la page',
-    'page_draft_color' => 'Couleur du brouillon',
+    'bookshelf_color' => 'Couleur des étagères',
+    'book_color' => 'Couleur des livres',
+    'chapter_color' => 'Couleur des chapitres',
+    'page_color' => 'Couleur des pages',
+    'page_draft_color' => 'Couleur des brouillons',
 
     // Registration Settings
     'reg_settings' => 'Préférence pour l\'inscription',
@@ -72,19 +72,19 @@ return [
     // Maintenance settings
     'maint' => 'Maintenance',
     'maint_image_cleanup' => 'Nettoyer les images',
-    'maint_image_cleanup_desc' => "Scan le contenu des pages et des révisions pour vérifier les images et les dessins en cours d'utilisation et lesquels sont redondant. Veuillez à faire une sauvegarde de la base de données et des images avant de lancer ceci.",
+    'maint_image_cleanup_desc' => "Scanne le contenu des pages et des révisions pour vérifier les images, les dessins en cours d'utilisation et les doublons. Assurez-vous d'avoir une sauvegarde de la base de données et des images avant de lancer ceci.",
     'maint_delete_images_only_in_revisions' => 'Supprimer également les images qui n\'existent que dans les anciennes révisions de page',
     'maint_image_cleanup_run' => 'Lancer le nettoyage',
-    'maint_image_cleanup_warning' => ':count images potentiellement inutilisées trouvées. Etes-vous sûr de vouloir supprimer ces images ?',
+    'maint_image_cleanup_warning' => ':count images potentiellement inutilisées trouvées. Êtes-vous sûr de vouloir supprimer ces images ?',
     'maint_image_cleanup_success' => ':count images potentiellement inutilisées trouvées et supprimées !',
     'maint_image_cleanup_nothing_found' => 'Aucune image inutilisée trouvée, rien à supprimer !',
-    'maint_send_test_email' => 'Envoyer un email de test',
+    'maint_send_test_email' => 'Envoyer un e-mail de test',
     'maint_send_test_email_desc' => 'Ceci envoie un e-mail de test à votre adresse e-mail spécifiée dans votre profil.',
-    'maint_send_test_email_run' => 'Envoyer un email de test',
-    'maint_send_test_email_success' => 'Email envoyé à :address',
-    'maint_send_test_email_mail_subject' => 'Email de test',
-    'maint_send_test_email_mail_greeting' => 'La livraison d\'email semble fonctionner !',
-    'maint_send_test_email_mail_text' => 'Félicitations ! Lorsque vous avez reçu cette notification par courriel, vos paramètres d\'email semblent être configurés correctement.',
+    'maint_send_test_email_run' => 'Envoyer un e-mail de test',
+    'maint_send_test_email_success' => 'E-mail envoyé à :address',
+    'maint_send_test_email_mail_subject' => 'E-mail de test',
+    'maint_send_test_email_mail_greeting' => 'L\'envoi d\'e-mail semble fonctionner !',
+    'maint_send_test_email_mail_text' => 'Félicitations ! Comme vous avez bien reçu cette notification, vos paramètres d\'e-mail semblent être configurés correctement.',
     'maint_recycle_bin_desc' => 'Les étagères, livres, chapitres et pages supprimés sont envoyés dans la corbeille afin qu\'ils puissent être restaurés ou supprimés définitivement. Les éléments plus anciens de la corbeille peuvent être supprimés automatiquement après un certain temps selon la configuration du système.',
     'maint_recycle_bin_open' => 'Ouvrir la corbeille',
 
@@ -98,7 +98,7 @@ return [
     'recycle_bin_permanently_delete' => 'Supprimer définitivement',
     'recycle_bin_restore' => 'Restaurer',
     'recycle_bin_contents_empty' => 'La corbeille est vide',
-    'recycle_bin_empty' => 'Vider la Corbeille',
+    'recycle_bin_empty' => 'Vider la corbeille',
     'recycle_bin_empty_confirm' => 'Cela détruira définitivement tous les éléments de la corbeille, y compris le contenu contenu de chaque élément. Êtes-vous sûr de vouloir vider la corbeille ?',
     'recycle_bin_destroy_confirm' => 'Cette action supprimera définitivement cet élément, ainsi que tous les éléments enfants listés ci-dessous du système et vous ne pourrez pas restaurer ce contenu. Êtes-vous sûr de vouloir supprimer définitivement cet élément ?',
     'recycle_bin_destroy_list' => 'Éléments à détruire',
@@ -117,9 +117,10 @@ return [
     'audit_deleted_item' => 'Élément supprimé',
     'audit_deleted_item_name' => 'Nom: :name',
     'audit_table_user' => 'Utilisateur',
-    'audit_table_event' => 'Evènement',
-    'audit_table_related' => 'Élément ou détail lié',
-    'audit_table_date' => 'Date d\'activation',
+    'audit_table_event' => 'Événement',
+    'audit_table_related' => 'Élément concerné ou action réalisée',
+    'audit_table_ip' => 'Adresse IP',
+    'audit_table_date' => 'Horodatage',
     'audit_date_from' => 'À partir du',
     'audit_date_to' => 'Jusqu\'au',
 
@@ -138,7 +139,7 @@ return [
     'role_details' => 'Détails du rôle',
     'role_name' => 'Nom du rôle',
     'role_desc' => 'Courte description du rôle',
-    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_mfa_enforced' => 'Nécessite une authentification multi-facteurs',
     'role_external_auth_id' => 'Identifiants d\'authentification externes',
     'role_system' => 'Permissions système',
     'role_manage_users' => 'Gérer les utilisateurs',
@@ -148,8 +149,9 @@ return [
     'role_manage_page_templates' => 'Gérer les modèles de page',
     'role_access_api' => 'Accès à l\'API du système',
     'role_manage_settings' => 'Gérer les préférences de l\'application',
+    'role_export_content' => 'Exporter le contenu',
     'role_asset' => 'Permissions des ressources',
-    'roles_system_warning' => 'Sachez que l\'accès à l\'une des trois permissions ci-dessus peut permettre à un utilisateur de modifier ses propres privilèges ou les privilèges des autres utilisateurs du système. Attribuer uniquement des rôles avec ces permissions à des utilisateurs de confiance.',
+    'roles_system_warning' => 'Sachez que l\'accès à l\'une des trois permissions ci-dessus peut permettre à un utilisateur de modifier ses propres privilèges ou les privilèges des autres utilisateurs du système. N\'attribuez uniquement des rôles avec ces permissions qu\'à des utilisateurs de confiance.',
     'role_asset_desc' => 'Ces permissions contrôlent l\'accès par défaut des ressources dans le système. Les permissions dans les livres, les chapitres et les pages ignoreront ces permissions',
     'role_asset_admins' => 'Les administrateurs ont automatiquement accès à tous les contenus mais les options suivantes peuvent afficher ou masquer certaines options de l\'interface.',
     'role_all' => 'Tous',
@@ -164,7 +166,7 @@ return [
     'users' => 'Utilisateurs',
     'user_profile' => 'Profil d\'utilisateur',
     'users_add_new' => 'Ajouter un nouvel utilisateur',
-    'users_search' => 'Chercher les utilisateurs',
+    'users_search' => 'Rechercher les utilisateurs',
     'users_latest_activity' => 'Dernière activité',
     'users_details' => 'Informations de l\'utilisateur',
     'users_details_desc' => 'Définissez un nom et une adresse e-mail pour cet utilisateur. L\'adresse e-mail sera utilisée pour se connecter à l\'application.',
@@ -172,20 +174,20 @@ return [
     'users_role' => 'Rôles de l\'utilisateur',
     'users_role_desc' => 'Sélectionnez les rôles auxquels cet utilisateur sera affecté. Si un utilisateur est affecté à plusieurs rôles, les permissions de ces rôles s\'empileront et ils recevront toutes les capacités des rôles affectés.',
     'users_password' => 'Mot de passe de l\'utilisateur',
-    'users_password_desc' => 'Définissez un mot de passe utilisé pour vous connecter à l\'application. Il doit comporter au moins 5 caractères.',
-    'users_send_invite_text' => 'Vous pouvez choisir d\'envoyer à cet utilisateur un email d\'invitation qui lui permet de définir son propre mot de passe, sinon vous pouvez définir son mot de passe vous-même.',
+    'users_password_desc' => 'Définissez un mot de passe utilisé pour vous connecter à l\'application. Il doit comporter au moins 6 caractères.',
+    'users_send_invite_text' => 'Vous pouvez choisir d\'envoyer à cet utilisateur un e-mail d\'invitation qui lui permet de définir son propre mot de passe, sinon vous pouvez définir son mot de passe vous-même.',
     'users_send_invite_option' => 'Envoyer l\'e-mail d\'invitation',
     'users_external_auth_id' => 'Identifiant d\'authentification externe',
     'users_external_auth_id_desc' => 'C\'est l\'ID utilisé pour correspondre à cet utilisateur lors de la communication avec votre système d\'authentification externe.',
-    'users_password_warning' => 'Remplissez ce formulaire uniquement si vous souhaitez changer de mot de passe:',
+    'users_password_warning' => 'Remplissez ce formulaire uniquement si vous souhaitez changer de mot de passe :',
     'users_system_public' => 'Cet utilisateur représente les invités visitant votre instance. Il est assigné automatiquement aux invités.',
     'users_delete' => 'Supprimer un utilisateur',
     'users_delete_named' => 'Supprimer l\'utilisateur :userName',
     'users_delete_warning' => 'Ceci va supprimer \':userName\' du système.',
     'users_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer cet utilisateur ?',
-    'users_migrate_ownership' => 'Migré propriété',
+    'users_migrate_ownership' => 'Transférer la propriété',
     'users_migrate_ownership_desc' => 'Sélectionnez un utilisateur ici si vous voulez qu\'un autre utilisateur devienne le propriétaire de tous les éléments actuellement détenus par cet utilisateur.',
-    'users_none_selected' => 'Aucun utilisateur n\'a été séléctionné',
+    'users_none_selected' => 'Aucun utilisateur n\'a été sélectionné',
     'users_delete_success' => 'Utilisateur supprimé avec succès',
     'users_edit' => 'Modifier l\'utilisateur',
     'users_edit_profile' => 'Modifier le profil',
@@ -194,21 +196,21 @@ return [
     'users_avatar_desc' => 'Cette image doit être un carré d\'environ 256 px.',
     'users_preferred_language' => 'Langue préférée',
     'users_preferred_language_desc' => 'Cette option changera la langue utilisée pour l\'interface utilisateur de l\'application. Ceci n\'affectera aucun contenu créé par l\'utilisateur.',
-    'users_social_accounts' => 'Comptes sociaux',
+    'users_social_accounts' => 'Réseaux sociaux',
     'users_social_accounts_info' => 'Vous pouvez connecter des réseaux sociaux à votre compte pour vous connecter plus rapidement. Déconnecter un compte n\'enlèvera pas les accès autorisés précédemment sur votre compte de réseau social.',
     'users_social_connect' => 'Connecter le compte',
     'users_social_disconnect' => 'Déconnecter le compte',
     'users_social_connected' => 'Votre compte :socialAccount a été ajouté avec succès.',
     'users_social_disconnected' => 'Votre compte :socialAccount a été déconnecté avec succès',
-    'users_api_tokens' => 'Jetons de l\'API',
+    'users_api_tokens' => 'Jetons API',
     'users_api_tokens_none' => 'Aucun jeton API n\'a été créé pour cet utilisateur',
     'users_api_tokens_create' => 'Créer un jeton',
     'users_api_tokens_expires' => 'Expiré',
     'users_api_tokens_docs' => 'Documentation de l\'API',
-    'users_mfa' => 'Multi-Factor Authentication',
-    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'users_mfa_x_methods' => ':count method configured|:count methods configured',
-    'users_mfa_configure' => 'Configure Methods',
+    'users_mfa' => 'Authentification multi-facteurs',
+    'users_mfa_desc' => 'Configurer l\'authentification multi-facteurs ajoute une couche supplémentaire de sécurité à votre compte utilisateur.',
+    'users_mfa_x_methods' => ':count méthode configurée|:count méthodes configurées',
+    'users_mfa_configure' => 'Méthode de configuration',
 
     // API Tokens
     'user_api_token_create' => 'Créer un nouveau jeton API',
@@ -217,19 +219,19 @@ return [
     'user_api_token_expiry' => 'Date d\'expiration',
     'user_api_token_expiry_desc' => 'Définissez une date à laquelle ce jeton expire. Après cette date, les demandes effectuées à l\'aide de ce jeton ne fonctionneront plus. Le fait de laisser ce champ vide entraînera une expiration dans 100 ans.',
     'user_api_token_create_secret_message' => 'Immédiatement après la création de ce jeton, un "ID de jeton" "et" Secret de jeton "sera généré et affiché. Le secret ne sera affiché qu\'une seule fois, alors assurez-vous de copier la valeur dans un endroit sûr et sécurisé avant de continuer.',
-    'user_api_token_create_success' => 'L\'API token a été créé avec succès',
-    'user_api_token_update_success' => 'L\'API token a été mis à jour avec succès',
-    'user_api_token' => 'Token API',
+    'user_api_token_create_success' => 'Le jeton API a été créé avec succès',
+    'user_api_token_update_success' => 'Le jeton API a été mis à jour avec succès',
+    'user_api_token' => 'Jeton API',
     'user_api_token_id' => 'Token ID',
     'user_api_token_id_desc' => 'Il s\'agit d\'un identifiant généré par le système non modifiable pour ce jeton qui devra être fourni dans les demandes d\'API.',
     'user_api_token_secret' => 'Token Secret',
     'user_api_token_secret_desc' => 'Il s\'agit d\'un secret généré par le système pour ce jeton, qui devra être fourni dans les demandes d\'API. Cela ne sera affiché qu\'une seule fois, alors copiez cette valeur dans un endroit sûr et sécurisé.',
     'user_api_token_created' => 'Jeton créé :timeAgo',
     'user_api_token_updated' => 'Jeton mis à jour :timeAgo',
-    'user_api_token_delete' => 'Supprimer le Token',
+    'user_api_token_delete' => 'Supprimer le jeton',
     'user_api_token_delete_warning' => 'Cela supprimera complètement le jeton d\'API avec le nom \':tokenName\'.',
-    'user_api_token_delete_confirm' => 'Souhaitez-vous vraiment effacer l\'API Token ?',
-    'user_api_token_delete_success' => 'L\'API token a été supprimé avec succès',
+    'user_api_token_delete_confirm' => 'Souhaitez-vous vraiment effacer ce jeton API ?',
+    'user_api_token_delete_success' => 'Le jeton API a été supprimé avec succès',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norvegien',
index e39286c7a609d61b0cb9c2f9f4b6de3ea0de9f02..71a5e8e083d9b6a420833d9472816c2f75c105a7 100644 (file)
@@ -15,11 +15,11 @@ return [
     'alpha_dash'           => ':attribute doit contenir uniquement des lettres, chiffres et traits d\'union.',
     'alpha_num'            => ':attribute doit contenir uniquement des chiffres et des lettres.',
     'array'                => ':attribute doit être un tableau.',
-    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'backup_codes'         => 'Le code fourni n\'est pas valide ou a déjà été utilisé.',
     'before'               => ':attribute doit être inférieur à :date.',
     'between'              => [
         'numeric' => ':attribute doit être compris entre :min et :max.',
-        'file'    => ':attribute doit être compris entre :min et :max kilobytes.',
+        'file'    => ':attribute doit être compris entre :min et :max Ko.',
         'string'  => ':attribute doit être compris entre :min et :max caractères.',
         'array'   => ':attribute doit être compris entre :min et :max éléments.',
     ],
@@ -35,13 +35,13 @@ return [
     'filled'               => ':attribute est un champ requis.',
     'gt'                   => [
         'numeric' => ':attribute doit être plus grand que :value.',
-        'file'    => ':attribute doit être plus grand que :value kilobytes.',
+        'file'    => ':attribute doit être plus grand que :value Ko.',
         'string'  => ':attribute doit être plus grand que :value caractères.',
         'array'   => ':attribute doit avoir plus que :value éléments.',
     ],
     'gte'                  => [
         'numeric' => ':attribute doit être plus grand ou égal à :value.',
-        'file'    => ':attribute doit être plus grand ou égal à :value kilobytes.',
+        'file'    => ':attribute doit être plus grand ou égal à :value Ko.',
         'string'  => ':attribute doit être plus grand ou égal à :value caractères.',
         'array'   => ':attribute doit avoir :value éléments ou plus.',
     ],
@@ -53,22 +53,22 @@ return [
     'ip'                   => ':attribute doit être une adresse IP valide.',
     'ipv4'                 => ':attribute doit être une adresse IPv4 valide.',
     'ipv6'                 => ':attribute doit être une adresse IPv6 valide.',
-    'json'                 => ':attribute doit être une chaine JSON valide.',
+    'json'                 => ':attribute doit être une chaîne JSON valide.',
     'lt'                   => [
         'numeric' => ':attribute doit être plus petit que :value.',
-        'file'    => ':attribute doit être plus petit que :value kilobytes.',
+        'file'    => ':attribute doit être plus petit que :value Ko.',
         'string'  => ':attribute doit être plus petit que :value caractères.',
         'array'   => ':attribute doit avoir moins de :value éléments.',
     ],
     'lte'                  => [
         'numeric' => ':attribute doit être plus petit ou égal à :value.',
-        'file'    => ':attribute doit être plus petit ou égal à :value kilobytes.',
+        'file'    => ':attribute doit être plus petit ou égal à :value Ko.',
         'string'  => ':attribute doit être plus petit ou égal à :value caractères.',
         'array'   => ':attribute ne doit pas avoir plus de :value éléments.',
     ],
     'max'                  => [
         'numeric' => ':attribute ne doit pas excéder :max.',
-        'file'    => ':attribute ne doit pas excéder :max kilobytes.',
+        'file'    => ':attribute ne doit pas excéder :max Ko.',
         'string'  => ':attribute ne doit pas excéder :max caractères.',
         'array'   => ':attribute ne doit pas contenir plus de :max éléments.',
     ],
@@ -99,7 +99,7 @@ return [
     ],
     'string'               => ':attribute doit être une chaîne de caractères.',
     'timezone'             => ':attribute doit être une zone valide.',
-    'totp'                 => 'The provided code is not valid or has expired.',
+    'totp'                 => 'Le code fourni n\'est pas valide ou est expiré.',
     'unique'               => ':attribute est déjà utilisé.',
     'url'                  => ':attribute a un format invalide.',
     'uploaded'             => 'Le fichier n\'a pas pu être envoyé. Le serveur peut ne pas accepter des fichiers de cette taille.',
index 7abfabb5924de6b1b5f131a97ea4f3cf3bbcc356..ac4e25c8b612dca1ee423146a6302666bf3812cc 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'הרשאות מדף',
     'shelves_permissions_updated' => 'הרשאות מדף עודכנו',
     'shelves_permissions_active' => 'הרשאות מדף פעילות',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'העתק הרשאות מדף אל הספרים',
     'shelves_copy_permissions' => 'העתק הרשאות',
     'shelves_copy_permissions_explain' => 'פעולה זו תעתיק את כל הרשאות המדף לכל הספרים המשוייכים למדף זה. לפני הביצוע, יש לוודא שכל הרשאות המדף אכן נשמרו.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'דף חדש',
     'pages_editing_draft_notification' => 'הינך עורך טיוטה אשר נשמרה לאחרונה ב :timeDiff',
     'pages_draft_edited_notification' => 'דף זה עודכן מאז, מומלץ להתעלם מהטיוטה הזו.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count משתמשים החלו לערוך דף זה',
         'start_b' => ':userName החל לערוך דף זה',
index 9630807ab6753343a7e1bb417b0f0fefc0f249a1..e158867797e8b89fb8e5134b135ebb570c0a7a0b 100755 (executable)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'משתמש',
     'audit_table_event' => 'אירוע',
     'audit_table_related' => 'פריט או פרט קשור',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'זמן הפעילות',
     'audit_date_from' => 'טווח תאריכים החל מ...',
     'audit_date_to' => 'טווח תאריכים עד ל...',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'נהל תבניות דפים',
     'role_access_api' => 'גש ל-API המערכת',
     'role_manage_settings' => 'ניהול הגדרות יישום',
+    'role_export_content' => 'Export content',
     'role_asset' => 'הרשאות משאבים',
     'roles_system_warning' => 'שימו לב לכך שגישה לכל אחת משלושת ההרשאות הנ"ל יכולה לאפשר למשתמש לשנות את הפריווילגיות שלהם או של אחרים במערכת. הגדירו תפקידים להרשאות אלה למשתמשים בהם אתם בוטחים בלבד.',
     'role_asset_desc' => 'הרשאות אלו שולטות בגישת ברירת המחדל למשאבים בתוך המערכת. הרשאות של ספרים, פרקים ודפים יגברו על הרשאות אלו.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 4e192a24b762a9a23318a3680ec2c6efd513be0a..5f3f8bedbdb357c6e72e3282a93d023f11ec9a4e 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Dopuštenja za policu',
     'shelves_permissions_updated' => 'Ažurirana dopuštenja za policu',
     'shelves_permissions_active' => 'Aktivirana dopuštenja za policu',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Kopiraj dopuštenja za knjige',
     'shelves_copy_permissions' => 'Kopiraj dopuštenja',
     'shelves_copy_permissions_explain' => 'Ovo će promijeniti trenutna dopuštenja za policu i knjige u njoj. Prije aktivacije provjerite jesu li sve dopuštenja za ovu policu spremljena.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Nova stranica',
     'pages_editing_draft_notification' => 'Uređujete nacrt stranice posljednji put spremljen :timeDiff.',
     'pages_draft_edited_notification' => 'Ova je stranica u međuvremenu ažurirana. Preporučujemo da odbacite ovaj nacrt.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count korisnika koji uređuju ovu stranicu',
         'start_b' => ':userName je počeo uređivati ovu stranicu',
index 4a2ccc962acc82b22b487bf42bbdd78d3de2ffb1..547f27a83a2abbb885c07bfeef946771b7231f65 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Korisnik',
     'audit_table_event' => 'Događaj',
     'audit_table_related' => 'Povezana stavka ili detalj',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Datum aktivnosti',
     'audit_date_from' => 'Rangiraj datum od',
     'audit_date_to' => 'Rangiraj datum do',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Upravljanje predlošcima stranica',
     'role_access_api' => 'API pristup',
     'role_manage_settings' => 'Upravljanje postavkama aplikacija',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Upravljanje vlasništvom',
     'roles_system_warning' => 'Uzmite u obzir da pristup bilo kojem od ovih dopuštenja dozvoljavate korisniku upravljanje dopuštenjima ostalih u sustavu. Ova dopuštenja dodijelite pouzdanim korisnicima.',
     'role_asset_desc' => 'Ova dopuštenja kontroliraju zadane pristupe. Dopuštenja za knjige, poglavlja i stranice ih poništavaju.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 3d5b000b6edc76ef4bb84d6e5c34e74bf160fd31..f78392ec2e2589531c695aad560dcddebfec7310 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Könyvespolc jogosultság',
     'shelves_permissions_updated' => 'Könyvespolc jogosultságok frissítve',
     'shelves_permissions_active' => 'Könyvespolc jogosultságok aktívak',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Jogosultság másolása könyvekre',
     'shelves_copy_permissions' => 'Jogosultság másolása',
     'shelves_copy_permissions_explain' => 'Ez alkalmazni fogja ennek a könyvespolcnak az aktuális jogosultság beállításait az összes benne található könyvön. Aktiválás előtt ellenőrizni kell, hogy a könyvespolc jogosultságain végzett összes módosítás el lett mentve.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Új oldal',
     'pages_editing_draft_notification' => 'A jelenleg szerkesztett vázlat legutóbb ekkor volt elmentve: :timeDiff.',
     'pages_draft_edited_notification' => 'Ezt az oldalt azóta már frissítették. Javasolt ennek a vázlatnak az elvetése.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count felhasználók kezdte el szerkeszteni ezt az oldalt',
         'start_b' => ':userName elkezdte szerkeszteni ezt az oldalt',
index 0f64dc29273abfa944706cf690fa781c14262f7b..9cc3f840d433a8db7769ad7c279c624ec701c847 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Felhasználó',
     'audit_table_event' => 'Esemény',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Activity Date',
     'audit_date_from' => 'Date Range From',
     'audit_date_to' => 'Date Range To',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Oldalsablonok kezelése',
     'role_access_api' => 'Hozzáférés a rendszer API-hoz',
     'role_manage_settings' => 'Alkalmazás beállításainak kezelése',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Eszköz jogosultságok',
     'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
     'role_asset_desc' => 'Ezek a jogosultság vezérlik a alapértelmezés szerinti hozzáférést a rendszerben található eszközökhöz. A könyvek, fejezetek és oldalak jogosultságai felülírják ezeket a jogosultságokat.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 80add1dd1d0dd49d64a7ce5eaddd6406c20a9c49..0b58563fad4fd5d379492f4bffa0feddea767e8b 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Izin Rak Buku',
     'shelves_permissions_updated' => 'Izin Rak Buku Diperbarui',
     'shelves_permissions_active' => 'Izin Rak Buku Aktif',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Salin Izin ke Buku',
     'shelves_copy_permissions' => 'Salin Izin',
     'shelves_copy_permissions_explain' => 'Ini akan menerapkan setelan izin rak buku ini saat ini ke semua buku yang ada di dalamnya. Sebelum mengaktifkan, pastikan setiap perubahan pada izin rak buku ini telah disimpan.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Halaman Baru',
     'pages_editing_draft_notification' => 'Anda sedang menyunting konsep yang terakhir disimpan :timeDiff.',
     'pages_draft_edited_notification' => 'Halaman ini telah diperbarui sejak saat itu. Anda disarankan untuk membuang draf ini.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count pengguna sudah mulai mengedit halaman ini',
         'start_b' => ':userName telah memulai menyunting halaman ini',
index 407e6dec94ffed6e93c36f0e2995771454cbc57b..c01cbdb016d369eded8c9f4847f6131e0d6f8785 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Pengguna',
     'audit_table_event' => 'Peristiwa',
     'audit_table_related' => 'Item atau Detail Terkait',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Tanggal Kegiatan',
     'audit_date_from' => 'Rentang Tanggal Dari',
     'audit_date_to' => 'Rentang Tanggal Sampai',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Kelola template halaman',
     'role_access_api' => 'Akses Sistem API',
     'role_manage_settings' => 'Kelola setelan aplikasi',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Izin Aset',
     'roles_system_warning' => 'Ketahuilah bahwa akses ke salah satu dari tiga izin di atas dapat memungkinkan pengguna untuk mengubah hak mereka sendiri atau orang lain dalam sistem. Hanya tetapkan peran dengan izin ini untuk pengguna tepercaya.',
     'role_asset_desc' => 'Izin ini mengontrol akses default ke aset dalam sistem. Izin pada Buku, Bab, dan Halaman akan menggantikan izin ini.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index dce9598286f7898849ef6f0c2bd69d675b362eb8..96852f9220f9e6d26150abb65f6ec3b6bbf6df72 100755 (executable)
@@ -48,8 +48,8 @@ return [
     'favourite_remove_notification' => '":name" è stato rimosso dai tuoi preferiti',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => 'Metodo multi-fattore impostato con successo',
+    'mfa_remove_method_notification' => 'Metodo multi-fattore rimosso con successo',
 
     // Other
     'commented_on'                => 'ha commentato in',
index 3e384a797631449178585b1d025db89417ffcd06..3e1500a6ff3b0b2cf7eb243cb61a7fa48959f292 100755 (executable)
@@ -76,12 +76,12 @@ return [
     'user_invite_success' => 'Password impostata, ora hai accesso a :appName!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Setup Multi-Factor Authentication',
-    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'mfa_setup_configured' => 'Already configured',
-    'mfa_setup_reconfigure' => 'Reconfigure',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
-    'mfa_setup_action' => 'Setup',
+    'mfa_setup' => 'Imposta Autenticazione Multi-Fattore',
+    'mfa_setup_desc' => 'Imposta l\'autenticazione multi-fattore come misura di sicurezza aggiuntiva per il tuo account.',
+    'mfa_setup_configured' => 'Già configurata',
+    'mfa_setup_reconfigure' => 'Riconfigura',
+    'mfa_setup_remove_confirmation' => 'Sei sicuro di voler rimuovere questo metodo di autenticazione multi-fattore?',
+    'mfa_setup_action' => 'Imposta',
     'mfa_backup_codes_usage_limit_warning' => '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.',
     'mfa_option_totp_title' => 'Mobile App',
     'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
@@ -108,5 +108,5 @@ return [
     'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
     'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
     'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
-    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+    'mfa_setup_login_notification' => 'Metodo multi-fattore configurato, si prega di effettuare nuovamente il login utilizzando il metodo configurato.',
 ];
\ No newline at end of file
index 76ee14bc4bd83b731507c07e0109bc488ad65e2e..bcd3aadf92ad6f251e3b1923af9fd9f9f62afedd 100755 (executable)
@@ -39,7 +39,7 @@ return [
     'reset' => 'Azzera',
     'remove' => 'Rimuovi',
     'add' => 'Aggiungi',
-    'configure' => 'Configure',
+    'configure' => 'Configura',
     'fullscreen' => 'Schermo intero',
     'favourite' => 'Aggiungi ai Preferiti',
     'unfavourite' => 'Rimuovi dai preferiti',
index d0f47164bcb1d30a9119bff9f167d8f2b10c9028..b9cd4caf33008ee9d521de61835c007193f1b36d 100755 (executable)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Permessi Libreria',
     'shelves_permissions_updated' => 'Permessi Libreria Aggiornati',
     'shelves_permissions_active' => 'Permessi Attivi Libreria',
+    'shelves_permissions_cascade_warning' => 'I permessi sugli scaffali non si estendono automaticamente ai libri contenuti. Questo avviene in quanto un libro può essere presente su più scaffali. I permessi possono comunque essere copiati ai libri contenuti usando l\'opzione qui sotto.',
     'shelves_copy_permissions_to_books' => 'Copia Permessi ai Libri',
     'shelves_copy_permissions' => 'Copia Permessi',
     'shelves_copy_permissions_explain' => 'Verranno applicati tutti i permessi della libreria ai libri contenuti. Prima di attivarlo, assicurati che ogni permesso di questa libreria sia salvato.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Nuova Pagina',
     'pages_editing_draft_notification' => 'Stai modificando una bozza che è stata salvata il :timeDiff.',
     'pages_draft_edited_notification' => 'Questa pagina è stata aggiornata. È consigliabile scartare questa bozza.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count hanno iniziato a modificare questa pagina',
         'start_b' => ':userName ha iniziato a modificare questa pagina',
index e86df3018dc1f1d6097b5688c1626309717ba0a6..c5e016b35c835af8b8bfa065af89e1a501c4ec47 100755 (executable)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Utente',
     'audit_table_event' => 'Evento',
     'audit_table_related' => 'Elemento o Dettaglio correlato',
+    'audit_table_ip' => 'Indirizzo IP',
     'audit_table_date' => 'Data attività',
     'audit_date_from' => 'Dalla data',
     'audit_date_to' => 'Alla data',
@@ -138,7 +139,7 @@ return [
     'role_details' => 'Dettagli Ruolo',
     'role_name' => 'Nome Ruolo',
     'role_desc' => 'Breve Descrizione del Ruolo',
-    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_mfa_enforced' => 'Richiesta autenticazione multi-fattore',
     'role_external_auth_id' => 'ID Autenticazione Esterna',
     'role_system' => 'Permessi di Sistema',
     'role_manage_users' => 'Gestire gli utenti',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Gestisci template pagine',
     'role_access_api' => 'API sistema d\'accesso',
     'role_manage_settings' => 'Gestire impostazioni app',
+    'role_export_content' => 'Esporta contenuto',
     'role_asset' => 'Permessi Entità',
     'roles_system_warning' => 'Siate consapevoli che l\'accesso a uno dei tre permessi qui sopra, può consentire a un utente di modificare i propri privilegi o i privilegi di altri nel sistema. Assegna ruoli con questi permessi solo ad utenti fidati.',
     'role_asset_desc' => 'Questi permessi controllano l\'accesso di default alle entità. I permessi nei Libri, Capitoli e Pagine sovrascriveranno questi.',
@@ -205,10 +207,10 @@ return [
     'users_api_tokens_create' => 'Crea Token',
     'users_api_tokens_expires' => 'Scade',
     'users_api_tokens_docs' => 'Documentazione API',
-    'users_mfa' => 'Multi-Factor Authentication',
-    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'users_mfa_x_methods' => ':count method configured|:count methods configured',
-    'users_mfa_configure' => 'Configure Methods',
+    'users_mfa' => 'Autenticazione multi-fattore',
+    'users_mfa_desc' => 'Imposta l\'autenticazione multi-fattore come misura di sicurezza aggiuntiva per il tuo account.',
+    'users_mfa_x_methods' => ':count metodo configurato|:count metodi configurati',
+    'users_mfa_configure' => 'Configura metodi',
 
     // API Tokens
     'user_api_token_create' => 'Crea Token API',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index efd11be10cab6bb6b0e1c4a61f838dd9de9cdb5f..62f0701198d9aebb52ee1687d24ab4b885ec5946 100755 (executable)
@@ -15,7 +15,7 @@ return [
     'alpha_dash'           => ':attribute deve contenere solo lettere, numeri e meno.',
     'alpha_num'            => ':attribute deve contenere solo lettere e numeri.',
     'array'                => ':attribute deve essere un array.',
-    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'backup_codes'         => 'Il codice fornito non è valido o è già stato utilizzato.',
     'before'               => ':attribute deve essere una data prima del :date.',
     'between'              => [
         'numeric' => 'Il campo :attribute deve essere tra :min e :max.',
@@ -99,7 +99,7 @@ return [
     ],
     'string'               => ':attribute deve essere una stringa.',
     'timezone'             => ':attribute deve essere una zona valida.',
-    'totp'                 => 'The provided code is not valid or has expired.',
+    'totp'                 => 'Il codice fornito non è valido o è scaduto.',
     'unique'               => ':attribute è già preso.',
     'url'                  => 'Il formato :attribute non è valido.',
     'uploaded'             => 'Il file non può essere caricato. Il server potrebbe non accettare file di questa dimensione.',
index c61871dafb21500436778abeec428ad4e736594d..e93169ac6563e448a13004cd7b5b304d4fe499fe 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Bookshelf Permissions',
     'shelves_permissions_updated' => 'Bookshelf Permissions Updated',
     'shelves_permissions_active' => 'Bookshelf Permissions Active',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
     'shelves_copy_permissions' => 'Copy Permissions',
     'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => '新規ページ',
     'pages_editing_draft_notification' => ':timeDiffに保存された下書きを編集しています。',
     'pages_draft_edited_notification' => 'このページは更新されています。下書きを破棄することを推奨します。',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count人のユーザがページの編集を開始しました',
         'start_b' => ':userNameがページの編集を開始しました',
index 1968d00da249de38803fc93b3902f28995f2b94e..c769174e7be40e5de2b5e733639c78cad7afa795 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'User',
     'audit_table_event' => 'Event',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Activity Date',
     'audit_date_from' => 'Date Range From',
     'audit_date_to' => 'Date Range To',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Manage page templates',
     'role_access_api' => 'Access system API',
     'role_manage_settings' => 'アプリケーション設定の管理',
+    'role_export_content' => 'Export content',
     'role_asset' => 'アセット権限',
     'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
     'role_asset_desc' => '各アセットに対するデフォルトの権限を設定します。ここで設定した権限が優先されます。',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 02b483d5ecc8cf7cfdb703758bc8eae8d7ac7d24..46dced1ecec2f6741959749bef19b8acbc480911 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => '서가 권한',
     'shelves_permissions_updated' => '서가 권한 바꿈',
     'shelves_permissions_active' => '서가 권한 허용함',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => '권한 맞춤',
     'shelves_copy_permissions' => '실행',
     'shelves_copy_permissions_explain' => '서가의 모든 책자에 이 권한을 적용합니다. 서가의 권한을 저장했는지 확인하세요.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => '제목 없음',
     'pages_editing_draft_notification' => ':timeDiff에 초안 문서입니다.',
     'pages_draft_edited_notification' => '최근에 수정한 문서이기 때문에 초안 문서를 폐기하는 편이 좋습니다.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count명이 이 문서를 수정하고 있습니다.',
         'start_b' => ':userName이 이 문서를 수정하고 있습니다.',
index eeaea2273f144a15eca5c0619dfed50070e92c89..6c81bc7355ef1c423ba822b0e00e72bdb4400211 100755 (executable)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => '사용자',
     'audit_table_event' => '이벤트',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => '활동 날짜',
     'audit_date_from' => '날짜 범위 시작',
     'audit_date_to' => '날짜 범위 끝',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => '템플릿 관리',
     'role_access_api' => '시스템 접근 API',
     'role_manage_settings' => '사이트 설정 관리',
+    'role_export_content' => 'Export content',
     'role_asset' => '권한 항목',
     'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
     'role_asset_desc' => '책자, 챕터, 문서별 권한은 이 설정에 우선합니다.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index ee2d0ebffbf65cd2d0fcb179b0491213ee0c25ed..f1eda39e0b1c790b17b547474480196ca4f2bcc1 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Knygų lentynos leidimai',
     'shelves_permissions_updated' => 'Knygų lentynos leidimai atnaujinti',
     'shelves_permissions_active' => 'Knygų lentynos leidimai aktyvūs',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Kopijuoti leidimus knygoms',
     'shelves_copy_permissions' => 'Kopijuoti leidimus',
     'shelves_copy_permissions_explain' => 'Visoms knygoms, esančioms šioje knygų lentynoje, bus taikomi dabartiniai leidimų nustatymai. Prieš suaktyvindami įsitikinkite, kad visi šios knygų lentynos leidimų pakeitimai buvo išsaugoti.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Naujas puslapis',
     'pages_editing_draft_notification' => 'Dabar jūs redaguojate juodraštį, kuris paskutinį kartą buvo išsaugotas :timeDiff',
     'pages_draft_edited_notification' => 'Šis puslapis buvo redaguotas iki to laiko. Rekomenduojame jums išmesti šį juodraštį.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count naudotojai pradėjo redaguoti šį puslapį',
         'start_b' => ':userName pradėjo redaguoti šį puslapį',
index e904dc33ac2608e4dc86fbb717e4f6daffb94730..f5795edbcab8fc4dbeac0c5f4341625b2cfb29d3 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Naudotojas',
     'audit_table_event' => 'Įvykis',
     'audit_table_related' => 'Susijęs elementas arba detalė',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Veiklos data',
     'audit_date_from' => 'Datos seka nuo',
     'audit_date_to' => 'Datos seka iki',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Tvarkyti puslapių šablonus',
     'role_access_api' => 'Gauti prieigą prie sistemos API',
     'role_manage_settings' => 'Tvarkyti programos nustatymus',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Nuosavybės leidimai',
     'roles_system_warning' => 'Būkite sąmoningi, kad prieiga prie bet kurio iš trijų leidimų viršuje gali leisti naudotojui pakeisti jų pačių privilegijas arba kitų privilegijas sistemoje. Paskirkite vaidmenis su šiais leidimais tik patikimiems naudotojams.',
     'role_asset_desc' => 'Šie leidimai kontroliuoja numatytą prieigą į nuosavybę, esančią sistemoje. Knygų, skyrių ir puslapių leidimai nepaisys šių leidimų.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index bf8a9e2881af75ddabb9b895da01305ad7b2cc1b..fc2d876e58c68affea87f8f646cf826ce172b145 100644 (file)
@@ -48,8 +48,8 @@ return [
     'favourite_remove_notification' => '":name" ir izņemts no jūsu favorītiem',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => '2FA funkcija aktivizēta',
+    'mfa_remove_method_notification' => '2FA funkcija noņemta',
 
     // Other
     'commented_on'                => 'komentēts',
index f55423ec594bf33700bb9bff7d285a711f94e121..497509d54cd6061952feca8d79408aa9d056b131 100644 (file)
@@ -76,11 +76,11 @@ return [
     'user_invite_success' => 'Parole iestatīta, tagad varat piekļūt :appName!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Setup Multi-Factor Authentication',
-    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'mfa_setup_configured' => 'Already configured',
-    'mfa_setup_reconfigure' => 'Reconfigure',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup' => 'Iestati divfaktoru autentifikāciju (2FA)',
+    'mfa_setup_desc' => 'Iestati divfaktoru autentifikāciju kā papildus drošību tavam lietotāja kontam.',
+    'mfa_setup_configured' => 'Divfaktoru autentifikācija jau ir nokonfigurēta',
+    'mfa_setup_reconfigure' => 'Mainīt 2FA konfigurāciju',
+    'mfa_setup_remove_confirmation' => 'Vai esi drošs, ka vēlies noņemt divfaktoru autentifikāciju?',
     'mfa_setup_action' => 'Setup',
     'mfa_backup_codes_usage_limit_warning' => '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.',
     'mfa_option_totp_title' => 'Mobile App',
index 1694e3f9255efe6431cfa6329e3b42f7a23dd82c..23cd07d7d1a264ad6b5ca88cc2fefbb259e77a40 100644 (file)
@@ -39,7 +39,7 @@ return [
     'reset' => 'Atiestatīt',
     'remove' => 'Noņemt',
     'add' => 'Pievienot',
-    'configure' => 'Configure',
+    'configure' => 'Mainīt konfigurāciju',
     'fullscreen' => 'Pilnekrāns',
     'favourite' => 'Pievienot favorītiem',
     'unfavourite' => 'Noņemt no favorītiem',
index 2d023f37cc7e3ce4229d0f79cf26e3ce9d9da258..4cfca7aa54aa0bb4c716ddf1503ad91c0726c69e 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Grāmatplaukta atļaujas',
     'shelves_permissions_updated' => 'Grāmatplaukta atļaujas atjauninātas',
     'shelves_permissions_active' => 'Grāmatplaukta atļaujas ir aktīvas',
+    'shelves_permissions_cascade_warning' => 'Grāmatu plauktu atļaujas netiek automātiski pārvietotas uz grāmatām. Tas ir tāpēc, ka grāmata var atrasties vairākos plauktos. Tomēr atļaujas var nokopēt uz plauktam pievienotajām grāmatām, izmantojot zemāk norādīto opciju.',
     'shelves_copy_permissions_to_books' => 'Kopēt grāmatplaukta atļaujas uz grāmatām',
     'shelves_copy_permissions' => 'Kopēt atļaujas',
     'shelves_copy_permissions_explain' => 'Šis piemēros pašreizējās grāmatplaukta piekļuves tiesības visām tajā esošajām grāmatām. Pirms ieslēgšanas pārliecinieties, ka ir saglabātas izmaiņas grāmatplaukta piekļuves tiesībām.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Jauna lapa',
     'pages_editing_draft_notification' => 'Jūs pašlaik veicat izmaiņas melnrakstā, kurš pēdējo reizi ir saglabāts :timeDiff.',
     'pages_draft_edited_notification' => 'Šī lapa ir tikusi atjaunināta. Šo melnrakstu ieteicams atmest.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count lietotāji pašlaik veic izmaiņas šajā lapā',
         'start_b' => ':userName veic izmaiņas šajā lapā',
index 28ebec8c424eb93f0844297f95f05e50789815fe..0108a9aa5d54149b447b968d7f11b5a2fb8abcf4 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Lietotājs',
     'audit_table_event' => 'Notikums',
     'audit_table_related' => 'Saistīta vienība vai detaļa',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Notikuma datums',
     'audit_date_from' => 'Datums no',
     'audit_date_to' => 'Datums līdz',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Pārvaldīt lapas veidnes',
     'role_access_api' => 'Piekļūt sistēmas API',
     'role_manage_settings' => 'Pārvaldīt iestatījumus',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Resursa piekļuves tiesības',
     'roles_system_warning' => 'Jebkuras no trīs augstāk redzamajām atļaujām dod iespēju lietotājam mainīt savas un citu lietotāju sistēmas atļaujas. Pievieno šīs grupu atļaujas tikai tiem lietotājiem, kuriem uzticies.',
     'role_asset_desc' => 'Šīs piekļuves tiesības kontrolē noklusēto piekļuvi sistēmas resursiem. Grāmatām, nodaļām un lapām norādītās tiesības būs pārākas par šīm.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index c013eda48211b71e3d98170fe4bcc8779785525b..8dd93974615ec6b3c2ad1075af1d9e994d90c86b 100644 (file)
@@ -15,7 +15,7 @@ return [
     'alpha_dash'           => ':attribute var saturēt tikai burtus, ciparus, domuzīmes un apakš svītras.',
     'alpha_num'            => ':attribute var saturēt tikai burtus un ciparus.',
     'array'                => ':attribute ir jābūt masīvam.',
-    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'backup_codes'         => 'Ievadītais kods nav derīgs vai arī jau ir izmantots.',
     'before'               => ':attribute jābūt datumam pirms :date.',
     'between'              => [
         'numeric' => ':attribute jābūt starp :min un :max.',
@@ -99,7 +99,7 @@ return [
     ],
     'string'               => ':attribute jābūt teksta virknei.',
     'timezone'             => ':attribute jābūt derīgai zonai.',
-    'totp'                 => 'The provided code is not valid or has expired.',
+    'totp'                 => 'Ievadītais kods nav derīgs.',
     'unique'               => ':attribute jau ir aizņemts.',
     'url'                  => ':attribute formāts nav derīgs.',
     'uploaded'             => 'Fails netika ielādēts. Serveris nevar pieņemt šāda izmēra failus.',
index 95a6831a73258021bd2fab1ec41200c78908ef17..7313f37f1d3f22b26a2f7c7e3df1a24d50835ea4 100644 (file)
@@ -49,8 +49,8 @@ return [
     'favourite_remove_notification' => '«:name» ble fjernet fra dine favoritter',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => 'Flerfaktor-metoden ble konfigurert',
+    'mfa_remove_method_notification' => 'Flerfaktor-metoden ble fjernet',
 
     // Other
     'commented_on'                => 'kommenterte på',
index 18117d244e6e8b50a726f3278ddb4b4ff3ebaba2..4c1f5557732ce02074757b8bd9bb6df197330946 100644 (file)
@@ -76,37 +76,37 @@ return [
     'user_invite_success' => 'Passordet er angitt, du kan nå bruke :appName!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Setup Multi-Factor Authentication',
-    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'mfa_setup_configured' => 'Already configured',
-    'mfa_setup_reconfigure' => 'Reconfigure',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
-    'mfa_setup_action' => 'Setup',
-    'mfa_backup_codes_usage_limit_warning' => '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.',
-    'mfa_option_totp_title' => 'Mobile App',
-    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_option_backup_codes_title' => 'Backup Codes',
-    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
-    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
-    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
-    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
-    'mfa_gen_backup_codes_download' => 'Download Codes',
-    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
-    'mfa_gen_totp_title' => 'Mobile App Setup',
-    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
-    'mfa_gen_totp_verify_setup' => 'Verify Setup',
-    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
-    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
-    'mfa_verify_access' => 'Verify Access',
-    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
-    'mfa_verify_no_methods' => 'No Methods Configured',
-    'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
-    'mfa_verify_use_totp' => 'Verify using a mobile app',
-    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
-    'mfa_verify_backup_code' => 'Backup Code',
-    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
-    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
-    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
-    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+    'mfa_setup' => 'Konfigurer flerfaktor-autentisering',
+    'mfa_setup_desc' => 'Konfigurer flerfaktor-autentisering som et ekstra lag med sikkerhet for brukerkontoen din.',
+    'mfa_setup_configured' => 'Allerede konfigurert',
+    'mfa_setup_reconfigure' => 'Omkonfigurer',
+    'mfa_setup_remove_confirmation' => 'Er du sikker på at du vil deaktivere denne flerfaktor-autentiseringsmetoden?',
+    'mfa_setup_action' => 'Konfigurasjon',
+    'mfa_backup_codes_usage_limit_warning' => 'Du har mindre enn 5 sikkerhetskoder igjen; vennligst generer og lagre ett nytt sett før du går tom for koder, for å unngå å bli låst ute av kontoen din.',
+    'mfa_option_totp_title' => 'Mobilapplikasjon',
+    'mfa_option_totp_desc' => 'For å bruke flerfaktorautentisering trenger du en mobilapplikasjon som støtter TOTP-teknologien, slik som Google Authenticator, Authy eller Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Sikkerhetskoder',
+    'mfa_option_backup_codes_desc' => 'Lagre sikkerhetskoder til engangsbruk på et trygt sted, disse kan du bruke for å verifisere identiteten din.',
+    'mfa_gen_confirm_and_enable' => 'Bekreft og aktiver',
+    'mfa_gen_backup_codes_title' => 'Konfigurasjon av sikkerhetskoder',
+    'mfa_gen_backup_codes_desc' => 'Lagre nedeforstående liste med koder på et trygt sted. Når du skal ha tilgang til systemet kan du bruke en av disse som en faktor under innlogging.',
+    'mfa_gen_backup_codes_download' => 'Last ned koder',
+    'mfa_gen_backup_codes_usage_warning' => 'Hver kode kan kun brukes en gang',
+    'mfa_gen_totp_title' => 'Oppsett for mobilapplikasjon',
+    'mfa_gen_totp_desc' => 'For å bruke flerfaktorautentisering trenger du en mobilapplikasjon som støtter TOTP-teknologien, slik som Google Authenticator, Authy eller Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan QR-koden nedenfor med valgt TOTP-applikasjon for å starte.',
+    'mfa_gen_totp_verify_setup' => 'Bekreft oppsett',
+    'mfa_gen_totp_verify_setup_desc' => 'Bekreft at oppsettet fungerer ved å skrive inn koden fra TOTP-applikasjonen i boksen nedenfor:',
+    'mfa_gen_totp_provide_code_here' => 'Skriv inn den genererte koden her',
+    'mfa_verify_access' => 'Bekreft tilgang',
+    'mfa_verify_access_desc' => 'Brukerkontoen din krever at du bekrefter din identitet med en ekstra autentiseringsfaktor før du får tilgang. Bekreft identiteten med en av dine konfigurerte metoder for å fortsette.',
+    'mfa_verify_no_methods' => 'Ingen metoder er konfigurert',
+    'mfa_verify_no_methods_desc' => 'Ingen flerfaktorautentiseringsmetoder er satt opp for din konto. Du må sette opp minst en metode for å få tilgang.',
+    'mfa_verify_use_totp' => 'Bekreft med mobilapplikasjon',
+    'mfa_verify_use_backup_codes' => 'Bekreft med sikkerhetskode',
+    'mfa_verify_backup_code' => 'Sikkerhetskode',
+    'mfa_verify_backup_code_desc' => 'Skriv inn en av dine ubrukte sikkerhetskoder under:',
+    'mfa_verify_backup_code_enter_here' => 'Skriv inn sikkerhetskode her',
+    'mfa_verify_totp_desc' => 'Skriv inn koden, generert ved hjelp av mobilapplikasjonen, nedenfor:',
+    'mfa_setup_login_notification' => 'Flerfaktorautentisering er konfigurert, vennligst logg inn på nytt med denne metoden.',
 ];
\ No newline at end of file
index ddaa7613066522f61103c1dde46740227427b73b..8ba4e74745df47daec83d6326751a12c40c96244 100644 (file)
@@ -39,7 +39,7 @@ return [
     'reset' => 'Nullstill',
     'remove' => 'Fjern',
     'add' => 'Legg til',
-    'configure' => 'Configure',
+    'configure' => 'Konfigurer',
     'fullscreen' => 'Fullskjerm',
     'favourite' => 'Favorisér',
     'unfavourite' => 'Avfavorisér',
index 130ab7e283a2b1e7ea9e68f6f4333c3b222e8a64..705c33875a2ee6a9d461c20904f704e812533f32 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Tilganger til hylla',
     'shelves_permissions_updated' => 'Hyllas tilganger er oppdatert',
     'shelves_permissions_active' => 'Hyllas tilganger er aktive',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Kopier tilganger til bøkene på hylla',
     'shelves_copy_permissions' => 'Kopier tilganger',
     'shelves_copy_permissions_explain' => 'Dette vil angi gjeldende tillatelsesinnstillinger for denne bokhyllen på alle bøkene som finnes på den. Før du aktiverer, må du forsikre deg om at endringer i tillatelsene til denne bokhyllen er lagret.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Ny side',
     'pages_editing_draft_notification' => 'Du skriver på et utkast som sist ble lagret :timeDiff.',
     'pages_draft_edited_notification' => 'Siden har blitt endret siden du startet. Det anbefales at du forkaster dine endringer.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count forfattere har begynt å endre denne siden.',
         'start_b' => ':userName skriver på siden for øyeblikket',
index 8a0a3242f3df7d4f97d5db8661348322765718d5..cfa82f87c5b70717682c8cb4b494cacca14e6f33 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Kontoholder',
     'audit_table_event' => 'Hendelse',
     'audit_table_related' => 'Relaterte elementer eller detaljer',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Aktivitetsdato',
     'audit_date_from' => 'Datoperiode fra',
     'audit_date_to' => 'Datoperiode til',
@@ -138,7 +139,7 @@ return [
     'role_details' => 'Rolledetaljer',
     'role_name' => 'Rollenavn',
     'role_desc' => 'Kort beskrivelse av rolle',
-    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_mfa_enforced' => 'Krever flerfaktorautentisering',
     'role_external_auth_id' => 'Ekstern godkjennings-ID',
     'role_system' => 'Systemtilganger',
     'role_manage_users' => 'Behandle kontoer',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Behandle sidemaler',
     'role_access_api' => 'Systemtilgang API',
     'role_manage_settings' => 'Behandle applikasjonsinnstillinger',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Eiendomstillatelser',
     'roles_system_warning' => 'Vær oppmerksom på at tilgang til noen av de ovennevnte tre tillatelsene kan tillate en bruker å endre sine egne rettigheter eller rettighetene til andre i systemet. Bare tildel roller med disse tillatelsene til pålitelige brukere.',
     'role_asset_desc' => 'Disse tillatelsene kontrollerer standard tilgang til eiendelene i systemet. Tillatelser til bøker, kapitler og sider overstyrer disse tillatelsene.',
@@ -205,10 +207,10 @@ return [
     'users_api_tokens_create' => 'Opprett nøkkel',
     'users_api_tokens_expires' => 'Utløper',
     'users_api_tokens_docs' => 'API-dokumentasjon',
-    'users_mfa' => 'Multi-Factor Authentication',
-    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'users_mfa_x_methods' => ':count method configured|:count methods configured',
-    'users_mfa_configure' => 'Configure Methods',
+    'users_mfa' => 'Flerfaktorautentisering',
+    'users_mfa_desc' => 'Konfigurer flerfaktorautentisering som et ekstra lag med sikkerhet for din konto.',
+    'users_mfa_x_methods' => ':count metode konfigurert|:count metoder konfigurert',
+    'users_mfa_configure' => 'Konfigurer metoder',
 
     // API Tokens
     'user_api_token_create' => 'Opprett API-nøkkel',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 45db6a99852d9698192418369376f71186f98b9e..684645729870e35f9175c683e54cbd5f20a4f80a 100644 (file)
@@ -15,7 +15,7 @@ return [
     'alpha_dash'           => ':attribute kan kunne inneholde bokstaver, tall, bindestreker eller understreker.',
     'alpha_num'            => ':attribute kan kun inneholde bokstaver og tall.',
     'array'                => ':attribute må være en liste.',
-    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'backup_codes'         => 'Den angitte koden er ikke gyldig, eller er allerede benyttet.',
     'before'               => ':attribute må være en dato før :date.',
     'between'              => [
         'numeric' => ':attribute må være mellom :min og :max.',
@@ -99,7 +99,7 @@ return [
     ],
     'string'               => ':attribute må være en tekststreng.',
     'timezone'             => ':attribute må være en tidssone.',
-    'totp'                 => 'The provided code is not valid or has expired.',
+    'totp'                 => 'Den angitte koden er ikke gyldig eller har utløpt.',
     'unique'               => ':attribute har allerede blitt tatt.',
     'url'                  => ':attribute format er ugyldig.',
     'uploaded'             => 'kunne ikke lastes opp, tjeneren støtter ikke filer av denne størrelsen.',
index bb82ad883b15c958981042167a5c8f5df8065734..e08ae3e62e3a7cb51ac351cb42df004c20e1ddfe 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Boekenplank permissies',
     'shelves_permissions_updated' => 'Boekenplank permissies opgeslagen',
     'shelves_permissions_active' => 'Boekenplank permissies actief',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Kopieer permissies naar boeken',
     'shelves_copy_permissions' => 'Kopieer permissies',
     'shelves_copy_permissions_explain' => 'Met deze actie worden de permissies van deze boekenplank gekopieërd naar alle boeken op de plank. Voordat deze actie wordt uitgevoerd, zorg dat de wijzigingen in de permissies van deze boekenplank zijn opgeslagen.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Nieuwe pagina',
     'pages_editing_draft_notification' => 'U bewerkt momenteel een concept dat voor het laatst is opgeslagen op :timeDiff.',
     'pages_draft_edited_notification' => 'Deze pagina is sindsdien bijgewerkt. Het wordt aanbevolen dat u dit concept verwijderd.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count gebruikers zijn begonnen deze pagina te bewerken',
         'start_b' => ':userName is begonnen met het bewerken van deze pagina',
index e5e5d6808ae0752342b37099726efdb4802721a6..1cbc677ae0568991d9f17ae3b01adedf562605d9 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Gebruiker',
     'audit_table_event' => 'Gebeurtenis',
     'audit_table_related' => 'Gerelateerd Item of Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Activiteit datum',
     'audit_date_from' => 'Datum bereik vanaf',
     'audit_date_to' => 'Datum bereik tot',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Paginasjablonen beheren',
     'role_access_api' => 'Ga naar systeem API',
     'role_manage_settings' => 'Beheer app instellingen',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Asset Permissies',
     'roles_system_warning' => 'Wees ervan bewust dat toegang tot een van de bovengenoemde drie machtigingen een gebruiker in staat kan stellen zijn eigen privileges of de privileges van anderen in het systeem te wijzigen. Wijs alleen rollen toe met deze machtigingen aan vertrouwde gebruikers.',
     'role_asset_desc' => 'Deze permissies bepalen de standaardtoegangsrechten. Permissies op boeken, hoofdstukken en pagina\'s overschrijven deze instelling.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 9c984d46eca14cfdb7f57018a80d560abfe97d71..5ca5fd9f416d237a413427b99db04553a99a797a 100644 (file)
@@ -48,8 +48,8 @@ return [
     'favourite_remove_notification' => '":name" został usunięty z ulubionych',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => 'Metoda wieloskładnikowa została pomyślnie skonfigurowana',
+    'mfa_remove_method_notification' => 'Metoda wieloskładnikowa pomyślnie usunięta',
 
     // Other
     'commented_on'                => 'skomentował',
index eeb6e198c5970109f9c34a7d2eab3c45e3d1d3ca..cb5343c28da51cd41c84a503559fbe79d4aca8d2 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Uprawnienia półki',
     'shelves_permissions_updated' => 'Uprawnienia półki zostały zaktualizowane',
     'shelves_permissions_active' => 'Uprawnienia półki są aktywne',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Skopiuj uprawnienia do książek',
     'shelves_copy_permissions' => 'Skopiuj uprawnienia',
     'shelves_copy_permissions_explain' => 'To spowoduje zastosowanie obecnych ustawień uprawnień dla tej półki do wszystkich książek w niej zawartych. Przed aktywacją upewnij się, że wszelkie zmiany w uprawnieniach do tej półki zostały zapisane.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Nowa strona',
     'pages_editing_draft_notification' => 'Edytujesz obecnie wersje roboczą, która była ostatnio zapisana :timeDiff.',
     'pages_draft_edited_notification' => 'Od tego czasu ta strona była zmieniana. Zalecane jest odrzucenie tej wersji roboczej.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count użytkowników rozpoczęło edytowanie tej strony',
         'start_b' => ':userName edytuje stronę',
index 782137ec35e43f3dda7f71560b12cac4926dd517..18121a9f24262ad02ab49b49b79dd50c464996f5 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Użytkownik',
     'audit_table_event' => 'Wydarzenie',
     'audit_table_related' => 'Powiązany element lub szczegóły',
+    'audit_table_ip' => 'Adres IP',
     'audit_table_date' => 'Data Aktywności',
     'audit_date_from' => 'Zakres dat od',
     'audit_date_to' => 'Zakres dat do',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Zarządzaj szablonami stron',
     'role_access_api' => 'Dostęp do systemowego API',
     'role_manage_settings' => 'Zarządzanie ustawieniami aplikacji',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Zarządzanie zasobami',
     'roles_system_warning' => 'Pamiętaj, że dostęp do trzech powyższych uprawnień może pozwolić użytkownikowi na zmianę własnych uprawnień lub uprawnień innych osób w systemie. Przypisz tylko role z tymi uprawnieniami do zaufanych użytkowników.',
     'role_asset_desc' => 'Te ustawienia kontrolują zarządzanie zasobami systemu. Uprawnienia książek, rozdziałów i stron nadpisują te ustawienia.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index b1f28b8c1288a156e5f2c0701cfd554212268f29..d852d46b98ccafac5389bafefb36dcffd24673fa 100644 (file)
@@ -52,7 +52,7 @@ return [
     'integer'              => ':attribute musi być liczbą całkowitą.',
     'ip'                   => ':attribute musi być prawidłowym adresem IP.',
     'ipv4'                 => ':attribute musi być prawidłowym adresem IPv4.',
-    'ipv6'                 => ':attribute musi być prawidłowym adresem  IPv6.',
+    'ipv6'                 => ':attribute musi być prawidłowym adresem IPv6.',
     'json'                 => ':attribute musi być prawidłowym ciągiem JSON.',
     'lt'                   => [
         'numeric' => ':attribute musi być mniejszy niż :value.',
index 8bf2ff9fcf1ad8fb5b1d58e7ac05e93b2f4ec22c..b3f7af86a38866cf910c6ab04991a4a74306089e 100644 (file)
@@ -6,7 +6,7 @@
 return [
 
     // Pages
-    'page_create'                 => 'página criada',
+    'page_create'                 => 'criou a página',
     'page_create_notification'    => 'Página criada com sucesso',
     'page_update'                 => 'página atualizada',
     'page_update_notification'    => 'Página atualizada com sucesso',
@@ -48,8 +48,8 @@ return [
     'favourite_remove_notification' => '":name" foi removido dos seus favoritos',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => 'Método de múltiplos-fatores configurado com sucesso',
+    'mfa_remove_method_notification' => 'Método de múltiplos-fatores removido com sucesso',
 
     // Other
     'commented_on'                => 'comentado a',
index de3d35e97e1849e1318283c80ff37272ec66fba1..849bfc22ec9717ae0b3cbc508f1cedcc15c68c3a 100644 (file)
@@ -76,12 +76,12 @@ return [
     'user_invite_success' => 'Palavra-passe definida, tem agora acesso a :appName!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Setup Multi-Factor Authentication',
-    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'mfa_setup_configured' => 'Already configured',
-    'mfa_setup_reconfigure' => 'Reconfigure',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
-    'mfa_setup_action' => 'Setup',
+    'mfa_setup' => 'Configurar autenticação de múltiplos fatores',
+    'mfa_setup_desc' => 'Configure a autenticação multi-fatores como uma camada extra de segurança para sua conta de utilizador.',
+    'mfa_setup_configured' => 'Já configurado',
+    'mfa_setup_reconfigure' => 'Reconfigurar',
+    'mfa_setup_remove_confirmation' => 'Tem a certeza que deseja remover este método de autenticação de múltiplos fatores?',
+    'mfa_setup_action' => 'Configuração',
     'mfa_backup_codes_usage_limit_warning' => '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.',
     'mfa_option_totp_title' => 'Mobile App',
     'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
index 19a5dc24a9fb2294b77ba5a4d1c187c508016325..d2d18a2e4ea15801b25148fc8ed3fd17461fc462 100644 (file)
@@ -39,7 +39,7 @@ return [
     'reset' => 'Redefinir',
     'remove' => 'Remover',
     'add' => 'Adicionar',
-    'configure' => 'Configure',
+    'configure' => 'Configurar',
     'fullscreen' => 'Ecrã completo',
     'favourite' => 'Favorito',
     'unfavourite' => 'Retirar Favorito',
index 5281edf27bf404d8c9dffcbbe06b0d2251ffccdd..07e2d5f64853491e70578023abcc225b0003b3a0 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Permissões da Estante',
     'shelves_permissions_updated' => 'Permissões da Estante de Livros Atualizada',
     'shelves_permissions_active' => 'Permissões da Estante de Livros Ativas',
+    'shelves_permissions_cascade_warning' => 'As permissões nas estantes não são passadas automaticamente em efeito dominó para os livros contidos. Isto acontece porque um livro pode existir em várias prateleiras. As permissões podem, no entanto, ser copiadas para livros filhos usando a opção encontrada abaixo.',
     'shelves_copy_permissions_to_books' => 'Copiar Permissões para Livros',
     'shelves_copy_permissions' => 'Copiar Permissões',
     'shelves_copy_permissions_explain' => 'Isto aplicará as configurações de permissões atuais desta estante a todos os livros nela contidos. Antes de ativar, assegure-se de que quaisquer alterações nas permissões desta estante foram guardadas.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Nova Página',
     'pages_editing_draft_notification' => 'Você está atualmente a editar um rascunho que foi guardado pela última vez a :timeDiff.',
     'pages_draft_edited_notification' => 'Esta página entretanto já foi atualizada. É recomendado que você descarte este rascunho.',
+    'pages_draft_page_changed_since_creation' => 'Esta página foi atualizada desde que este rascunho foi criado. É recomendável que descarte este rascunho ou tenha cuidado para não sobrescrever nenhuma alteração de página.',
     'pages_draft_edit_active' => [
         'start_a' => ':count usuários iniciaram a edição dessa página',
         'start_b' => ':userName iniciou a edição desta página',
index 6679eb87cc9792ceafb4c592a7dbd30cb5b98757..e85ce74b25e867fff7f28834d3d79041b63773bc 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Utilizador',
     'audit_table_event' => 'Evento',
     'audit_table_related' => 'Item ou Detalhe Relacionado',
+    'audit_table_ip' => 'Endereço de IP',
     'audit_table_date' => 'Data da Atividade',
     'audit_date_from' => 'Intervalo De',
     'audit_date_to' => 'Intervalo Até',
@@ -138,7 +139,7 @@ return [
     'role_details' => 'Detalhes do Cargo',
     'role_name' => 'Nome do Cargo',
     'role_desc' => 'Breve Descrição do Cargo',
-    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_mfa_enforced' => 'Exige autenticação de múltiplos fatores',
     'role_external_auth_id' => 'IDs de Autenticação Externa',
     'role_system' => 'Permissões do Sistema',
     'role_manage_users' => 'Gerir utilizadores',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Gerir modelos de página',
     'role_access_api' => 'Aceder à API do sistema',
     'role_manage_settings' => 'Gerir as configurações da aplicação',
+    'role_export_content' => 'Exportar conteúdo',
     'role_asset' => 'Permissões de Ativos',
     'roles_system_warning' => 'Esteja ciente de que o acesso a qualquer uma das três permissões acima pode permitir que um utilizador altere os seus próprios privilégios ou privilégios de outros no sistema. Apenas atribua cargos com essas permissões a utilizadores de confiança.',
     'role_asset_desc' => 'Estas permissões controlam o acesso padrão para os ativos dentro do sistema. Permissões em Livros, Capítulos e Páginas serão sobrescritas por estas permissões.',
@@ -205,10 +207,10 @@ return [
     'users_api_tokens_create' => 'Criar Token',
     'users_api_tokens_expires' => 'Expira',
     'users_api_tokens_docs' => 'Documentação da API',
-    'users_mfa' => 'Multi-Factor Authentication',
-    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'users_mfa_x_methods' => ':count method configured|:count methods configured',
-    'users_mfa_configure' => 'Configure Methods',
+    'users_mfa' => 'Autenticação Multi-fator',
+    'users_mfa_desc' => 'Configure a autenticação multi-fatores como uma camada extra de segurança para sua conta de utilizador.',
+    'users_mfa_x_methods' => ':count método configurado|:count métodos configurados',
+    'users_mfa_configure' => 'Configurar Métodos',
 
     // API Tokens
     'user_api_token_create' => 'Criar Token de API',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 30033fce8f5593214962fb43010aa508bc585ed3..b5a15ed363016e9212e6d14fa769b075097ffa76 100644 (file)
@@ -15,7 +15,7 @@ return [
     'alpha_dash'           => 'O campo :attribute deve conter apenas letras, números, traços e sublinhado.',
     'alpha_num'            => 'O campo :attribute deve conter apenas letras e números.',
     'array'                => 'O campo :attribute deve ser uma lista(array).',
-    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'backup_codes'         => 'O código fornecido não é válido ou já foi utilizado.',
     'before'               => 'O campo :attribute deve ser uma data anterior à data :date.',
     'between'              => [
         'numeric' => 'O campo :attribute deve estar entre :min e :max.',
@@ -99,7 +99,7 @@ return [
     ],
     'string'               => 'O campo :attribute deve ser uma string.',
     'timezone'             => 'O campo :attribute deve conter uma timezone válida.',
-    'totp'                 => 'The provided code is not valid or has expired.',
+    'totp'                 => 'O código fornecido não é válido ou já expirou.',
     'unique'               => 'Já existe um campo/dado de nome :attribute.',
     'url'                  => 'O formato da URL :attribute é inválido.',
     'uploaded'             => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.',
index 1d1239332e491bbcc9af2b3ffc97c2757e69b74c..14ce2a832bbbb5688967bf0dc2a3a3f73bde268d 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Permissões da Prateleira',
     'shelves_permissions_updated' => 'Permissões da Prateleira Atualizadas',
     'shelves_permissions_active' => 'Permissões da Prateleira Ativas',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Copiar Permissões para Livros',
     'shelves_copy_permissions' => 'Copiar Permissões',
     'shelves_copy_permissions_explain' => 'Isto aplicará as configurações de permissões atuais desta prateleira a todos os livros contidos nela. Antes de ativar, assegure-se de que quaisquer alterações nas permissões desta prateleira tenham sido salvas.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Nova Página',
     'pages_editing_draft_notification' => 'Você está atualmente editando um rascunho que foi salvo da última vez em :timeDiff.',
     'pages_draft_edited_notification' => 'Essa página foi atualizada desde então. É recomendado que você descarte esse rascunho.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count usuários iniciaram a edição dessa página',
         'start_b' => ':userName iniciou a edição dessa página',
index 69be0b38fea2d398ce82eb11a34835705cda21aa..c5b113da3f7d5b029c772e6f71969c9c979750ad 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Usuário',
     'audit_table_event' => 'Evento',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Data da Atividade',
     'audit_date_from' => 'Date Range From',
     'audit_date_to' => 'Date Range To',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Gerenciar modelos de página',
     'role_access_api' => 'Acessar API do sistema',
     'role_manage_settings' => 'Gerenciar configurações da aplicação',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Permissões de Ativos',
     'roles_system_warning' => 'Esteja ciente de que o acesso a qualquer uma das três permissões acima pode permitir que um usuário altere seus próprios privilégios ou privilégios de outros usuários no sistema. Apenas atribua cargos com essas permissões para usuários confiáveis.',
     'role_asset_desc' => 'Essas permissões controlam o acesso padrão para os ativos dentro do sistema. Permissões em Livros, Capítulos e Páginas serão sobrescritas por essas permissões.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 5a9eb8170047dbb386bd922660869bb01715943e..0a43afc5a543039cf9019fdb3e0cd240cec82147 100644 (file)
@@ -48,8 +48,8 @@ return [
     'favourite_remove_notification' => '":name" удалено из избранного',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => 'Двухфакторный метод авторизации успешно настроен',
+    'mfa_remove_method_notification' => 'Двухфакторный метод авторизации успешно удален',
 
     // Other
     'commented_on'                => 'прокомментировал',
index 1c9c9309dbbc8ebf71cc6b6558246fe4c75d1fd8..8410e40e4503771e688df1be1bd5474305224262 100644 (file)
@@ -76,37 +76,37 @@ return [
     'user_invite_success' => 'Пароль установлен, теперь у вас есть доступ к :appName!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Setup Multi-Factor Authentication',
-    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'mfa_setup_configured' => 'Already configured',
-    'mfa_setup_reconfigure' => 'Reconfigure',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
-    'mfa_setup_action' => 'Setup',
-    'mfa_backup_codes_usage_limit_warning' => '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.',
-    'mfa_option_totp_title' => 'Mobile App',
-    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_option_backup_codes_title' => 'Backup Codes',
-    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
-    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
-    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
-    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
-    'mfa_gen_backup_codes_download' => 'Download Codes',
-    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
-    'mfa_gen_totp_title' => 'Mobile App Setup',
-    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
-    'mfa_gen_totp_verify_setup' => 'Verify Setup',
-    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
-    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
-    'mfa_verify_access' => 'Verify Access',
-    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
-    'mfa_verify_no_methods' => 'No Methods Configured',
-    'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
-    'mfa_verify_use_totp' => 'Verify using a mobile app',
-    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
-    'mfa_verify_backup_code' => 'Backup Code',
-    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
-    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
-    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
-    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+    'mfa_setup' => 'Двухфакторная аутентификация',
+    'mfa_setup_desc' => 'Двухфакторная аутентификация повышает степень безопасности вашей учетной записи.',
+    'mfa_setup_configured' => 'Настроено',
+    'mfa_setup_reconfigure' => 'Перенастроить',
+    'mfa_setup_remove_confirmation' => 'Вы уверены, что хотите удалить этот двухфакторный метод аутентификации?',
+    'mfa_setup_action' => 'Настройка',
+    'mfa_backup_codes_usage_limit_warning' => 'У вас осталось менее 5 резервных кодов, пожалуйста, создайте и сохраните новый набор перед тем, как закончатся коды, чтобы предотвратить блокировку вашей учетной записи.',
+    'mfa_option_totp_title' => 'Мобильное приложение',
+    'mfa_option_totp_desc' => 'Для использования двухфакторной аутентификации вам понадобится мобильное приложение, поддерживающее TOTP, например Google Authenticator, Authy или Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Резервные коды',
+    'mfa_option_backup_codes_desc' => 'Безопасно хранить набор одноразовых резервных кодов, которые вы можете ввести для проверки вашей личности.',
+    'mfa_gen_confirm_and_enable' => 'Подтвердить и включить',
+    'mfa_gen_backup_codes_title' => 'Настройка резервных кодов',
+    'mfa_gen_backup_codes_desc' => 'Сохраните приведенный ниже список кодов в безопасном месте. При доступе к системе вы сможете использовать один из кодов в качестве второго механизма аутентификации.',
+    'mfa_gen_backup_codes_download' => 'Скачать коды',
+    'mfa_gen_backup_codes_usage_warning' => 'Каждый код может быть использован только один раз',
+    'mfa_gen_totp_title' => 'Настройка мобильного приложения',
+    'mfa_gen_totp_desc' => 'Для использования двухфакторной аутентификации вам понадобится мобильное приложение, поддерживающее TOTP, например Google Authenticator, Authy или Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Отсканируйте QR-код, используя приложение для аутентификации.',
+    'mfa_gen_totp_verify_setup' => 'Проверить настройки',
+    'mfa_gen_totp_verify_setup_desc' => 'Проверьте, что все работает введя код, сгенерированный внутри вашего приложения для аутентификации, в поле ввода ниже:',
+    'mfa_gen_totp_provide_code_here' => 'Введите код, сгенерированный приложением',
+    'mfa_verify_access' => 'Подтвердите доступ',
+    'mfa_verify_access_desc' => 'Ваша учетная запись требует подтверждения личности на дополнительном уровне верификации, прежде чем вам будет предоставлен доступ. Для продолжения подтвердите вход, используя один из настроенных методов.',
+    'mfa_verify_no_methods' => 'Методы не настроены',
+    'mfa_verify_no_methods_desc' => 'Для вашей учетной записи не найдены двухфакторные методы аутентификации. Вам нужно настроить хотя бы один метод, прежде чем получить доступ.',
+    'mfa_verify_use_totp' => 'Проверить используя мобильное приложение',
+    'mfa_verify_use_backup_codes' => 'Проверить используя резервный код',
+    'mfa_verify_backup_code' => 'Резервный код',
+    'mfa_verify_backup_code_desc' => 'Введите один из оставшихся резервных кодов ниже:',
+    'mfa_verify_backup_code_enter_here' => 'Введите резервный код',
+    'mfa_verify_totp_desc' => 'Введите код, сгенерированный с помощью мобильного приложения, ниже:',
+    'mfa_setup_login_notification' => 'Двухфакторный метод настроен, пожалуйста, войдите снова, используя сконфигурированный метод.',
 ];
\ No newline at end of file
index 5aab6729227a5cc14e4d02e7d0ebd54869db05d3..0bcb7c1d43384e24b54ae7e3eb238384bc819c1a 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Доступы к книжной полке',
     'shelves_permissions_updated' => 'Доступы к книжной полке обновлены',
     'shelves_permissions_active' => 'Действующие разрешения книжной полки',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Наследовать доступы книгам',
     'shelves_copy_permissions' => 'Копировать доступы',
     'shelves_copy_permissions_explain' => 'Это применит текущие настройки доступов этой книжной полки ко всем книгам, содержащимся внутри. Перед активацией убедитесь, что все изменения в доступах этой книжной полки сохранены.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Новая страница',
     'pages_editing_draft_notification' => 'В настоящее время вы редактируете черновик, который был сохранён :timeDiff.',
     'pages_draft_edited_notification' => 'Эта страница была обновлена до этого момента. Рекомендуется отменить этот черновик.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count пользователей начали редактирование этой страницы',
         'start_b' => ':userName начал редактирование этой страницы',
index b6c1de640cc34f97bc4955ac24306d598833f8f5..e4bd8534094c9506432a34d772ca54332d27d35b 100755 (executable)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Пользователь',
     'audit_table_event' => 'Событие',
     'audit_table_related' => 'Связанный элемент',
+    'audit_table_ip' => 'IP-адрес',
     'audit_table_date' => 'Дата действия',
     'audit_date_from' => 'Диапазон даты от',
     'audit_date_to' => 'Диапазон даты до',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Управление шаблонами страниц',
     'role_access_api' => 'Доступ к системному API',
     'role_manage_settings' => 'Управление настройками приложения',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Права доступа к материалам',
     'roles_system_warning' => 'Имейте в виду, что доступ к любому из указанных выше трех разрешений может позволить пользователю изменить свои собственные привилегии или привилегии других пользователей системы. Назначать роли с этими правами можно только доверенным пользователям.',
     'role_asset_desc' => 'Эти разрешения контролируют доступ по умолчанию к параметрам внутри системы. Разрешения на книги, главы и страницы перезапишут эти разрешения.',
@@ -205,10 +207,10 @@ return [
     'users_api_tokens_create' => 'Создать токен',
     'users_api_tokens_expires' => 'Истекает',
     'users_api_tokens_docs' => 'Документация',
-    'users_mfa' => 'Multi-Factor Authentication',
-    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa' => 'Двухфакторная аутентификация',
+    'users_mfa_desc' => 'Двухфакторная аутентификация повышает степень безопасности вашей учетной записи.',
     'users_mfa_x_methods' => ':count method configured|:count methods configured',
-    'users_mfa_configure' => 'Configure Methods',
+    'users_mfa_configure' => 'Настройка методов',
 
     // API Tokens
     'user_api_token_create' => 'Создать токен',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 5b3f8f76647f61211cfff2407dedf79928bc9480..45cc96155dbbff435580c6421a3e3509163cd487 100644 (file)
@@ -15,7 +15,7 @@ return [
     'alpha_dash'           => ':attribute может содержать только буквы, цифры и тире.',
     'alpha_num'            => ':attribute должен содержать только буквы и цифры.',
     'array'                => ':attribute должен быть массивом.',
-    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'backup_codes'         => 'Указанный код недействителен или уже использован.',
     'before'               => ':attribute дата должна быть до :date.',
     'between'              => [
         'numeric' => ':attribute должен быть между :min и :max.',
@@ -99,7 +99,7 @@ return [
     ],
     'string'               => ':attribute должен быть строкой.',
     'timezone'             => ':attribute должен быть корректным часовым поясом.',
-    'totp'                 => 'The provided code is not valid or has expired.',
+    'totp'                 => 'Указанный код недействителен или истек.',
     'unique'               => ':attribute уже есть.',
     'url'                  => 'Формат :attribute некорректен.',
     'uploaded'             => 'Не удалось загрузить файл. Сервер не может принимать файлы такого размера.',
index 91b9e1880e1e7d4dd371871f732e6394b6a713de..9f9ded00e731a110a8913200dd4556efb9f36050 100644 (file)
@@ -8,7 +8,7 @@ return [
     // Pages
     'page_create'                 => 'vytvoril(a) stránku',
     'page_create_notification'    => 'Stránka úspešne vytvorená',
-    'page_update'                 => 'aktualizoval stránku',
+    'page_update'                 => 'aktualizoval(a) stránku',
     'page_update_notification'    => 'Stránka úspešne aktualizovaná',
     'page_delete'                 => 'odstránil(a) stránku',
     'page_delete_notification'    => 'Stránka úspešne odstránená',
@@ -44,12 +44,12 @@ return [
     'bookshelf_delete_notification'    => 'Knižnica úspešne odstránená',
 
     // Favourites
-    'favourite_add_notification' => '":name" has been added to your favourites',
-    'favourite_remove_notification' => '":name" has been removed from your favourites',
+    'favourite_add_notification' => '":name" bol pridaný medzi obľúbené',
+    'favourite_remove_notification' => '":name" bol odstránený z obľúbených',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => 'Viacúrovňový spôsob overenia úspešne nastavený',
+    'mfa_remove_method_notification' => 'Viacúrovňový spôsob overenia úspešne odstránený',
 
     // Other
     'commented_on'                => 'komentoval(a)',
index 92cf4f648613c0f83bf62f4554afaf2cf47217c5..f79e79cca940747ceda6628f6801755ec9985fff 100644 (file)
@@ -76,37 +76,37 @@ return [
     'user_invite_success' => 'Heslo bolo nastavené, teraz máte prístup k :appName!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Setup Multi-Factor Authentication',
-    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'mfa_setup_configured' => 'Already configured',
-    'mfa_setup_reconfigure' => 'Reconfigure',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
-    'mfa_setup_action' => 'Setup',
+    'mfa_setup' => 'Nastaviť viacúrovňové prihlasovanie',
+    'mfa_setup_desc' => 'Pre vyššiu úroveň bezpečnosti si nastavte viacúrovňové prihlasovanie.',
+    'mfa_setup_configured' => 'Už nastavené',
+    'mfa_setup_reconfigure' => 'Znovunastavenie',
+    'mfa_setup_remove_confirmation' => 'Ste si istý, že chcete odstrániť tento spôsob viacúrovňového overenia?',
+    'mfa_setup_action' => 'Nastaveine',
     'mfa_backup_codes_usage_limit_warning' => '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.',
-    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_title' => 'Mobilná aplikácia',
     'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_option_backup_codes_title' => 'Backup Codes',
-    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
-    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
-    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
-    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
-    'mfa_gen_backup_codes_download' => 'Download Codes',
-    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
-    'mfa_gen_totp_title' => 'Mobile App Setup',
-    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
-    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_option_backup_codes_title' => 'Záložné kódy',
+    'mfa_option_backup_codes_desc' => 'Bezpečne uložte jednorázové záložné kódy pre overenie vačej identity.',
+    'mfa_gen_confirm_and_enable' => 'Potvrdiť a zapnúť',
+    'mfa_gen_backup_codes_title' => 'Nastavenie záložných kódov',
+    'mfa_gen_backup_codes_desc' => 'Uložte si tieto kódy na bezpečné miesto. Jeden z kódov budete môcť použiť ako druhý faktor overenia identiy na prihlásenie sa.',
+    'mfa_gen_backup_codes_download' => 'Stiahnuť kódy',
+    'mfa_gen_backup_codes_usage_warning' => 'Každý kód môže byť použitý len jeden krát',
+    'mfa_gen_totp_title' => 'Nastavenie mobilnej aplikácie',
+    'mfa_gen_totp_desc' => 'Pre používanie viacúrovňového prihlasovania budete potrebovať mobilnú aplikáciu, ktorá podporuje TOPS ako napríklad Google Authenticator, Authy alebo Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Naskenujte 1R k\'d pomocou vašej mobilnej aplikácie.',
+    'mfa_gen_totp_verify_setup' => 'Overiť nastavenie',
     'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
-    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
-    'mfa_verify_access' => 'Verify Access',
+    'mfa_gen_totp_provide_code_here' => 'Sem vložte kód vygenerovaný vašou mobilnou aplikáciou',
+    'mfa_verify_access' => 'Overiť prístup',
     'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
-    'mfa_verify_no_methods' => 'No Methods Configured',
+    'mfa_verify_no_methods' => 'Žiadny spôsob nebol nastavený',
     'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
-    'mfa_verify_use_totp' => 'Verify using a mobile app',
-    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
-    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_use_totp' => 'Overiť pomocou mobilnej aplikácie',
+    'mfa_verify_use_backup_codes' => 'Overiť pomocou záložného kódu',
+    'mfa_verify_backup_code' => 'Záložný kód',
     'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
-    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
-    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_verify_backup_code_enter_here' => 'Zadajte záložný kód',
+    'mfa_verify_totp_desc' => 'Zadajte kód vygenerovaný vašou mobilnou aplikáciou:',
     'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
 ];
\ No newline at end of file
index aab9593050025f49671de3bcdd78c9102925006d..b9913db5942ec5a6b30c29c9f69d09a7487dd844 100644 (file)
@@ -39,12 +39,12 @@ return [
     'reset' => 'Resetovať',
     'remove' => 'Odstrániť',
     'add' => 'Pridať',
-    'configure' => 'Configure',
+    'configure' => 'Konfigurácia',
     'fullscreen' => 'Celá obrazovka',
-    'favourite' => 'Favourite',
-    'unfavourite' => 'Unfavourite',
-    'next' => 'Next',
-    'previous' => 'Previous',
+    'favourite' => 'Pridať do obľúbených',
+    'unfavourite' => 'Odstrániť z obľúbených',
+    'next' => 'Ďalej',
+    'previous' => 'Späť',
 
     // Sort Options
     'sort_options' => 'Možnosti triedenia',
@@ -52,7 +52,7 @@ return [
     'sort_ascending' => 'Zoradiť vzostupne',
     'sort_descending' => 'Zoradiť zostupne',
     'sort_name' => 'Meno',
-    'sort_default' => 'Default',
+    'sort_default' => 'Východzie',
     'sort_created_at' => 'Dátum vytvorenia',
     'sort_updated_at' => 'Aktualizované dňa',
 
@@ -61,7 +61,7 @@ return [
     'no_activity' => 'Žiadna aktivita na zobrazenie',
     'no_items' => 'Žiadne položky nie sú dostupné',
     'back_to_top' => 'Späť nahor',
-    'skip_to_main_content' => 'Skip to main content',
+    'skip_to_main_content' => 'Preskočiť na hlavný obsah',
     'toggle_details' => 'Prepnúť detaily',
     'toggle_thumbnails' => 'Prepnúť náhľady',
     'details' => 'Podrobnosti',
@@ -71,7 +71,7 @@ return [
     'breadcrumb' => 'Breadcrumb',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'Rozbaliť menu v záhlaví',
     'profile_menu' => 'Menu profilu',
     'view_profile' => 'Zobraziť profil',
     'edit_profile' => 'Upraviť profil',
@@ -80,9 +80,9 @@ return [
 
     // Layout tabs
     'tab_info' => 'Informácie',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Tab: Zobraziť vedľajšie informácie',
     'tab_content' => 'Obsah',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Tab: Zobraziť hlavné informácie',
 
     // Email Content
     'email_action_help' => 'Ak máte problém klinkúť na tlačidlo ":actionText", skopírujte a vložte URL uvedenú nižšie do Vášho prehliadača:',
@@ -90,6 +90,6 @@ return [
 
     // Footer Link Options
     // Not directly used but available for convenience to users.
-    'privacy_policy' => 'Privacy Policy',
-    'terms_of_service' => 'Terms of Service',
+    'privacy_policy' => 'Zásady ochrany osobných údajov',
+    'terms_of_service' => 'Podmienky používania',
 ];
index 4851361bde8a2b90163d4aab45a0dfb73331ecab..e7531795fc6be94749ba7f33d6a654670ce6b5cb 100644 (file)
@@ -22,13 +22,13 @@ return [
     'meta_created_name' => 'Vytvorené :timeLength používateľom :user',
     'meta_updated' => 'Aktualizované :timeLength',
     'meta_updated_name' => 'Aktualizované :timeLength používateľom :user',
-    'meta_owned_name' => 'Owned by :user',
+    'meta_owned_name' => 'Vlastník :user',
     'entity_select' => 'Entita vybraná',
     'images' => 'Obrázky',
     'my_recent_drafts' => 'Moje nedávne koncepty',
     'my_recently_viewed' => 'Nedávno mnou zobrazené',
-    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
-    'my_favourites' => 'My Favourites',
+    'my_most_viewed_favourites' => 'Moje najčastejšie zobrazené obľubené',
+    'my_favourites' => 'Moje obľúbené',
     'no_pages_viewed' => 'Nepozreli ste si žiadne stránky',
     'no_pages_recently_created' => 'Žiadne stránky neboli nedávno vytvorené',
     'no_pages_recently_updated' => 'Žiadne stránky neboli nedávno aktualizované',
@@ -36,14 +36,14 @@ return [
     'export_html' => 'Obsahovaný webový súbor',
     'export_pdf' => 'PDF súbor',
     'export_text' => 'Súbor s čistým textom',
-    'export_md' => 'Markdown File',
+    'export_md' => 'Súbor Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Oprávnenia',
     'permissions_intro' => 'Ak budú tieto oprávnenia povolené, budú mať prioritu pred oprávneniami roly.',
     'permissions_enable' => 'Povoliť vlastné oprávnenia',
     'permissions_save' => 'Uložiť oprávnenia',
-    'permissions_owner' => 'Owner',
+    'permissions_owner' => 'Vlastník',
 
     // Search
     'search_results' => 'Výsledky hľadania',
@@ -63,7 +63,7 @@ return [
     'search_permissions_set' => 'Oprávnenia',
     'search_created_by_me' => 'Vytvorené mnou',
     'search_updated_by_me' => 'Aktualizované mnou',
-    'search_owned_by_me' => 'Owned by me',
+    'search_owned_by_me' => 'Patriace mne',
     'search_date_options' => 'Možnosti dátumu',
     'search_updated_before' => 'Aktualizované pred',
     'search_updated_after' => 'Aktualizované po',
@@ -99,22 +99,23 @@ return [
     'shelves_permissions' => 'Oprávnenia knižnice',
     'shelves_permissions_updated' => 'Oprávnenia knižnice aktualizované',
     'shelves_permissions_active' => 'Oprávnenia knižnice aktívne',
-    'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
-    'shelves_copy_permissions' => 'Copy Permissions',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
+    'shelves_copy_permissions_to_books' => 'Kopírovať oprávnenia pre knihy',
+    'shelves_copy_permissions' => 'Kopírovať oprávnenia',
     'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
-    'shelves_copy_permission_success' => 'Bookshelf permissions copied to :count books',
+    'shelves_copy_permission_success' => 'Oprávnenia knižnice boli skopírované {0}:count kníh|{1}:count kniha|[2,3,4]:count knihy|[5,*]:count kníh',
 
     // Books
     'book' => 'Kniha',
     'books' => 'Knihy',
-    'x_books' => ':count Book|:count Books',
+    'x_books' => '{0}:count kníh|{1}:count kniha|[2,3,4]:count knihy|[5,*]:count kníh',
     'books_empty' => 'Žiadne knihy neboli vytvorené',
     'books_popular' => 'Populárne knihy',
     'books_recent' => 'Nedávne knihy',
-    'books_new' => 'New Books',
-    'books_new_action' => 'New Book',
+    'books_new' => 'Nové knihy',
+    'books_new_action' => 'Nová kniha',
     'books_popular_empty' => 'Najpopulárnejšie knihy sa objavia tu.',
-    'books_new_empty' => 'The most recently created books will appear here.',
+    'books_new_empty' => 'Najnovšie knihy sa zobrazia tu.',
     'books_create' => 'Vytvoriť novú knihu',
     'books_delete' => 'Zmazať knihu',
     'books_delete_named' => 'Zmazať knihu :bookName',
@@ -135,24 +136,24 @@ return [
     'books_navigation' => 'Navigácia knihy',
     'books_sort' => 'Zoradiť obsah knihy',
     'books_sort_named' => 'Zoradiť knihu :bookName',
-    'books_sort_name' => 'Sort by Name',
-    'books_sort_created' => 'Sort by Created Date',
-    'books_sort_updated' => 'Sort by Updated Date',
-    'books_sort_chapters_first' => 'Chapters First',
-    'books_sort_chapters_last' => 'Chapters Last',
+    'books_sort_name' => 'Zoradiť podľa mena',
+    'books_sort_created' => 'Zoradiť podľa dátumu vytvorenia',
+    'books_sort_updated' => 'Zoradiť podľa dátumu aktualizácie',
+    'books_sort_chapters_first' => 'Kapitoly ako prvé',
+    'books_sort_chapters_last' => 'Kapitoly ako posledné',
     'books_sort_show_other' => 'Zobraziť ostatné knihy',
     'books_sort_save' => 'Uložiť nové zoradenie',
 
     // Chapters
     'chapter' => 'Kapitola',
     'chapters' => 'Kapitoly',
-    'x_chapters' => ':count Chapter|:count Chapters',
+    'x_chapters' => '{0}:count Kapitol|{1}:count Kapitola|[2,3,4]:count Kapitoly|[5,*]:count Kapitol',
     'chapters_popular' => 'Populárne kapitoly',
     'chapters_new' => 'Nová kapitola',
     'chapters_create' => 'Vytvoriť novú kapitolu',
     'chapters_delete' => 'Zmazať kapitolu',
     'chapters_delete_named' => 'Zmazať kapitolu :chapterName',
-    'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
+    'chapters_delete_explain' => 'Týmto sa odstráni kapitola s názvom \':chapterName\'. Spolu s ňou sa odstránia všetky stránky v tejto kapitole.',
     'chapters_delete_confirm' => 'Ste si istý, že chcete zmazať túto kapitolu?',
     'chapters_edit' => 'Upraviť kapitolu',
     'chapters_edit_named' => 'Upraviť kapitolu :chapterName',
@@ -164,7 +165,7 @@ return [
     'chapters_empty' => 'V tejto kapitole nie sú teraz žiadne stránky.',
     'chapters_permissions_active' => 'Oprávnenia kapitoly aktívne',
     'chapters_permissions_success' => 'Oprávnenia kapitoly aktualizované',
-    'chapters_search_this' => 'Search this chapter',
+    'chapters_search_this' => 'Hladať v kapitole',
 
     // Pages
     'page' => 'Stránka',
@@ -183,7 +184,7 @@ return [
     'pages_delete_confirm' => 'Ste si istý, že chcete zmazať túto stránku?',
     'pages_delete_draft_confirm' => 'Ste si istý, že chcete zmazať tento koncept stránky?',
     'pages_editing_named' => 'Upraviť stránku :pageName',
-    'pages_edit_draft_options' => 'Draft Options',
+    'pages_edit_draft_options' => 'Možnosti konceptu',
     'pages_edit_save_draft' => 'Uložiť koncept',
     'pages_edit_draft' => 'Upraviť koncept stránky',
     'pages_editing_draft' => 'Upravuje sa koncept',
@@ -201,25 +202,25 @@ return [
     'pages_md_preview' => 'Náhľad',
     'pages_md_insert_image' => 'Vložiť obrázok',
     'pages_md_insert_link' => 'Vložiť odkaz na entitu',
-    'pages_md_insert_drawing' => 'Insert Drawing',
+    'pages_md_insert_drawing' => 'Vložiť kresbu',
     'pages_not_in_chapter' => 'Stránka nie je v kapitole',
     'pages_move' => 'Presunúť stránku',
     'pages_move_success' => 'Stránka presunutá do ":parentName"',
-    'pages_copy' => 'Copy Page',
-    'pages_copy_desination' => 'Copy Destination',
-    'pages_copy_success' => 'Page successfully copied',
+    'pages_copy' => 'Kpoírovať stránku',
+    'pages_copy_desination' => 'Ciel kopírovania',
+    'pages_copy_success' => 'Stránka bola skopírovaná',
     'pages_permissions' => 'Oprávnenia stránky',
     'pages_permissions_success' => 'Oprávnenia stránky aktualizované',
-    'pages_revision' => 'Revision',
+    'pages_revision' => 'Revízia',
     'pages_revisions' => 'Revízie stránky',
     'pages_revisions_named' => 'Revízie stránky :pageName',
     'pages_revision_named' => 'Revízia stránky :pageName',
-    'pages_revision_restored_from' => 'Restored from #:id; :summary',
+    'pages_revision_restored_from' => 'Obnovené z #:id; :summary',
     'pages_revisions_created_by' => 'Vytvoril',
     'pages_revisions_date' => 'Dátum revízie',
-    'pages_revisions_number' => '#',
-    'pages_revisions_numbered' => 'Revision #:id',
-    'pages_revisions_numbered_changes' => 'Revision #:id Changes',
+    'pages_revisions_number' => 'č.',
+    'pages_revisions_numbered' => 'Revízia č. :id',
+    'pages_revisions_numbered_changes' => 'Zmeny revízie č. ',
     'pages_revisions_changelog' => 'Záznam zmien',
     'pages_revisions_changes' => 'Zmeny',
     'pages_revisions_current' => 'Aktuálna verzia',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Nová stránka',
     'pages_editing_draft_notification' => 'Práve upravujete koncept, ktorý bol naposledy uložený :timeDiff.',
     'pages_draft_edited_notification' => 'Táto stránka bola odvtedy upravená. Odporúča sa odstrániť tento koncept.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count používateľov začalo upravovať túto stránku',
         'start_b' => ':userName začal upravovať túto stránku',
@@ -241,21 +243,21 @@ return [
         'message' => ':start :time. Dávajte pozor aby ste si navzájom neprepísali zmeny!',
     ],
     'pages_draft_discarded' => 'Koncept ostránený, aktuálny obsah stránky bol nahraný do editora',
-    'pages_specific' => 'Specific Page',
-    'pages_is_template' => 'Page Template',
+    'pages_specific' => 'Konkrétna stránka',
+    'pages_is_template' => 'Šablóna stránky',
 
     // Editor Sidebar
     'page_tags' => 'Štítky stránok',
-    'chapter_tags' => 'Chapter Tags',
-    'book_tags' => 'Book Tags',
-    'shelf_tags' => 'Shelf Tags',
+    'chapter_tags' => 'Štítky kapitol',
+    'book_tags' => 'Štítky kníh',
+    'shelf_tags' => 'Štítky knižníc',
     'tag' => 'Štítok',
     'tags' =>  'Štítky',
-    'tag_name' =>  'Tag Name',
+    'tag_name' =>  'Názov štítku',
     'tag_value' => 'Hodnota štítku (Voliteľné)',
     'tags_explain' => "Pridajte pár štítkov pre uľahčenie kategorizácie Vášho obsahu. \n Štítku môžete priradiť hodnotu pre ešte lepšiu organizáciu.",
     'tags_add' => 'Pridať ďalší štítok',
-    'tags_remove' => 'Remove this tag',
+    'tags_remove' => 'Odstrániť tento štítok',
     'attachments' => 'Prílohy',
     'attachments_explain' => 'Nahrajte nejaké súbory alebo priložte zopár odkazov pre zobrazenie na Vašej stránke. Budú viditeľné v bočnom paneli.',
     'attachments_explain_instant_save' => 'Zmeny budú okamžite uložené.',
@@ -282,8 +284,8 @@ return [
     'attachments_file_uploaded' => 'Súbor úspešne nahraný',
     'attachments_file_updated' => 'Súbor úspešne aktualizovaný',
     'attachments_link_attached' => 'Odkaz úspešne pripojený k stránke',
-    'templates' => 'Templates',
-    'templates_set_as_template' => 'Page is a template',
+    'templates' => 'Šablóny',
+    'templates_set_as_template' => 'Táto stránka je šablóna',
     'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.',
     'templates_replace_content' => 'Replace page content',
     'templates_append_content' => 'Append to page content',
@@ -300,18 +302,18 @@ return [
     // Comments
     'comment' => 'Komentár',
     'comments' => 'Komentáre',
-    'comment_add' => 'Add Comment',
+    'comment_add' => 'Pridať komentár',
     'comment_placeholder' => 'Tu zadajte svoje pripomienky',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_count' => '{0} Bez komentárov|{1} 1 komentár|[2,3,4] :count komentáre|[5,*] :count komentárov',
     'comment_save' => 'Uložiť komentár',
-    'comment_saving' => 'Saving comment...',
-    'comment_deleting' => 'Deleting comment...',
-    'comment_new' => 'New Comment',
-    'comment_created' => 'commented :createDiff',
+    'comment_saving' => 'Ukladanie komentára...',
+    'comment_deleting' => 'Mazanie komentára...',
+    'comment_new' => 'Nový komentár',
+    'comment_created' => 'komentované :createDiff',
     'comment_updated' => 'Updated :updateDiff by :username',
-    'comment_deleted_success' => 'Comment deleted',
-    'comment_created_success' => 'Comment added',
-    'comment_updated_success' => 'Comment updated',
+    'comment_deleted_success' => 'Komentár odstránený',
+    'comment_created_success' => 'Komentár pridaný',
+    'comment_updated_success' => 'Komentár aktualizovaný',
     'comment_delete_confirm' => 'Ste si istý, že chcete odstrániť tento komentár?',
     'comment_in_reply_to' => 'Odpovedať na :commentId',
 
index e523110edea3a369f546aa8bba7ebf9961b76010..bb30243e8b13da41203b5731d4bc85e1bc3ceb15 100644 (file)
@@ -13,7 +13,7 @@ return [
     'email_already_confirmed' => 'Email bol už overený, skúste sa prihlásiť.',
     'email_confirmation_invalid' => 'Tento potvrdzujúci token nie je platný alebo už bol použitý, skúste sa prosím registrovať znova.',
     'email_confirmation_expired' => 'Potvrdzujúci token expiroval, bol odoslaný nový potvrdzujúci email.',
-    'email_confirmation_awaiting' => 'The email address for the account in use needs to be confirmed',
+    'email_confirmation_awaiting' => 'Potvrďte emailovú adresu pre užívateľský účet',
     'ldap_fail_anonymous' => 'LDAP access failed using anonymous bind',
     'ldap_fail_authed' => 'LDAP access failed using given dn & password details',
     'ldap_extension_not_installed' => 'LDAP PHP extension not installed',
@@ -83,7 +83,7 @@ return [
     '404_page_not_found' => 'Stránka nenájdená',
     'sorry_page_not_found' => 'Prepáčte, stránka ktorú hľadáte nebola nájdená.',
     'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.',
-    'image_not_found' => 'Image Not Found',
+    'image_not_found' => 'Obrázok nebol nájdený',
     'image_not_found_subtitle' => 'Sorry, The image file you were looking for could not be found.',
     'image_not_found_details' => 'If you expected this image to exist it might have been deleted.',
     'return_home' => 'Vrátiť sa domov',
index 7ad8fd118ad9354550a3f69de339246c0fd45f80..9ec036802a8a9f77911f249163ba3129ee7bcadc 100644 (file)
@@ -12,15 +12,15 @@ return [
     'settings_save_success' => 'Nastavenia uložené',
 
     // App Settings
-    'app_customization' => 'Customization',
-    'app_features_security' => 'Features & Security',
+    'app_customization' => 'Prispôsobenia',
+    'app_features_security' => 'Funkcie a bezpečnosť',
     'app_name' => 'Názov aplikácia',
     'app_name_desc' => 'Tento názov sa zobrazuje v hlavičke a v emailoch.',
     'app_name_header' => 'Zobraziť názov aplikácie v hlavičke?',
-    'app_public_access' => 'Public Access',
+    'app_public_access' => 'Verejný prístup',
     'app_public_access_desc' => 'Enabling this option will allow visitors, that are not logged-in, to access content in your BookStack instance.',
     'app_public_access_desc_guest' => 'Access for public visitors can be controlled through the "Guest" user.',
-    'app_public_access_toggle' => 'Allow public access',
+    'app_public_access_toggle' => 'Povoliť verejný prístup',
     'app_public_viewing' => 'Povoliť verejné zobrazenie?',
     'app_secure_images' => 'Povoliť nahrávanie súborov so zvýšeným zabezpečením?',
     'app_secure_images_toggle' => 'Enable higher security image uploads',
@@ -34,20 +34,20 @@ return [
     'app_logo_desc' => 'Tento obrázok by mal mať 43px na výšku. <br>Veľké obrázky budú preškálované na menší rozmer.',
     'app_primary_color' => 'Primárna farba pre aplikáciu',
     'app_primary_color_desc' => 'Toto by mala byť hodnota v hex tvare. <br>Nechajte prázdne ak chcete použiť prednastavenú farbu.',
-    'app_homepage' => 'Application Homepage',
+    'app_homepage' => 'Domovská stránka aplikácie',
     'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
-    'app_homepage_select' => 'Select a page',
-    'app_footer_links' => 'Footer Links',
+    'app_homepage_select' => 'Vybrať stránku',
+    'app_footer_links' => 'Odkazy v pätičke',
     'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
     'app_footer_links_label' => 'Link Label',
     'app_footer_links_url' => 'Link URL',
     'app_footer_links_add' => 'Add Footer Link',
     'app_disable_comments' => 'Zakázať komentáre',
-    'app_disable_comments_toggle' => 'Disable comments',
+    'app_disable_comments_toggle' => 'Vypnúť komentáre',
     'app_disable_comments_desc' => 'Zakázať komentáre na všetkých stránkach aplikácie. Existujúce komentáre sa nezobrazujú.',
 
     // Color settings
-    'content_colors' => 'Content Colors',
+    'content_colors' => 'Farby obsahu',
     'content_colors_desc' => 'Sets colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
     'bookshelf_color' => 'Shelf Color',
     'book_color' => 'Book Color',
@@ -57,12 +57,12 @@ return [
 
     // Registration Settings
     'reg_settings' => 'Nastavenia registrácie',
-    'reg_enable' => 'Enable Registration',
-    'reg_enable_toggle' => 'Enable registration',
+    'reg_enable' => 'Povolenie registrácie',
+    'reg_enable_toggle' => 'Povoliť registrácie',
     'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.',
     'reg_default_role' => 'Prednastavená používateľská rola po registrácii',
     'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.',
-    'reg_email_confirmation' => 'Email Confirmation',
+    'reg_email_confirmation' => 'Potvrdenie e-mailom',
     'reg_email_confirmation_toggle' => 'Require email confirmation',
     'reg_confirm_email_desc' => 'Ak je použité obmedzenie domény, potom bude vyžadované overenie emailu a hodnota nižšie bude ignorovaná.',
     'reg_confirm_restrict_domain' => 'Obmedziť registráciu na doménu',
@@ -70,28 +70,28 @@ return [
     'reg_confirm_restrict_domain_placeholder' => 'Nie sú nastavené žiadne obmedzenia',
 
     // Maintenance settings
-    'maint' => 'Maintenance',
-    'maint_image_cleanup' => 'Cleanup Images',
+    'maint' => 'Údržba',
+    'maint_image_cleanup' => 'Prečistenie obrázkov',
     'maint_image_cleanup_desc' => "Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.",
     'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
-    'maint_image_cleanup_run' => 'Run Cleanup',
+    'maint_image_cleanup_run' => 'Spustiť prečistenie',
     'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?',
     'maint_image_cleanup_success' => ':count potentially unused images found and deleted!',
-    'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!',
-    'maint_send_test_email' => 'Send a Test Email',
+    'maint_image_cleanup_nothing_found' => 'Žiadne nepoužit obrázky neboli nájdené. Nič sa nezmazalo!',
+    'maint_send_test_email' => 'Odoslať testovací email',
     'maint_send_test_email_desc' => 'This sends a test email to your email address specified in your profile.',
-    'maint_send_test_email_run' => 'Send test email',
+    'maint_send_test_email_run' => 'Odoslať testovací email',
     'maint_send_test_email_success' => 'Email sent to :address',
-    'maint_send_test_email_mail_subject' => 'Test Email',
+    'maint_send_test_email_mail_subject' => 'Testovací email',
     'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!',
     'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.',
     'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.',
-    'maint_recycle_bin_open' => 'Open Recycle Bin',
+    'maint_recycle_bin_open' => 'Otvoriť kôš',
 
     // Recycle Bin
-    'recycle_bin' => 'Recycle Bin',
+    'recycle_bin' => 'Kôš',
     'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
-    'recycle_bin_deleted_item' => 'Deleted Item',
+    'recycle_bin_deleted_item' => 'Odstránené položky',
     'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'Deleted By',
     'recycle_bin_deleted_at' => 'Deletion Time',
@@ -116,10 +116,11 @@ return [
     'audit_event_filter_no_filter' => 'No Filter',
     'audit_deleted_item' => 'Deleted Item',
     'audit_deleted_item_name' => 'Name: :name',
-    'audit_table_user' => 'User',
-    'audit_table_event' => 'Event',
+    'audit_table_user' => 'Užívateľ',
+    'audit_table_event' => 'Udalosť',
     'audit_table_related' => 'Related Item or Detail',
-    'audit_table_date' => 'Activity Date',
+    'audit_table_ip' => 'IP adresa',
+    'audit_table_date' => 'Dátum aktivity',
     'audit_date_from' => 'Date Range From',
     'audit_date_to' => 'Date Range To',
 
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Manage page templates',
     'role_access_api' => 'Access system API',
     'role_manage_settings' => 'Spravovať nastavenia aplikácie',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Oprávnenia majetku',
     'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
     'role_asset_desc' => 'Tieto oprávnenia regulujú prednastavený prístup k zdroju v systéme. Oprávnenia pre knihy, kapitoly a stránky majú vyššiu prioritu.',
@@ -165,13 +167,13 @@ return [
     'user_profile' => 'Profil používateľa',
     'users_add_new' => 'Pridať nového používateľa',
     'users_search' => 'Hľadať medzi používateľmi',
-    'users_latest_activity' => 'Latest Activity',
-    'users_details' => 'User Details',
+    'users_latest_activity' => 'Nedávna aktivita',
+    'users_details' => 'Údaje o používateľovi',
     'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.',
     'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.',
     'users_role' => 'Používateľské roly',
     'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
-    'users_password' => 'User Password',
+    'users_password' => 'Heslo používateľa',
     'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
     'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
     'users_send_invite_option' => 'Send user invite email',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 60472fedc4754b5b9175ce638944dc3fdbfe00f3..4ad1aea00dc4f5ab65bc8be420932f20eda061af 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Dovoljenja knjižnih polic',
     'shelves_permissions_updated' => 'Posodobljena dovoljenja knjižnih polic',
     'shelves_permissions_active' => 'Aktivna dovoljenja knjižnih polic',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Kopiraj dovoljenja na knjige',
     'shelves_copy_permissions' => 'Dovoljenja kopiranja',
     'shelves_copy_permissions_explain' => 'To bo uveljavilo trenutne nastavitve dovoljenj na knjižni polici za vse knjige, ki jih vsebuje ta polica. Pred aktiviranjem zagotovite, da so shranjene vse spremembe dovoljenj te knjižne police.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Nova stran',
     'pages_editing_draft_notification' => 'Trenutno urejate osnutek, ki je bil nazadnje shranjen :timeDiff.',
     'pages_draft_edited_notification' => 'Ta stran je odtlej posodobljena. Priporočamo, da zavržete ta osnutek.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count uporabnikov je začelo urejati to stran',
         'start_b' => ':userName je začel urejati to stran',
index b361b0d74acef17f3cbae5edb9eb022923db62da..cadba7bce937bd7c041e33607ac5484330a38c27 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Uporabnik',
     'audit_table_event' => 'Dogodek',
     'audit_table_related' => 'Povezani predmet ali podrobnost',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Datum zadnje dejavnosti',
     'audit_date_from' => 'Časovno obdobje od',
     'audit_date_to' => 'Časovno obdobje do',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Uredi predloge',
     'role_access_api' => 'API za dostop do sistema',
     'role_manage_settings' => 'Nastavitve za upravljanje',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Sistemska dovoljenja',
     'roles_system_warning' => 'Zavedajte se, da lahko dostop do kateregakoli od zgornjih treh dovoljenj uporabniku omogoči, da spremeni lastne privilegije ali privilegije drugih v sistemu. Vloge s temi dovoljenji dodelite samo zaupanja vrednim uporabnikom.',
     'role_asset_desc' => 'Ta dovoljenja nadzorujejo privzeti dostop do sredstev v sistemu. Dovoljenja za knjige, poglavja in strani bodo razveljavila ta dovoljenja.',
@@ -255,6 +257,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 6bc603dd0c02796e198a929f48358a599cca66f0..80eb570c97dfe0cdae6959ab99dc424638880d25 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Bokhyllerättigheter',
     'shelves_permissions_updated' => 'Bokhyllerättigheterna har ändrats',
     'shelves_permissions_active' => 'Bokhyllerättigheterna är aktiva',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Kopiera rättigheter till böcker',
     'shelves_copy_permissions' => 'Kopiera rättigheter',
     'shelves_copy_permissions_explain' => 'Detta kommer kopiera hyllans rättigheter till alla böcker på den. Se till att du har sparat alla ändringar innan du går vidare.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Ny sida',
     'pages_editing_draft_notification' => 'Du redigerar just nu ett utkast som senast sparades :timeDiff.',
     'pages_draft_edited_notification' => 'Denna sida har uppdaterats sen dess. Vi rekommenderar att du förkastar dina ändringar.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count har börjat redigera den här sidan',
         'start_b' => ':userName har börjat redigera den här sidan',
index 1eb2ba9c91dd785aea5950af5654cf430ff3ad53..1aa51ee38d8df0019ad90fb4c3dadb6c9207e5f5 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Användare',
     'audit_table_event' => 'Händelse',
     'audit_table_related' => 'Relaterat objekt eller detalj',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Datum för senaste aktiviteten',
     'audit_date_from' => 'Datumintervall från',
     'audit_date_to' => 'Datumintervall till',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Hantera mallar',
     'role_access_api' => 'Åtkomst till systemets API',
     'role_manage_settings' => 'Hantera appinställningar',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Tillgång till innehåll',
     'roles_system_warning' => 'Var medveten om att åtkomst till någon av ovanstående tre behörigheter kan tillåta en användare att ändra sina egna rättigheter eller andras rättigheter i systemet. Tilldela endast roller med dessa behörigheter till betrodda användare.',
     'role_asset_desc' => 'Det här är standardinställningarna för allt innehåll i systemet. Eventuella anpassade rättigheter på böcker, kapitel och sidor skriver över dessa inställningar.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index e3340fe72356521d7d093237626475072cebaed5..9b0a15c5d1110934c23fd8141ef3b2c78acd2202 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Kitaplık İzinleri',
     'shelves_permissions_updated' => 'Kitaplık İzinleri Güncellendi',
     'shelves_permissions_active' => 'Kitaplık İzinleri Aktif',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'İzinleri Kitaplara Kopyala',
     'shelves_copy_permissions' => 'İzinleri Kopyala',
     'shelves_copy_permissions_explain' => 'Bu işlem sonucunda kitaplığınızın izinleri, içerdiği kitaplara da aynen uygulanır. Aktifleştirmeden önce bu kitaplığa ait izinleri kaydettiğinizden emin olun.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Yeni Sayfa',
     'pages_editing_draft_notification' => 'Şu anda en son :timeDiff tarihinde kaydedilmiş olan taslağı düzenliyorsunuz.',
     'pages_draft_edited_notification' => 'Bu sayfa o zamandan bu zamana güncellenmiş, bu nedenle bu taslağı yok saymanız önerilir.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count kullanıcı, bu sayfayı düzenlemeye başladı',
         'start_b' => ':userName, bu sayfayı düzenlemeye başladı',
index 6a5521b06794e6e50ad7af7bb268af437f1fd67f..aca4a062891bd01dc51074583f43af85a55ca27b 100755 (executable)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Kullanıcı',
     'audit_table_event' => 'Etkinlik',
     'audit_table_related' => 'İlgili Öğe veya Detay',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Aktivite Tarihi',
     'audit_date_from' => 'Tarih Aralığından',
     'audit_date_to' => 'Tarih Aralığına',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Sayfa şablonlarını yönet',
     'role_access_api' => 'Sistem programlama arayüzüne (API) eriş',
     'role_manage_settings' => 'Uygulama ayarlarını yönet',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Varlık Yetkileri',
     'roles_system_warning' => 'Yukarıdaki üç izinden herhangi birine erişimin, kullanıcının kendi ayrıcalıklarını veya sistemdeki diğerlerinin ayrıcalıklarını değiştirmesine izin verebileceğini unutmayın. Yalnızca bu izinlere sahip rolleri güvenilir kullanıcılara atayın.',
     'role_asset_desc' => 'Bu izinler, sistem içindeki varlıklara varsayılan erişim izinlerini ayarlar. Kitaplar, bölümler ve sayfalar üzerindeki izinler, buradaki izinleri geçersiz kılar.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index 88130b90fdf2be780cb5f1508a9beb2eccafb598..a2521b2a4bb894962da3c9ab0d67aae16620b403 100644 (file)
@@ -44,12 +44,12 @@ return [
     'bookshelf_delete_notification'    => 'Книжкову полицю успішно видалено',
 
     // Favourites
-    'favourite_add_notification' => '":name" has been added to your favourites',
-    'favourite_remove_notification' => '":name" has been removed from your favourites',
+    'favourite_add_notification' => '":ім\'я" було додане до ваших улюлених',
+    'favourite_remove_notification' => '":ім\'я" було видалено з ваших улюблених',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => 'Багатофакторний метод успішно налаштований',
+    'mfa_remove_method_notification' => 'Багатофакторний метод успішно видалений',
 
     // Other
     'commented_on'                => 'прокоментував',
index 83bf8db04122db422fdc2909416859f6e3bc9ba7..6f9b4dae810f0106e54ce69a1fb1a21f6a7e26e1 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Дозволи на книжкову полицю',
     'shelves_permissions_updated' => 'Дозволи на книжкову полицю оновлено',
     'shelves_permissions_active' => 'Діючі дозволи на книжкову полицю',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Копіювати дозволи на книги',
     'shelves_copy_permissions' => 'Копіювати дозволи',
     'shelves_copy_permissions_explain' => 'Це застосовує поточні налаштування дозволів цієї книжкової полиці до всіх книг, що містяться всередині. Перш ніж активувати, переконайтесь що будь-які зміни дозволів цієї книжкової полиці були збережені.',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Нова сторінка',
     'pages_editing_draft_notification' => 'Ви наразі редагуєте чернетку, що була збережена останньою :timeDiff.',
     'pages_draft_edited_notification' => 'З того часу ця сторінка була оновлена. Рекомендуємо відмовитися від цього проекту.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count користувачі(в) почали редагувати цю сторінку',
         'start_b' => ':userName розпочав редагування цієї сторінки',
index d6ff98d18ffb9a85f8b5edb261efc971002d0ea6..2c96d4a2b5119f98fe746eca8cd5645e51a0b5b9 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Користувач',
     'audit_table_event' => 'Подія',
     'audit_table_related' => 'Пов’язаний елемент',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Дата активності',
     'audit_date_from' => 'Діапазон дат від',
     'audit_date_to' => 'Діапазон дат до',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Управління шаблонами сторінок',
     'role_access_api' => 'Доступ до системного API',
     'role_manage_settings' => 'Керування налаштуваннями програми',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Дозволи',
     'roles_system_warning' => 'Майте на увазі, що доступ до будь-якого з вищезазначених трьох дозволів може дозволити користувачеві змінювати власні привілеї або привілеї інших в системі. Ролі з цими дозволами призначайте лише довіреним користувачам.',
     'role_asset_desc' => 'Ці дозволи контролюють стандартні доступи всередині системи. Права на книги, розділи та сторінки перевизначать ці дозволи.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index d5d5e76b3e1e875450696c1e5d5cd07192b3a447..255ce38aa10937d1e2414089bd7d41d58f209ff6 100644 (file)
@@ -44,12 +44,12 @@ return [
     'bookshelf_delete_notification'    => 'Giá sách đã được xóa thành công',
 
     // Favourites
-    'favourite_add_notification' => '":name" has been added to your favourites',
-    'favourite_remove_notification' => '":name" has been removed from your favourites',
+    'favourite_add_notification' => '":name" đã được thêm vào danh sách yêu thích của bạn',
+    'favourite_remove_notification' => '":name" đã được gỡ khỏi danh sách yêu thích của bạn',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => 'Cấu hình xác thực nhiều bước thành công',
+    'mfa_remove_method_notification' => 'Đã gỡ xác thực nhiều bước',
 
     // Other
     'commented_on'                => 'đã bình luận về',
index fbb99b8309b9cc2ac65768d94ca737ba628abf0a..e95d26ac6129bfda9c689c09b07d08e85ed1f5ad 100644 (file)
@@ -76,27 +76,27 @@ return [
     'user_invite_success' => 'Mật khẩu đã được thiết lập, bạn có quyền truy cập đến :appName!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Setup Multi-Factor Authentication',
-    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'mfa_setup_configured' => 'Already configured',
-    'mfa_setup_reconfigure' => 'Reconfigure',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
-    'mfa_setup_action' => 'Setup',
+    'mfa_setup' => 'Cài đặt xác thực nhiều bước',
+    'mfa_setup_desc' => 'Cài đặt xác thực nhiều bước như một lớp bảo mật khác cho tài khoản của bạn.',
+    'mfa_setup_configured' => 'Đã cài đặt',
+    'mfa_setup_reconfigure' => 'Cài đặt lại',
+    'mfa_setup_remove_confirmation' => 'Bạn có chắc muốn gỡ bỏ phương thức xác thực nhiều bước này?',
+    'mfa_setup_action' => 'Cài đặt',
     'mfa_backup_codes_usage_limit_warning' => '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.',
-    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_title' => 'Ứng dụng di động',
     'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_title' => 'Mã dự phòng',
     'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
-    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
-    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_confirm_and_enable' => 'Xác nhận và Mở',
+    'mfa_gen_backup_codes_title' => 'Cài đặt Mã dự phòng',
     'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
-    'mfa_gen_backup_codes_download' => 'Download Codes',
-    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
-    'mfa_gen_totp_title' => 'Mobile App Setup',
-    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
-    'mfa_gen_totp_verify_setup' => 'Verify Setup',
-    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_backup_codes_download' => 'Tải mã',
+    'mfa_gen_backup_codes_usage_warning' => 'Mỗi mã chỉ có thể sử dụng một lần',
+    'mfa_gen_totp_title' => 'Cài đặt ứng dụng di động',
+    'mfa_gen_totp_desc' => 'Để sử dụng xác thực nhiều bước, bạn cần một ứng dụng di động hỗ trợ TOTP ví dụ như Google Authenticator, Authy hoặc Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Quét mã QR dưới đây bằng ứng dụng xác thực mà bạn muốn để bắt đầu.',
+    'mfa_gen_totp_verify_setup' => 'Xác nhận cài đặt',
+    'mfa_gen_totp_verify_setup_desc' => 'Xác nhận rằng tất cả hoạt động bằng cách nhập vào một mã, được tạo ra bởi ứng dụng xác thực của bạn vào ô dưới đây:',
     'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
     'mfa_verify_access' => 'Verify Access',
     'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
@@ -104,9 +104,9 @@ return [
     'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
     'mfa_verify_use_totp' => 'Verify using a mobile app',
     'mfa_verify_use_backup_codes' => 'Verify using a backup code',
-    'mfa_verify_backup_code' => 'Backup Code',
-    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
-    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
-    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
-    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+    'mfa_verify_backup_code' => 'Mã dự phòng',
+    'mfa_verify_backup_code_desc' => 'Nhập một trong các mã dự phòng còn lại của bạn vào ô phía dưới:',
+    'mfa_verify_backup_code_enter_here' => 'Nhập mã xác thực của bạn tại đây',
+    'mfa_verify_totp_desc' => 'Nhập mã do ứng dụng di động của bạn tạo ra vào dưới đây:',
+    'mfa_setup_login_notification' => 'Đã cài đặt xác thực nhiều bước, bạn vui lòng đăng nhập lại sử dụng phương thức đã cài đặt.',
 ];
\ No newline at end of file
index dd721d99423b415106efe64d9a821c04ab4ec991..f118d34c3a9e6cb04bda0e0b421976b23d97362d 100644 (file)
@@ -39,7 +39,7 @@ return [
     'reset' => 'Thiết lập lại',
     'remove' => 'Xóa bỏ',
     'add' => 'Thêm',
-    'configure' => 'Configure',
+    'configure' => 'Cấu hình',
     'fullscreen' => 'Toàn màn hình',
     'favourite' => 'Yêu thích',
     'unfavourite' => 'Bỏ yêu thích',
index d86eb41300a23dbf2859fc59b75e5eab3a538e69..8162509d78cfd4701e978c9286b288dc3ec0f5b6 100644 (file)
@@ -22,13 +22,13 @@ return [
     'meta_created_name' => 'Được tạo :timeLength bởi :user',
     'meta_updated' => 'Được cập nhật :timeLength',
     'meta_updated_name' => 'Được cập nhật :timeLength bởi :user',
-    'meta_owned_name' => 'Owned by :user',
+    'meta_owned_name' => 'Được sở hữu bởi :user',
     'entity_select' => 'Chọn thực thể',
     'images' => 'Ảnh',
     'my_recent_drafts' => 'Bản nháp gần đây của tôi',
     'my_recently_viewed' => 'Xem gần đây',
     'my_most_viewed_favourites' => 'My Most Viewed Favourites',
-    'my_favourites' => 'My Favourites',
+    'my_favourites' => 'Danh sách yêu thích của tôi',
     'no_pages_viewed' => 'Bạn chưa xem bất cứ trang nào',
     'no_pages_recently_created' => 'Không có trang nào được tạo gần đây',
     'no_pages_recently_updated' => 'Không có trang nào được cập nhật gần đây',
@@ -36,14 +36,14 @@ return [
     'export_html' => 'Đang chứa tệp tin Web',
     'export_pdf' => 'Tệp PDF',
     'export_text' => 'Tệp văn bản thuần túy',
-    'export_md' => 'Markdown File',
+    'export_md' => '\bTệp Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Quyền',
     'permissions_intro' => 'Một khi được bật, các quyền này sẽ được ưu tiên trên hết tất cả các quyền hạn khác.',
     'permissions_enable' => 'Bật quyền hạn tùy chỉnh',
     'permissions_save' => 'Lưu quyền hạn',
-    'permissions_owner' => 'Owner',
+    'permissions_owner' => 'Chủ sở hữu',
 
     // Search
     'search_results' => 'Kết quả Tìm kiếm',
@@ -63,7 +63,7 @@ return [
     'search_permissions_set' => 'Phân quyền',
     'search_created_by_me' => 'Được tạo bởi tôi',
     'search_updated_by_me' => 'Được cập nhật bởi tôi',
-    'search_owned_by_me' => 'Owned by me',
+    'search_owned_by_me' => 'Của tôi',
     'search_date_options' => 'Tùy chọn ngày',
     'search_updated_before' => 'Đã được cập nhật trước đó',
     'search_updated_after' => 'Đã được cập nhật sau',
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => 'Các quyền đối với kệ sách',
     'shelves_permissions_updated' => 'Các quyền với kệ sách đã được cập nhật',
     'shelves_permissions_active' => 'Đang bật các quyền hạn từ Kệ sách',
+    'shelves_permissions_cascade_warning' => 'Các quyền trên giá sách sẽ không được tự động gán cho các sách trên đó. Vì một quyển sách có thể tồn tại trên nhiều giá sách. Các quyền có thể được sao chép xuống các quyển sách sử dụng tuỳ chọn dưới đây.',
     'shelves_copy_permissions_to_books' => 'Sao chép các quyền cho sách',
     'shelves_copy_permissions' => 'Sao chép các quyền',
     'shelves_copy_permissions_explain' => 'Điều này sẽ áp dụng các cài đặt quyền của giá sách hiện tại với tất cả các cuốn sách bên trong. Trước khi kích hoạt, đảm bảo bất cứ thay đổi liên quan đến quyền của giá sách này đã được lưu.',
@@ -152,7 +153,7 @@ return [
     'chapters_create' => 'Tạo Chương mới',
     'chapters_delete' => 'Xóa Chương',
     'chapters_delete_named' => 'Xóa Chương :chapterName',
-    'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
+    'chapters_delete_explain' => 'Hành động này sẽ xoá chương \':chapterName\'. Tất cả các trang trong chương này cũng sẽ bị xoá.',
     'chapters_delete_confirm' => 'Bạn có chắc chắn muốn xóa chương này?',
     'chapters_edit' => 'Sửa Chương',
     'chapters_edit_named' => 'Sửa chương :chapterName',
@@ -214,7 +215,7 @@ return [
     'pages_revisions' => 'Phiên bản Trang',
     'pages_revisions_named' => 'Phiên bản Trang cho :pageName',
     'pages_revision_named' => 'Phiên bản Trang cho :pageName',
-    'pages_revision_restored_from' => 'Restored from #:id; :summary',
+    'pages_revision_restored_from' => 'Khôi phục từ #:id; :summary',
     'pages_revisions_created_by' => 'Tạo bởi',
     'pages_revisions_date' => 'Ngày của Phiên bản',
     'pages_revisions_number' => '#',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => 'Trang mới',
     'pages_editing_draft_notification' => 'Bạn hiện đang chỉnh sửa một bản nháp được lưu cách đây :timeDiff.',
     'pages_draft_edited_notification' => 'Trang này đã được cập nhật từ lúc đó. Bạn nên loại bỏ bản nháp này.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count người dùng đang bắt đầu chỉnh sửa trang này',
         'start_b' => ':userName đang bắt đầu chỉnh sửa trang này',
index 8db01539101c34b3b290f8f9c027b90d7b7e41ae..7dbed9018bba197ddff5e32121b1c1daf752523a 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => 'Người dùng',
     'audit_table_event' => 'Sự kiện',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Ngày hoạt động',
     'audit_date_from' => 'Ngày từ khoảng',
     'audit_date_to' => 'Ngày đến khoảng',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Quản lý các mẫu trang',
     'role_access_api' => 'Truy cập đến API hệ thống',
     'role_manage_settings' => 'Quản lý cài đặt của ứng dụng',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Quyền tài sản (asset)',
     'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
     'role_asset_desc' => 'Các quyền này điều khiển truy cập mặc định tới tài sản (asset) nằm trong hệ thống. Quyền tại Sách, Chường và Trang se ghi đè các quyền này.',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index dcbf98ae430b6f33ba73cfe82a334a08275cb534..65e7c3583b2fb7ac1ce40c36421014e77cd561c2 100644 (file)
@@ -48,8 +48,8 @@ return [
     'favourite_remove_notification' => '":name" 已从你的收藏中删除',
 
     // MFA
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_setup_method_notification' => '多重身份认证设置成功',
+    'mfa_remove_method_notification' => '多重身份认证已成功移除',
 
     // Other
     'commented_on'                => '评论',
index e9b130a0f8b316d92d12e431f47d7286e6526574..a31ee20e75b53c9f214ecc7a5ebe3c48961b5b33 100644 (file)
@@ -26,7 +26,7 @@ return [
     'remember_me' => '记住我',
     'ldap_email_hint' => '请输入用于此帐户的电子邮件。',
     'create_account' => '创建账户',
-    'already_have_account' => '您已经有账号?',
+    'already_have_account' => '已经有账号了?',
     'dont_have_account' => '您还没有账号吗?',
     'social_login' => 'SNS登录',
     'social_registration' => '使用社交网站账号注册',
@@ -76,37 +76,37 @@ return [
     'user_invite_success' => '已设置密码,您现在可以访问 :appName!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Setup Multi-Factor Authentication',
-    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'mfa_setup_configured' => 'Already configured',
-    'mfa_setup_reconfigure' => 'Reconfigure',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
-    'mfa_setup_action' => 'Setup',
-    'mfa_backup_codes_usage_limit_warning' => '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.',
-    'mfa_option_totp_title' => 'Mobile App',
-    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_option_backup_codes_title' => 'Backup Codes',
-    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
-    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
-    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
-    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
-    'mfa_gen_backup_codes_download' => 'Download Codes',
-    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
-    'mfa_gen_totp_title' => 'Mobile App Setup',
-    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
-    'mfa_gen_totp_verify_setup' => 'Verify Setup',
-    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
-    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
-    'mfa_verify_access' => 'Verify Access',
-    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
-    'mfa_verify_no_methods' => 'No Methods Configured',
-    'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
-    'mfa_verify_use_totp' => 'Verify using a mobile app',
-    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
-    'mfa_verify_backup_code' => 'Backup Code',
-    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
-    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
-    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
-    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+    'mfa_setup' => '设置多重身份认证',
+    'mfa_setup_desc' => '设置多重身份认证能增加您账户的安全性。',
+    'mfa_setup_configured' => '已经设置过了',
+    'mfa_setup_reconfigure' => '重新配置',
+    'mfa_setup_remove_confirmation' => '您确定想要移除多重身份认证吗?',
+    'mfa_setup_action' => '设置',
+    'mfa_backup_codes_usage_limit_warning' => '您剩余的备用认证码少于 5 个,请在用完认证码之前生成并保存新的认证码,以防止您的帐户被锁定。',
+    'mfa_option_totp_title' => '移动设备 App',
+    'mfa_option_totp_desc' => '要使用多重身份认证功能,您需要一个支持 TOTP(基于时间的一次性密码算法) 的移动设备 App,如谷歌身份验证器(Google Authenticator)、Authy 或微软身份验证器(Microsoft Authenticator)。',
+    'mfa_option_backup_codes_title' => '备用认证码',
+    'mfa_option_backup_codes_desc' => '请安全地保存这些一次性使用的备用认证码,您可以输入这些认证码来验证您的身份。',
+    'mfa_gen_confirm_and_enable' => '确认并启用',
+    'mfa_gen_backup_codes_title' => '备用认证码设置',
+    'mfa_gen_backup_codes_desc' => '将下面的认证码存放在一个安全的地方。访问系统时,您可以使用其中的一个验证码进行二次认证。',
+    'mfa_gen_backup_codes_download' => '下载认证码',
+    'mfa_gen_backup_codes_usage_warning' => '每个认证码只能使用一次',
+    'mfa_gen_totp_title' => '移动设备 App',
+    'mfa_gen_totp_desc' => '要使用多重身份认证功能,您需要一个支持 TOTP(基于时间的一次性密码算法) 的移动设备 App,如谷歌身份验证器(Google Authenticator)、Authy 或微软身份验证器(Microsoft Authenticator)。',
+    'mfa_gen_totp_scan' => '要开始操作,请使用你的身份验证 App 扫描下面的二维码。',
+    'mfa_gen_totp_verify_setup' => '验证设置',
+    'mfa_gen_totp_verify_setup_desc' => '请在下面的框中输入您在身份验证 App 中生成的认证码来验证一切是否正常:',
+    'mfa_gen_totp_provide_code_here' => '在此输入您的 App 生成的认证码',
+    'mfa_verify_access' => '认证访问',
+    'mfa_verify_access_desc' => '您的账户要求您在访问前通过额外的验证确认您的身份。使用您设置的认证方法认证以继续。',
+    'mfa_verify_no_methods' => '没有设置认证方法',
+    'mfa_verify_no_methods_desc' => '您的账户没有设置多重身份认证。您需要至少设置一种才能访问。',
+    'mfa_verify_use_totp' => '使用移动设备 App 进行认证',
+    'mfa_verify_use_backup_codes' => '使用备用认证码进行认证',
+    'mfa_verify_backup_code' => '备用认证码',
+    'mfa_verify_backup_code_desc' => '在下面输入你的其中一个备用认证码:',
+    'mfa_verify_backup_code_enter_here' => '在这里输入备用认证码',
+    'mfa_verify_totp_desc' => '在下面输入您的移动 App 生成的认证码:',
+    'mfa_setup_login_notification' => '多重身份认证已设置,请使用新配置的方法重新登录。',
 ];
\ No newline at end of file
index 2a8ab9562069ac0fbee2017da3e6eff8cbef1d37..6c2fa668bed9aab35c9064a9cce303c3c4325932 100644 (file)
@@ -39,7 +39,7 @@ return [
     'reset' => '重置',
     'remove' => '删除',
     'add' => '添加',
-    'configure' => 'Configure',
+    'configure' => '配置',
     'fullscreen' => '全屏',
     'favourite' => '收藏',
     'unfavourite' => '取消收藏',
index f7cfb469458db3dae31de870cb6cdf7958b17469..f3414179bbaba9be119aac2d445f2a55a23d16e2 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => '书架权限',
     'shelves_permissions_updated' => '书架权限已更新',
     'shelves_permissions_active' => '书架权限激活',
+    'shelves_permissions_cascade_warning' => '书架上的权限不会自动应用到书架里的书。这是因为书可以在多个书架上存在。使用下面的选项可以将权限复制到书架里的书上。',
     'shelves_copy_permissions_to_books' => '将权限复制到图书',
     'shelves_copy_permissions' => '复制权限',
     'shelves_copy_permissions_explain' => '这会将此书架的当前权限设置应用于其中包含的所有图书。 在激活之前,请确保已保存对此书架权限的任何更改。',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => '新页面',
     'pages_editing_draft_notification' => '您正在编辑在 :timeDiff 内保存的草稿.',
     'pages_draft_edited_notification' => '此后,此页面已经被更新,建议您放弃此草稿。',
+    'pages_draft_page_changed_since_creation' => '这个页面在您的草稿创建后被其他用户更新了,您目前的草稿不包含新的内容。建议您放弃此草稿,或是注意不要覆盖新的页面更改。',
     'pages_draft_edit_active' => [
         'start_a' => ':count位用户正在编辑此页面',
         'start_b' => '用户“:userName”已经开始编辑此页面',
index 815d91f791580fb53700ee5d5e89c3bd784bfcf9..ebed8029e4d2ab3312dfa3bb196269e565e52a8d 100755 (executable)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => '用户',
     'audit_table_event' => '事件',
     'audit_table_related' => '相关项目或详细信息',
+    'audit_table_ip' => 'IP地址',
     'audit_table_date' => '活动日期',
     'audit_date_from' => '日期范围从',
     'audit_date_to' => '日期范围至',
@@ -138,7 +139,7 @@ return [
     'role_details' => '角色详细信息',
     'role_name' => '角色名',
     'role_desc' => '角色简述',
-    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_mfa_enforced' => '需要多重身份认证',
     'role_external_auth_id' => '外部身份认证ID',
     'role_system' => '系统权限',
     'role_manage_users' => '管理用户',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => '管理页面模板',
     'role_access_api' => '访问系统 API',
     'role_manage_settings' => '管理App设置',
+    'role_export_content' => '导出内容',
     'role_asset' => '资源许可',
     'roles_system_warning' => '请注意,具有上述三个权限中的任何一个都可以允许用户更改自己的特权或系统中其他人的特权。 只将具有这些权限的角色分配给受信任的用户。',
     'role_asset_desc' => '对系统内资源的默认访问许可将由这些权限控制。单独设置在书籍,章节和页面上的权限将覆盖这里的权限设定。',
@@ -205,10 +207,10 @@ return [
     'users_api_tokens_create' => '创建令牌',
     'users_api_tokens_expires' => '过期',
     'users_api_tokens_docs' => 'API文档',
-    'users_mfa' => 'Multi-Factor Authentication',
-    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'users_mfa_x_methods' => ':count method configured|:count methods configured',
-    'users_mfa_configure' => 'Configure Methods',
+    'users_mfa' => '多重身份认证',
+    'users_mfa_desc' => '设置多重身份认证能增加您账户的安全性。',
+    'users_mfa_x_methods' => ':count 个措施已配置|:count 个措施已配置',
+    'users_mfa_configure' => '配置安全措施',
 
     // API Tokens
     'user_api_token_create' => '创建 API 令牌',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => '挪威语 (Bokmål)',
index c8dfda90b7f8db58d6c661fafdf1fde0036ebed7..3398a1142c0a2ea63eb08c174653f2a3b3d30bb8 100644 (file)
@@ -15,7 +15,7 @@ return [
     'alpha_dash'           => ':attribute 只能包含字母、数字和短横线。',
     'alpha_num'            => ':attribute 只能包含字母和数字。',
     'array'                => ':attribute 必须是一个数组。',
-    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'backup_codes'         => '您输入的认证码无效或已被使用。',
     'before'               => ':attribute 必须是在 :date 前的日期。',
     'between'              => [
         'numeric' => ':attribute 必须在:min到:max之间。',
@@ -99,7 +99,7 @@ return [
     ],
     'string'               => ':attribute 必须是字符串。',
     'timezone'             => ':attribute 必须是有效的区域。',
-    'totp'                 => 'The provided code is not valid or has expired.',
+    'totp'                 => '您输入的认证码无效或已过期。',
     'unique'               => ':attribute 已经被使用。',
     'url'                  => ':attribute 格式无效。',
     'uploaded'             => '无法上传文件。 服务器可能不接受此大小的文件。',
index bf20e5b5311fff5285650716714fb5727ef1605a..e35a0795a754a0ae33d2024f493af2f6e2ec40b5 100644 (file)
@@ -99,6 +99,7 @@ return [
     'shelves_permissions' => '書架權限',
     'shelves_permissions_updated' => '書架權限已更新',
     'shelves_permissions_active' => '書架權限已啟用',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => '將權限複製到書本',
     'shelves_copy_permissions' => '複製權限',
     'shelves_copy_permissions_explain' => '這會將此書架目前的權限設定套用到所有包含的書本上。在啟用前,請確認您已儲存任何對此書架權限的變更。',
@@ -233,6 +234,7 @@ return [
     'pages_initial_name' => '新頁面',
     'pages_editing_draft_notification' => '您正在編輯最後儲存為 :timeDiff 的草稿。',
     'pages_draft_edited_notification' => '此頁面已經被更新過。建議您放棄此草稿。',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count 位使用者已經開始編輯此頁面',
         'start_b' => '使用者 :userName 已經開始編輯此頁面',
index d1d3dbe6ea905f2bcacfcbd1e4d1570159b218eb..aa0a8799341a2db0dfeb319a0e1fc2c21f9f03ec 100644 (file)
@@ -119,6 +119,7 @@ return [
     'audit_table_user' => '使用者',
     'audit_table_event' => '活動',
     'audit_table_related' => '相關的項目或詳細資訊',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => '活動日期',
     'audit_date_from' => '日期範圍,從',
     'audit_date_to' => '日期範圍,到',
@@ -148,6 +149,7 @@ return [
     'role_manage_page_templates' => '管理頁面範本',
     'role_access_api' => '存取系統 API',
     'role_manage_settings' => '管理應用程式設定',
+    'role_export_content' => 'Export content',
     'role_asset' => '資源權限',
     'roles_system_warning' => '請注意,有上述三項權限中的任一項的使用者都可以更改自己或系統中其他人的權限。有這些權限的角色只應分配給受信任的使用者。',
     'role_asset_desc' => '對系統內資源的預設權限將由這裡的權限控制。若有單獨設定在書本、章節和頁面上的權限,將會覆寫這裡的權限設定。',
@@ -254,6 +256,7 @@ return [
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
index e26948301f79b5d0e9c5c120c3448d50bb413ae7..362bab7d39195672c1511bc73832e0e6a4eb1ae0 100644 (file)
@@ -145,6 +145,7 @@ body.flexbox {
 .flex {
   min-height: 0;
   flex: 1;
+  max-width: 100%;
   &.fit-content {
     flex-basis: auto;
     flex-grow: 0;
index ba0f85fc74f6b02f0a86c627940653599da6cc3c..ca28a7d9013a9b524b598ade8e851614160fad94 100644 (file)
@@ -44,7 +44,7 @@
 </p>
 <table class="table">
     <tr>
-        <th>Parameter</th>
+        <th width="110">Parameter</th>
         <th>Details</th>
         <th width="30%">Examples</th>
     </tr>
diff --git a/resources/views/auth/parts/login-form-oidc.blade.php b/resources/views/auth/parts/login-form-oidc.blade.php
new file mode 100644 (file)
index 0000000..e5e1b70
--- /dev/null
@@ -0,0 +1,11 @@
+<form action="{{ url('/oidc/login') }}" method="POST" id="login-form" class="mt-l">
+    {!! csrf_field() !!}
+
+    <div>
+        <button id="oidc-login" class="button outline svg">
+            @icon('oidc')
+            <span>{{ trans('auth.log_in_with', ['socialDriver' => config('oidc.name')]) }}</span>
+        </button>
+    </div>
+
+</form>
index fa5ba0cc456333776de144372098e68fc7f3fe65..6f88bd43f7a9cd77d8d1aec9a85c665fadb27e7c 100644 (file)
@@ -1,5 +1,7 @@
+@inject('headContent', 'BookStack\Theming\CustomHtmlHeadContentProvider')
+
 @if(setting('app-custom-head') && \Route::currentRouteName() !== 'settings')
 <!-- Custom user content -->
-{!! setting('app-custom-head') !!}
+{!! $headContent->forWeb() !!}
 <!-- End custom user content -->
 @endif
\ No newline at end of file
index f428e9fe9b0ecf6d291aba85b9f34d07569b5e19..2452d6b8e646ceff733678a93b34be14ed38f9a8 100644 (file)
@@ -1,5 +1,7 @@
+@inject('headContent', 'BookStack\Theming\CustomHtmlHeadContentProvider')
+
 @if(setting('app-custom-head'))
 <!-- Custom user content -->
-{!! \BookStack\Util\HtmlContentFilter::removeScripts(setting('app-custom-head')) !!}
+{!! $headContent->forExport() !!}
 <!-- End custom user content -->
 @endif
\ No newline at end of file
diff --git a/resources/views/errors/debug.blade.php b/resources/views/errors/debug.blade.php
new file mode 100644 (file)
index 0000000..e715543
--- /dev/null
@@ -0,0 +1,146 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport"
+          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
+    <title>Error: {{ $error }}</title>
+
+    <style>
+        html, body {
+            background-color: #F2F2F2;
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
+        }
+
+        html {
+            padding: 0;
+        }
+
+        body {
+            margin: 0;
+            border-top: 6px solid #206ea7;
+        }
+
+        h1 {
+            margin-top: 0;
+        }
+
+        h2 {
+            color: #666;
+            font-size: 1rem;
+            margin-bottom: 0;
+        }
+
+        .container {
+            max-width: 800px;
+            margin: 1rem auto;
+        }
+
+        .panel {
+            background-color: #FFF;
+            border-radius: 3px;
+            box-shadow: 0 1px 6px -1px rgba(0, 0, 0, 0.1);
+            padding: 1rem 2rem;
+            margin: 2rem 1rem;
+        }
+
+        .panel-title {
+            font-weight: bold;
+            font-size: 1rem;
+            color: #FFF;
+            margin-top: 0;
+            margin-bottom: 0;
+            background-color: #206ea7;
+            padding: 0.25rem .5rem;
+            display: inline-block;
+            border-radius: 3px;
+        }
+
+        pre {
+            overflow-x: scroll;
+            background-color: #EEE;
+            border: 1px solid #DDD;
+            padding: .25rem;
+            border-radius: 3px;
+        }
+
+        a {
+            color: #206ea7;
+            text-decoration: none;
+        }
+
+        a:hover, a:focus {
+            text-decoration: underline;
+            color: #105282;
+        }
+
+        ul {
+            margin-left: 0;
+            padding-left: 1rem;
+        }
+
+        li {
+            margin-bottom: .4rem;
+        }
+
+        .notice {
+            margin-top: 2rem;
+            padding: 0 2rem;
+            font-weight: bold;
+            color: #666;
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+
+        <p class="notice">
+            WARNING: Application is in debug mode. This mode has the potential to leak confidential
+            information and therefore should not be used in production or publicly
+            accessible environments.
+        </p>
+
+        <div class="panel">
+            <h4 class="panel-title">Error</h4>
+            <h2>{{ $errorClass }}</h2>
+            <h1>{{ $error }}</h1>
+        </div>
+
+        <div class="panel">
+            <h4 class="panel-title">Help Resources</h4>
+            <ul>
+                <li>
+                    <a href="https://www.bookstackapp.com/docs/admin/debugging/" target="_blank">Review BookStack debugging documentation &raquo;</a>
+                </li>
+                <li>
+                    <a href="https://github.com/BookStackApp/BookStack/releases" target="_blank">Ensure your instance is up-to-date &raquo;</a>
+                </li>
+                <li>
+                    <a href="https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue+{{ urlencode($error) }}" target="_blank">Search for the issue on GitHub &raquo;</a>
+                </li>
+                <li>
+                    <a href="https://discord.gg/ztkBqR2" target="_blank">Ask for help via Discord &raquo;</a>
+                </li>
+                <li>
+                    <a href="https://duckduckgo.com/?q={{urlencode("BookStack {$error}")}}" target="_blank">Search the error message &raquo;</a>
+                </li>
+            </ul>
+        </div>
+
+        <div class="panel">
+            <h4 class="panel-title">Environment</h4>
+            <ul>
+                @foreach($environment as $label => $text)
+                <li><strong>{{ $label }}:</strong> {{ $text }}</li>
+                @endforeach
+            </ul>
+        </div>
+
+        <div class="panel">
+            <h4 class="panel-title">Stack Trace</h4>
+            <pre>{{ $trace }}</pre>
+        </div>
+
+    </div>
+</body>
+</html>
\ No newline at end of file
index 6a45b4209ac2b2b3425620f51d4eb6007d20dfe4..1f28e354ce8e119f03a0281fde89563eaee371d5 100644 (file)
@@ -15,7 +15,6 @@
     <meta property="og:title" content="{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}">
     <meta property="og:url" content="{{ url()->current() }}">
     @stack('social-meta')
-    
 
     <!-- Styles and Fonts -->
     <link rel="stylesheet" href="{{ versioned_asset('dist/styles.css') }}">
@@ -51,7 +50,7 @@
     </div>
 
     @yield('bottom')
-    <script src="{{ versioned_asset('dist/app.js') }}"></script>
+    <script src="{{ versioned_asset('dist/app.js') }}" nonce="{{ $cspNonce }}"></script>
     @yield('scripts')
 
 </body>
index f9a7c46ace38b8f83fe1d04ba9fcf90150e29e6e..e99861a8de30c597a38ad454fc8561e12616d430 100644 (file)
@@ -12,6 +12,9 @@
                 <div class="block inline">
                     {!! $svg !!}
                 </div>
+                <div class="code-base small text-muted px-s py-xs my-xs" style="overflow-x: scroll; white-space: nowrap;">
+                    {{ $url }}
+                </div>
             </div>
 
             <h2 class="list-heading">{{ trans('auth.mfa_gen_totp_verify_setup') }}</h2>
index db518b0d45af321621ee91bec4f86d46237630cc..6d2c3d484d43574ede66250a58347cef8c3f2692 100644 (file)
@@ -1,7 +1,7 @@
 @extends('layouts.base')
 
 @section('head')
-    <script src="{{ url('/libs/tinymce/tinymce.min.js?ver=4.9.4') }}"></script>
+    <script src="{{ url('/libs/tinymce/tinymce.min.js?ver=4.9.4') }}" nonce="{{ $cspNonce }}"></script>
 @stop
 
 @section('body-class', 'flexbox')
index 7a66cc3f37bd4b79bde88044034f864e5cdde3a7..84f180f3b41fbc43c07933aeab7a4b6910caa5d4 100644 (file)
@@ -62,6 +62,7 @@
                     <a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'key']) }}">{{ trans('settings.audit_table_event') }}</a>
                 </th>
                 <th>{{ trans('settings.audit_table_related') }}</th>
+                <th>{{ trans('settings.audit_table_ip') }}</th>
                 <th>
                     <a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'created_at']) }}">{{ trans('settings.audit_table_date') }}</a></th>
             </tr>
@@ -88,6 +89,7 @@
                             <div class="px-m">{{ $activity->detail }}</div>
                         @endif
                     </td>
+                    <td>{{ $activity->ip }}</td>
                     <td>{{ $activity->created_at }}</td>
                 </tr>
             @endforeach
index c87d84c5ef24eeee8252003721317c6b142b51b5..5fe5f3685ce505d30d30b7fdd2ff81fdef3d492e 100644 (file)
                                 'label' => trans('settings.reg_enable_toggle')
                             ])
 
-                            @if(in_array(config('auth.method'), ['ldap', 'saml2']))
+                            @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc']))
                                 <div class="text-warn text-small mb-l">{{ trans('settings.reg_enable_external_warning') }}</div>
                             @endif
 
diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php
new file mode 100644 (file)
index 0000000..e69de29
index 2f94398b5449846b89c57b3b7686bbe3369686ee..9cea9e1fb690eb5b293e5b3da84e64cd55bdbd56 100644 (file)
@@ -22,7 +22,7 @@
                     @include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced') ])
                 </div>
 
-                @if(config('auth.method') === 'ldap' || config('auth.method') === 'saml2')
+                @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc']))
                     <div class="form-group">
                         <label for="name">{{ trans('settings.role_external_auth_id') }}</label>
                         @include('form.text', ['name' => 'external_auth_id'])
index 7105e2ff14458578f978249728879bb1667b6c8d..2a5002c3b766c34cc97494336130268c448b499d 100644 (file)
@@ -25,7 +25,7 @@
     </div>
 </div>
 
-@if(($authMethod === 'ldap' || $authMethod === 'saml2') && userCan('users-manage'))
+@if(in_array($authMethod, ['ldap', 'saml2', 'oidc']) && userCan('users-manage'))
     <div class="grid half gap-xl v-center">
         <div>
             <label class="setting-list-label">{{ trans('settings.users_external_auth_id') }}</label>
index a6ed0c8f110702c007db4feceb8075a5f1ab6971..49521bb89190ceddf4205ba495406f5815551b4d 100644 (file)
@@ -5,9 +5,14 @@
  * Routes have a uri prefix of /api/.
  * Controllers are all within app/Http/Controllers/Api.
  */
-Route::get('docs', 'ApiDocsController@display');
 Route::get('docs.json', 'ApiDocsController@json');
 
+Route::get('attachments', 'AttachmentApiController@list');
+Route::post('attachments', 'AttachmentApiController@create');
+Route::get('attachments/{id}', 'AttachmentApiController@read');
+Route::put('attachments/{id}', 'AttachmentApiController@update');
+Route::delete('attachments/{id}', 'AttachmentApiController@delete');
+
 Route::get('books', 'BookApiController@list');
 Route::post('books', 'BookApiController@create');
 Route::get('books/{id}', 'BookApiController@read');
index a823b73c88d13ee3c3ac3427024d67ce191098a2..a5f35fb8af4a60f8f9e9540c2abe8b13a60f1a5d 100644 (file)
@@ -10,6 +10,9 @@ Route::group(['middleware' => 'auth'], function () {
     Route::get('/uploads/images/{path}', 'Images\ImageController@showImage')
         ->where('path', '.*$');
 
+    // API docs routes
+    Route::get('/api/docs', 'Api\ApiDocsController@display');
+
     Route::get('/pages/recently-updated', 'PageController@showRecentlyUpdated');
 
     // Shelves
@@ -262,7 +265,12 @@ Route::post('/saml2/login', 'Auth\Saml2Controller@login');
 Route::get('/saml2/logout', 'Auth\Saml2Controller@logout');
 Route::get('/saml2/metadata', 'Auth\Saml2Controller@metadata');
 Route::get('/saml2/sls', 'Auth\Saml2Controller@sls');
-Route::post('/saml2/acs', 'Auth\Saml2Controller@acs');
+Route::post('/saml2/acs', 'Auth\Saml2Controller@startAcs');
+Route::get('/saml2/acs', 'Auth\Saml2Controller@processAcs');
+
+// OIDC routes
+Route::post('/oidc/login', 'Auth\OidcController@login');
+Route::get('/oidc/callback', 'Auth\OidcController@callback');
 
 // User invitation routes
 Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword');
diff --git a/tests/ActivityTrackingTest.php b/tests/ActivityTrackingTest.php
deleted file mode 100644 (file)
index 494a1f5..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php
-
-namespace Tests;
-
-use BookStack\Entities\Models\Book;
-
-class ActivityTrackingTest extends BrowserKitTest
-{
-    public function test_recently_viewed_books()
-    {
-        $books = Book::all()->take(10);
-
-        $this->asAdmin()->visit('/books')
-            ->dontSeeInElement('#recents', $books[0]->name)
-            ->dontSeeInElement('#recents', $books[1]->name)
-            ->visit($books[0]->getUrl())
-            ->visit($books[1]->getUrl())
-            ->visit('/books')
-            ->seeInElement('#recents', $books[0]->name)
-            ->seeInElement('#recents', $books[1]->name);
-    }
-
-    public function test_popular_books()
-    {
-        $books = Book::all()->take(10);
-
-        $this->asAdmin()->visit('/books')
-            ->dontSeeInElement('#popular', $books[0]->name)
-            ->dontSeeInElement('#popular', $books[1]->name)
-            ->visit($books[0]->getUrl())
-            ->visit($books[1]->getUrl())
-            ->visit($books[0]->getUrl())
-            ->visit('/books')
-            ->seeInNthElement('#popular .book', 0, $books[0]->name)
-            ->seeInNthElement('#popular .book', 1, $books[1]->name);
-    }
-}
index 90d107eb34aa7b170d73fe2999014c7789248176..062adce5376821a8b1914b734039c107edf6fa2b 100644 (file)
@@ -2,7 +2,6 @@
 
 namespace Tests\Api;
 
-use BookStack\Auth\User;
 use Tests\TestCase;
 
 class ApiDocsTest extends TestCase
@@ -11,16 +10,6 @@ class ApiDocsTest extends TestCase
 
     protected $endpoint = '/api/docs';
 
-    public function test_docs_page_not_visible_to_normal_viewers()
-    {
-        $viewer = $this->getViewer();
-        $resp = $this->actingAs($viewer)->get($this->endpoint);
-        $resp->assertStatus(403);
-
-        $resp = $this->actingAsApiEditor()->get($this->endpoint);
-        $resp->assertStatus(200);
-    }
-
     public function test_docs_page_returns_view_with_docs_content()
     {
         $resp = $this->actingAsApiEditor()->get($this->endpoint);
@@ -42,19 +31,4 @@ class ApiDocsTest extends TestCase
             ]],
         ]);
     }
-
-    public function test_docs_page_visible_by_public_user_if_given_permission()
-    {
-        $this->setSettings(['app-public' => true]);
-        $guest = User::getDefault();
-
-        $this->startSession();
-        $resp = $this->get('/api/docs');
-        $resp->assertStatus(403);
-
-        $this->giveUserPermissions($guest, ['access-api']);
-
-        $resp = $this->get('/api/docs');
-        $resp->assertStatus(200);
-    }
 }
diff --git a/tests/Api/AttachmentsApiTest.php b/tests/Api/AttachmentsApiTest.php
new file mode 100644 (file)
index 0000000..40624dc
--- /dev/null
@@ -0,0 +1,330 @@
+<?php
+
+namespace Tests\Api;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Uploads\Attachment;
+use Illuminate\Http\UploadedFile;
+use Tests\TestCase;
+
+class AttachmentsApiTest extends TestCase
+{
+    use TestsApi;
+
+    protected $baseEndpoint = '/api/attachments';
+
+    public function test_index_endpoint_returns_expected_book()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::query()->first();
+        $attachment = $this->createAttachmentForPage($page, [
+            'name'     => 'My test attachment',
+            'external' => true,
+        ]);
+
+        $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
+        $resp->assertJson(['data' => [
+            [
+                'id'          => $attachment->id,
+                'name'        => 'My test attachment',
+                'uploaded_to' => $page->id,
+                'external'    => true,
+            ],
+        ]]);
+    }
+
+    public function test_attachments_listing_based_upon_page_visibility()
+    {
+        $this->actingAsApiEditor();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $attachment = $this->createAttachmentForPage($page, [
+            'name'     => 'My test attachment',
+            'external' => true,
+        ]);
+
+        $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
+        $resp->assertJson(['data' => [
+            [
+                'id' => $attachment->id,
+            ],
+        ]]);
+
+        $page->restricted = true;
+        $page->save();
+        $this->regenEntityPermissions($page);
+
+        $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
+        $resp->assertJsonMissing(['data' => [
+            [
+                'id' => $attachment->id,
+            ],
+        ]]);
+    }
+
+    public function test_create_endpoint_for_link_attachment()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $details = [
+            'name'        => 'My attachment',
+            'uploaded_to' => $page->id,
+            'link'        => 'https://cats.example.com',
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertStatus(200);
+        /** @var Attachment $newItem */
+        $newItem = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
+        $resp->assertJson(['id' => $newItem->id, 'external' => true, 'name' => $details['name'], 'uploaded_to' => $page->id]);
+    }
+
+    public function test_create_endpoint_for_upload_attachment()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $file = $this->getTestFile('textfile.txt');
+
+        $details = [
+            'name'        => 'My attachment',
+            'uploaded_to' => $page->id,
+        ];
+
+        $resp = $this->call('POST', $this->baseEndpoint, $details, [], ['file' => $file]);
+        $resp->assertStatus(200);
+        /** @var Attachment $newItem */
+        $newItem = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
+        $resp->assertJson(['id' => $newItem->id, 'external' => false, 'extension' => 'txt', 'name' => $details['name'], 'uploaded_to' => $page->id]);
+        $this->assertTrue(file_exists(storage_path($newItem->path)));
+        unlink(storage_path($newItem->path));
+    }
+
+    public function test_name_needed_to_create()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $details = [
+            'uploaded_to' => $page->id,
+            'link'        => 'https://example.com',
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertStatus(422);
+        $resp->assertJson([
+            'error' => [
+                'message'    => 'The given data was invalid.',
+                'validation' => [
+                    'name' => ['The name field is required.'],
+                ],
+                'code' => 422,
+            ],
+        ]);
+    }
+
+    public function test_link_or_file_needed_to_create()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $details = [
+            'name'        => 'my attachment',
+            'uploaded_to' => $page->id,
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertStatus(422);
+        $resp->assertJson([
+            'error' => [
+                'message'    => 'The given data was invalid.',
+                'validation' => [
+                    'file' => ['The file field is required when link is not present.'],
+                    'link' => ['The link field is required when file is not present.'],
+                ],
+                'code' => 422,
+            ],
+        ]);
+    }
+
+    public function test_read_endpoint_for_link_attachment()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $attachment = $this->createAttachmentForPage($page, [
+            'name'  => 'my attachment',
+            'path'  => 'https://example.com',
+            'order' => 1,
+        ]);
+
+        $resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}");
+
+        $resp->assertStatus(200);
+        $resp->assertJson([
+            'id'          => $attachment->id,
+            'content'     => 'https://example.com',
+            'external'    => true,
+            'uploaded_to' => $page->id,
+            'order'       => 1,
+            'created_by'  => [
+                'name' => $attachment->createdBy->name,
+            ],
+            'updated_by' => [
+                'name' => $attachment->createdBy->name,
+            ],
+            'links' => [
+                'html'     => "<a target=\"_blank\" href=\"http://localhost/attachments/{$attachment->id}\">my attachment</a>",
+                'markdown' => "[my attachment](http://localhost/attachments/{$attachment->id})",
+            ],
+        ]);
+    }
+
+    public function test_read_endpoint_for_file_attachment()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $file = $this->getTestFile('textfile.txt');
+
+        $details = [
+            'name'        => 'My file attachment',
+            'uploaded_to' => $page->id,
+        ];
+        $this->call('POST', $this->baseEndpoint, $details, [], ['file' => $file]);
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->firstOrFail();
+
+        $resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}");
+
+        $resp->assertStatus(200);
+        $resp->assertJson([
+            'id'          => $attachment->id,
+            'content'     => base64_encode(file_get_contents(storage_path($attachment->path))),
+            'external'    => false,
+            'uploaded_to' => $page->id,
+            'order'       => 1,
+            'created_by'  => [
+                'name' => $attachment->createdBy->name,
+            ],
+            'updated_by' => [
+                'name' => $attachment->updatedBy->name,
+            ],
+            'links' => [
+                'html'     => "<a target=\"_blank\" href=\"http://localhost/attachments/{$attachment->id}\">My file attachment</a>",
+                'markdown' => "[My file attachment](http://localhost/attachments/{$attachment->id})",
+            ],
+        ]);
+
+        unlink(storage_path($attachment->path));
+    }
+
+    public function test_update_endpoint()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $attachment = $this->createAttachmentForPage($page);
+
+        $details = [
+            'name' => 'My updated API attachment',
+        ];
+
+        $resp = $this->putJson("{$this->baseEndpoint}/{$attachment->id}", $details);
+        $attachment->refresh();
+
+        $resp->assertStatus(200);
+        $resp->assertJson(['id' => $attachment->id, 'name' => 'My updated API attachment']);
+    }
+
+    public function test_update_link_attachment_to_file()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $attachment = $this->createAttachmentForPage($page);
+        $file = $this->getTestFile('textfile.txt');
+
+        $resp = $this->call('PUT', "{$this->baseEndpoint}/{$attachment->id}", ['name' => 'My updated file'], [], ['file' => $file]);
+        $resp->assertStatus(200);
+
+        $attachment->refresh();
+        $this->assertFalse($attachment->external);
+        $this->assertEquals('txt', $attachment->extension);
+        $this->assertStringStartsWith('uploads/files/', $attachment->path);
+        $this->assertFileExists(storage_path($attachment->path));
+
+        unlink(storage_path($attachment->path));
+    }
+
+    public function test_update_file_attachment_to_link()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $file = $this->getTestFile('textfile.txt');
+        $this->call('POST', $this->baseEndpoint, ['name' => 'My file attachment', 'uploaded_to' => $page->id], [], ['file' => $file]);
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->where('name', '=', 'My file attachment')->firstOrFail();
+
+        $filePath = storage_path($attachment->path);
+        $this->assertFileExists($filePath);
+
+        $details = [
+            'name' => 'My updated API attachment',
+            'link' => 'https://cats.example.com',
+        ];
+
+        $resp = $this->putJson("{$this->baseEndpoint}/{$attachment->id}", $details);
+        $resp->assertStatus(200);
+        $attachment->refresh();
+
+        $this->assertFileDoesNotExist($filePath);
+        $this->assertTrue($attachment->external);
+        $this->assertEquals('https://cats.example.com', $attachment->path);
+        $this->assertEquals('', $attachment->extension);
+    }
+
+    public function test_delete_endpoint()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $attachment = $this->createAttachmentForPage($page);
+
+        $resp = $this->deleteJson("{$this->baseEndpoint}/{$attachment->id}");
+
+        $resp->assertStatus(204);
+        $this->assertDatabaseMissing('attachments', ['id' => $attachment->id]);
+    }
+
+    protected function createAttachmentForPage(Page $page, $attributes = []): Attachment
+    {
+        $admin = $this->getAdmin();
+        /** @var Attachment $attachment */
+        $attachment = $page->attachments()->forceCreate(array_merge([
+            'uploaded_to' => $page->id,
+            'name'        => 'test attachment',
+            'external'    => true,
+            'order'       => 1,
+            'created_by'  => $admin->id,
+            'updated_by'  => $admin->id,
+            'path'        => 'https://attachment.example.com',
+        ], $attributes));
+
+        return $attachment;
+    }
+
+    /**
+     * Get a test file that can be uploaded.
+     */
+    protected function getTestFile(string $fileName): UploadedFile
+    {
+        return new UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
+    }
+}
index 683ca0c747a022a31c829f9ea2359feadf4e6e44..97ca82ea71c914bf5597408f8dd5fc5aec2087b2 100644 (file)
@@ -17,6 +17,16 @@ trait TestsApi
         return $this;
     }
 
+    /**
+     * Set the API admin role as the current user via the API driver.
+     */
+    protected function actingAsApiAdmin()
+    {
+        $this->actingAs($this->getAdmin(), 'api');
+
+        return $this;
+    }
+
     /**
      * Format the given items into a standardised error format.
      */
index bc36a184d129f3465a9e9fc855dfcabd21b98d5d..8d13670ca95e85a084a1269d178a9dd088a259bc 100644 (file)
@@ -140,4 +140,53 @@ class AuditLogTest extends TestCase
         $resp->assertSeeText($chapter->name);
         $resp->assertDontSeeText($page->name);
     }
+
+    public function test_ip_address_logged_and_visible()
+    {
+        config()->set('app.proxies', '*');
+        $editor = $this->getEditor();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $this->actingAs($editor)->put($page->getUrl(), [
+            'name' => 'Updated page',
+            'html' => '<p>Updated content</p>',
+        ], [
+            'X-Forwarded-For' => '192.123.45.1',
+        ])->assertRedirect($page->refresh()->getUrl());
+
+        $this->assertDatabaseHas('activities', [
+            'type'      => ActivityType::PAGE_UPDATE,
+            'ip'        => '192.123.45.1',
+            'user_id'   => $editor->id,
+            'entity_id' => $page->id,
+        ]);
+
+        $resp = $this->asAdmin()->get('/settings/audit');
+        $resp->assertSee('192.123.45.1');
+    }
+
+    public function test_ip_address_not_logged_in_demo_mode()
+    {
+        config()->set('app.proxies', '*');
+        config()->set('app.env', 'demo');
+        $editor = $this->getEditor();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $this->actingAs($editor)->put($page->getUrl(), [
+            'name' => 'Updated page',
+            'html' => '<p>Updated content</p>',
+        ], [
+            'X-Forwarded-For' => '192.123.45.1',
+            'REMOTE_ADDR'     => '192.123.45.2',
+        ])->assertRedirect($page->refresh()->getUrl());
+
+        $this->assertDatabaseHas('activities', [
+            'type'      => ActivityType::PAGE_UPDATE,
+            'ip'        => '127.0.0.1',
+            'user_id'   => $editor->id,
+            'entity_id' => $page->id,
+        ]);
+    }
 }
index 657728c175e712e8d075ed2436483b1e6c2dacd9..79f00bed093cbd28c0c550820e41e76a0b799133 100644 (file)
@@ -3,49 +3,41 @@
 namespace Tests\Auth;
 
 use BookStack\Auth\Access\Mfa\MfaSession;
-use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Page;
 use BookStack\Notifications\ConfirmEmail;
 use BookStack\Notifications\ResetPassword;
-use BookStack\Settings\SettingService;
-use DB;
-use Hash;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Notification;
-use Illuminate\Support\Str;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
+use Tests\TestResponse;
 
-class AuthTest extends BrowserKitTest
+class AuthTest extends TestCase
 {
     public function test_auth_working()
     {
-        $this->visit('/')
-            ->seePageIs('/login');
+        $this->get('/')->assertRedirect('/login');
     }
 
     public function test_login()
     {
-        $this->login('[email protected]', 'password')
-            ->seePageIs('/');
+        $this->login('[email protected]', 'password')->assertRedirect('/');
     }
 
     public function test_public_viewing()
     {
-        $settings = app(SettingService::class);
-        $settings->put('app-public', 'true');
-        $this->visit('/')
-            ->seePageIs('/')
-            ->see('Log In');
+        $this->setSettings(['app-public' => 'true']);
+        $this->get('/')
+            ->assertOk()
+            ->assertSee('Log in');
     }
 
     public function test_registration_showing()
     {
         // Ensure registration form is showing
         $this->setSettings(['registration-enabled' => 'true']);
-        $this->visit('/login')
-            ->see('Sign up')
-            ->click('Sign up')
-            ->seePageIs('/register');
+        $this->get('/login')
+            ->assertElementContains('a[href="' . url('/register') . '"]', 'Sign up');
     }
 
     public function test_normal_registration()
@@ -55,15 +47,17 @@ class AuthTest extends BrowserKitTest
         $user = factory(User::class)->make();
 
         // Test form and ensure user is created
-        $this->visit('/register')
-            ->see('Sign Up')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Create Account')
-            ->seePageIs('/')
-            ->see($user->name)
-            ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email]);
+        $this->get('/register')
+            ->assertSee('Sign Up')
+            ->assertElementContains('form[action="' . url('/register') . '"]', 'Create Account');
+
+        $resp = $this->post('/register', $user->only('password', 'name', 'email'));
+        $resp->assertRedirect('/');
+
+        $resp = $this->get('/');
+        $resp->assertOk();
+        $resp->assertSee($user->name);
+        $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email]);
     }
 
     public function test_empty_registration_redirects_back_with_errors()
@@ -72,36 +66,33 @@ class AuthTest extends BrowserKitTest
         $this->setSettings(['registration-enabled' => 'true']);
 
         // Test form and ensure user is created
-        $this->visit('/register')
-            ->press('Create Account')
-            ->see('The name field is required')
-            ->seePageIs('/register');
+        $this->get('/register');
+        $this->post('/register', [])->assertRedirect('/register');
+        $this->get('/register')->assertSee('The name field is required');
     }
 
     public function test_registration_validation()
     {
         $this->setSettings(['registration-enabled' => 'true']);
 
-        $this->visit('/register')
-            ->type('1', '#name')
-            ->type('1', '#email')
-            ->type('1', '#password')
-            ->press('Create Account')
-            ->see('The name must be at least 2 characters.')
-            ->see('The email must be a valid email address.')
-            ->see('The password must be at least 8 characters.')
-            ->seePageIs('/register');
+        $this->get('/register');
+        $resp = $this->followingRedirects()->post('/register', [
+            'name'     => '1',
+            'email'    => '1',
+            'password' => '1',
+        ]);
+        $resp->assertSee('The name must be at least 2 characters.');
+        $resp->assertSee('The email must be a valid email address.');
+        $resp->assertSee('The password must be at least 8 characters.');
     }
 
     public function test_sign_up_link_on_login()
     {
-        $this->visit('/login')
-            ->dontSee('Sign up');
+        $this->get('/login')->assertDontSee('Sign up');
 
         $this->setSettings(['registration-enabled' => 'true']);
 
-        $this->visit('/login')
-            ->see('Sign up');
+        $this->get('/login')->assertSee('Sign up');
     }
 
     public function test_confirmed_registration()
@@ -114,27 +105,24 @@ class AuthTest extends BrowserKitTest
         $user = factory(User::class)->make();
 
         // Go through registration process
-        $this->visit('/register')
-            ->see('Sign Up')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Create Account')
-            ->seePageIs('/register/confirm')
-            ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
+        $resp = $this->post('/register', $user->only('name', 'email', 'password'));
+        $resp->assertRedirect('/register/confirm');
+        $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
 
         // Ensure notification sent
-        $dbUser = User::where('email', '=', $user->email)->first();
+        /** @var User $dbUser */
+        $dbUser = User::query()->where('email', '=', $user->email)->first();
         Notification::assertSentTo($dbUser, ConfirmEmail::class);
 
         // Test access and resend confirmation email
-        $this->login($user->email, $user->password)
-            ->seePageIs('/register/confirm/awaiting')
-            ->see('Resend')
-            ->visit('/books')
-            ->seePageIs('/login')
-            ->visit('/register/confirm/awaiting')
-            ->press('Resend Confirmation Email');
+        $resp = $this->login($user->email, $user->password);
+        $resp->assertRedirect('/register/confirm/awaiting');
+
+        $resp = $this->get('/register/confirm/awaiting');
+        $resp->assertElementContains('form[action="' . url('/register/confirm/resend') . '"]', 'Resend');
+
+        $this->get('/books')->assertRedirect('/login');
+        $this->post('/register/confirm/resend', $user->only('email'));
 
         // Get confirmation and confirm notification matches
         $emailConfirmation = DB::table('email_confirmations')->where('user_id', '=', $dbUser->id)->first();
@@ -143,188 +131,69 @@ class AuthTest extends BrowserKitTest
         });
 
         // Check confirmation email confirmation activation.
-        $this->visit('/register/confirm/' . $emailConfirmation->token)
-            ->seePageIs('/')
-            ->see($user->name)
-            ->notSeeInDatabase('email_confirmations', ['token' => $emailConfirmation->token])
-            ->seeInDatabase('users', ['name' => $dbUser->name, 'email' => $dbUser->email, 'email_confirmed' => true]);
+        $this->get('/register/confirm/' . $emailConfirmation->token)->assertRedirect('/');
+        $this->get('/')->assertSee($user->name);
+        $this->assertDatabaseMissing('email_confirmations', ['token' => $emailConfirmation->token]);
+        $this->assertDatabaseHas('users', ['name' => $dbUser->name, 'email' => $dbUser->email, 'email_confirmed' => true]);
     }
 
     public function test_restricted_registration()
     {
         $this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'true', 'registration-restrict' => 'example.com']);
         $user = factory(User::class)->make();
+
         // Go through registration process
-        $this->visit('/register')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Create Account')
-            ->seePageIs('/register')
-            ->dontSeeInDatabase('users', ['email' => $user->email])
-            ->see('That email domain does not have access to this application');
+        $this->post('/register', $user->only('name', 'email', 'password'))
+            ->assertRedirect('/register');
+        $resp = $this->get('/register');
+        $resp->assertSee('That email domain does not have access to this application');
+        $this->assertDatabaseMissing('users', $user->only('email'));
 
         $user->email = '[email protected]';
 
-        $this->visit('/register')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Create Account')
-            ->seePageIs('/register/confirm')
-            ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
+        $this->post('/register', $user->only('name', 'email', 'password'))
+            ->assertRedirect('/register/confirm');
+        $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
 
         $this->assertNull(auth()->user());
 
-        $this->visit('/')->seePageIs('/login')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Log In')
-            ->seePageIs('/register/confirm/awaiting')
-            ->seeText('Email Address Not Confirmed');
+        $this->get('/')->assertRedirect('/login');
+        $resp = $this->followingRedirects()->post('/login', $user->only('email', 'password'));
+        $resp->assertSee('Email Address Not Confirmed');
+        $this->assertNull(auth()->user());
     }
 
     public function test_restricted_registration_with_confirmation_disabled()
     {
         $this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'false', 'registration-restrict' => 'example.com']);
         $user = factory(User::class)->make();
+
         // Go through registration process
-        $this->visit('/register')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Create Account')
-            ->seePageIs('/register')
-            ->dontSeeInDatabase('users', ['email' => $user->email])
-            ->see('That email domain does not have access to this application');
+        $this->post('/register', $user->only('name', 'email', 'password'))
+            ->assertRedirect('/register');
+        $this->assertDatabaseMissing('users', $user->only('email'));
+        $this->get('/register')->assertSee('That email domain does not have access to this application');
 
         $user->email = '[email protected]';
 
-        $this->visit('/register')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Create Account')
-            ->seePageIs('/register/confirm')
-            ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
+        $this->post('/register', $user->only('name', 'email', 'password'))
+            ->assertRedirect('/register/confirm');
+        $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
 
         $this->assertNull(auth()->user());
 
-        $this->visit('/')->seePageIs('/login')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Log In')
-            ->seePageIs('/register/confirm/awaiting')
-            ->seeText('Email Address Not Confirmed');
-    }
-
-    public function test_user_creation()
-    {
-        /** @var User $user */
-        $user = factory(User::class)->make();
-        $adminRole = Role::getRole('admin');
-
-        $this->asAdmin()
-            ->visit('/settings/users')
-            ->click('Add New User')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->check("roles[{$adminRole->id}]")
-            ->type($user->password, '#password')
-            ->type($user->password, '#password-confirm')
-            ->press('Save')
-            ->seePageIs('/settings/users')
-            ->seeInDatabase('users', $user->only(['name', 'email']))
-            ->see($user->name);
-
-        $user->refresh();
-        $this->assertStringStartsWith(Str::slug($user->name), $user->slug);
-    }
-
-    public function test_user_updating()
-    {
-        $user = $this->getNormalUser();
-        $password = $user->password;
-        $this->asAdmin()
-            ->visit('/settings/users')
-            ->click($user->name)
-            ->seePageIs('/settings/users/' . $user->id)
-            ->see($user->email)
-            ->type('Barry Scott', '#name')
-            ->press('Save')
-            ->seePageIs('/settings/users')
-            ->seeInDatabase('users', ['id' => $user->id, 'name' => 'Barry Scott', 'password' => $password])
-            ->notSeeInDatabase('users', ['name' => $user->name]);
-
-        $user->refresh();
-        $this->assertStringStartsWith(Str::slug($user->name), $user->slug);
-    }
-
-    public function test_user_password_update()
-    {
-        $user = $this->getNormalUser();
-        $userProfilePage = '/settings/users/' . $user->id;
-        $this->asAdmin()
-            ->visit($userProfilePage)
-            ->type('newpassword', '#password')
-            ->press('Save')
-            ->seePageIs($userProfilePage)
-            ->see('Password confirmation required')
-
-            ->type('newpassword', '#password')
-            ->type('newpassword', '#password-confirm')
-            ->press('Save')
-            ->seePageIs('/settings/users');
-
-        $userPassword = User::find($user->id)->password;
-        $this->assertTrue(Hash::check('newpassword', $userPassword));
-    }
-
-    public function test_user_deletion()
-    {
-        $userDetails = factory(User::class)->make();
-        $user = $this->getEditor($userDetails->toArray());
-
-        $this->asAdmin()
-            ->visit('/settings/users/' . $user->id)
-            ->click('Delete User')
-            ->see($user->name)
-            ->press('Confirm')
-            ->seePageIs('/settings/users')
-            ->notSeeInDatabase('users', ['name' => $user->name]);
-    }
-
-    public function test_user_cannot_be_deleted_if_last_admin()
-    {
-        $adminRole = Role::getRole('admin');
-
-        // Delete all but one admin user if there are more than one
-        $adminUsers = $adminRole->users;
-        if (count($adminUsers) > 1) {
-            foreach ($adminUsers->splice(1) as $user) {
-                $user->delete();
-            }
-        }
-
-        // Ensure we currently only have 1 admin user
-        $this->assertEquals(1, $adminRole->users()->count());
-        $user = $adminRole->users->first();
-
-        $this->asAdmin()->visit('/settings/users/' . $user->id)
-            ->click('Delete User')
-            ->press('Confirm')
-            ->seePageIs('/settings/users/' . $user->id)
-            ->see('You cannot delete the only admin');
+        $this->get('/')->assertRedirect('/login');
+        $resp = $this->post('/login', $user->only('email', 'password'));
+        $resp->assertRedirect('/register/confirm/awaiting');
+        $this->get('/register/confirm/awaiting')->assertSee('Email Address Not Confirmed');
+        $this->assertNull(auth()->user());
     }
 
     public function test_logout()
     {
-        $this->asAdmin()
-            ->visit('/')
-            ->seePageIs('/')
-            ->visit('/logout')
-            ->visit('/')
-            ->seePageIs('/login');
+        $this->asAdmin()->get('/')->assertOk();
+        $this->get('/logout')->assertRedirect('/');
+        $this->get('/')->assertRedirect('/login');
     }
 
     public function test_mfa_session_cleared_on_logout()
@@ -335,7 +204,7 @@ class AuthTest extends BrowserKitTest
         $mfaSession->markVerifiedForUser($user);
         $this->assertTrue($mfaSession->isVerifiedForUser($user));
 
-        $this->asAdmin()->visit('/logout');
+        $this->asAdmin()->get('/logout');
         $this->assertFalse($mfaSession->isVerifiedForUser($user));
     }
 
@@ -343,69 +212,101 @@ class AuthTest extends BrowserKitTest
     {
         Notification::fake();
 
-        $this->visit('/login')->click('Forgot Password?')
-            ->seePageIs('/password/email')
-            ->type('[email protected]', 'email')
-            ->press('Send Reset Link')
-            ->see('A password reset link will be sent to [email protected] if that email address is found in the system.');
+        $this->get('/login')
+            ->assertElementContains('a[href="' . url('/password/email') . '"]', 'Forgot Password?');
 
-        $this->seeInDatabase('password_resets', [
+        $this->get('/password/email')
+            ->assertElementContains('form[action="' . url('/password/email') . '"]', 'Send Reset Link');
+
+        $resp = $this->post('/password/email', [
             'email' => '[email protected]',
         ]);
+        $resp->assertRedirect('/password/email');
+
+        $resp = $this->get('/password/email');
+        $resp->assertSee('A password reset link will be sent to [email protected] if that email address is found in the system.');
 
-        $user = User::where('email', '=', '[email protected]')->first();
+        $this->assertDatabaseHas('password_resets', [
+            'email' => '[email protected]',
+        ]);
+
+        /** @var User $user */
+        $user = User::query()->where('email', '=', '[email protected]')->first();
 
         Notification::assertSentTo($user, ResetPassword::class);
         $n = Notification::sent($user, ResetPassword::class);
 
-        $this->visit('/password/reset/' . $n->first()->token)
-            ->see('Reset Password')
-            ->submitForm('Reset Password', [
-                'email'                 => '[email protected]',
-                'password'              => 'randompass',
-                'password_confirmation' => 'randompass',
-            ])->seePageIs('/')
-            ->see('Your password has been successfully reset');
+        $this->get('/password/reset/' . $n->first()->token)
+            ->assertOk()
+            ->assertSee('Reset Password');
+
+        $resp = $this->post('/password/reset', [
+            'email'                 => '[email protected]',
+            'password'              => 'randompass',
+            'password_confirmation' => 'randompass',
+            'token'                 => $n->first()->token,
+        ]);
+        $resp->assertRedirect('/');
+
+        $this->get('/')->assertSee('Your password has been successfully reset');
     }
 
     public function test_reset_password_flow_shows_success_message_even_if_wrong_password_to_prevent_user_discovery()
     {
-        $this->visit('/login')->click('Forgot Password?')
-            ->seePageIs('/password/email')
-            ->type('[email protected]', 'email')
-            ->press('Send Reset Link')
-            ->see('A password reset link will be sent to [email protected] if that email address is found in the system.')
-            ->dontSee('We can\'t find a user');
-
-        $this->visit('/password/reset/arandometokenvalue')
-            ->see('Reset Password')
-            ->submitForm('Reset Password', [
-                'email'                 => '[email protected]',
-                'password'              => 'randompass',
-                'password_confirmation' => 'randompass',
-            ])->followRedirects()
-            ->seePageIs('/password/reset/arandometokenvalue')
-            ->dontSee('We can\'t find a user')
-            ->see('The password reset token is invalid for this email address.');
+        $this->get('/password/email');
+        $resp = $this->followingRedirects()->post('/password/email', [
+            'email' => '[email protected]',
+        ]);
+        $resp->assertSee('A password reset link will be sent to [email protected] if that email address is found in the system.');
+        $resp->assertDontSee('We can\'t find a user');
+
+        $this->get('/password/reset/arandometokenvalue')->assertSee('Reset Password');
+        $resp = $this->post('/password/reset', [
+            'email'                 => '[email protected]',
+            'password'              => 'randompass',
+            'password_confirmation' => 'randompass',
+            'token'                 => 'arandometokenvalue',
+        ]);
+        $resp->assertRedirect('/password/reset/arandometokenvalue');
+
+        $this->get('/password/reset/arandometokenvalue')
+            ->assertDontSee('We can\'t find a user')
+            ->assertSee('The password reset token is invalid for this email address.');
     }
 
     public function test_reset_password_page_shows_sign_links()
     {
         $this->setSettings(['registration-enabled' => 'true']);
-        $this->visit('/password/email')
-            ->seeLink('Log in')
-            ->seeLink('Sign up');
+        $this->get('/password/email')
+            ->assertElementContains('a', 'Log in')
+            ->assertElementContains('a', 'Sign up');
+    }
+
+    public function test_reset_password_request_is_throttled()
+    {
+        $editor = $this->getEditor();
+        Notification::fake();
+        $this->get('/password/email');
+        $this->followingRedirects()->post('/password/email', [
+            'email' => $editor->email,
+        ]);
+
+        $resp = $this->followingRedirects()->post('/password/email', [
+            'email' => $editor->email,
+        ]);
+        Notification::assertTimesSent(1, ResetPassword::class);
+        $resp->assertSee('A password reset link will be sent to ' . $editor->email . ' if that email address is found in the system.');
     }
 
     public function test_login_redirects_to_initially_requested_url_correctly()
     {
         config()->set('app.url', 'http://localhost');
+        /** @var Page $page */
         $page = Page::query()->first();
 
-        $this->visit($page->getUrl())
-            ->seePageUrlIs(url('/login'));
+        $this->get($page->getUrl())->assertRedirect(url('/login'));
         $this->login('[email protected]', 'password')
-            ->seePageUrlIs($page->getUrl());
+            ->assertRedirect($page->getUrl());
     }
 
     public function test_login_intended_redirect_does_not_redirect_to_external_pages()
@@ -416,15 +317,15 @@ class AuthTest extends BrowserKitTest
         $this->get('/login', ['referer' => 'https://example.com']);
         $login = $this->post('/login', ['email' => '[email protected]', 'password' => 'password']);
 
-        $login->assertRedirectedTo('http://localhost');
+        $login->assertRedirect('http://localhost');
     }
 
     public function test_login_intended_redirect_does_not_factor_mfa_routes()
     {
-        $this->get('/books')->assertRedirectedTo('/login');
-        $this->get('/mfa/setup')->assertRedirectedTo('/login');
+        $this->get('/books')->assertRedirect('/login');
+        $this->get('/mfa/setup')->assertRedirect('/login');
         $login = $this->post('/login', ['email' => '[email protected]', 'password' => 'password']);
-        $login->assertRedirectedTo('/books');
+        $login->assertRedirect('/books');
     }
 
     public function test_login_authenticates_admins_on_all_guards()
@@ -433,6 +334,7 @@ class AuthTest extends BrowserKitTest
         $this->assertTrue(auth()->check());
         $this->assertTrue(auth('ldap')->check());
         $this->assertTrue(auth('saml2')->check());
+        $this->assertTrue(auth('oidc')->check());
     }
 
     public function test_login_authenticates_nonadmins_on_default_guard_only()
@@ -445,6 +347,7 @@ class AuthTest extends BrowserKitTest
         $this->assertTrue(auth()->check());
         $this->assertFalse(auth('ldap')->check());
         $this->assertFalse(auth('saml2')->check());
+        $this->assertFalse(auth('oidc')->check());
     }
 
     public function test_failed_logins_are_logged_when_message_configured()
@@ -459,14 +362,25 @@ class AuthTest extends BrowserKitTest
         $this->assertFalse($log->hasWarningThatContains('Failed login for [email protected]'));
     }
 
+    public function test_logged_in_user_with_unconfirmed_email_is_logged_out()
+    {
+        $this->setSettings(['registration-confirmation' => 'true']);
+        $user = $this->getEditor();
+        $user->email_confirmed = false;
+        $user->save();
+
+        auth()->login($user);
+        $this->assertTrue(auth()->check());
+
+        $this->get('/books')->assertRedirect('/');
+        $this->assertFalse(auth()->check());
+    }
+
     /**
      * Perform a login.
      */
-    protected function login(string $email, string $password): AuthTest
+    protected function login(string $email, string $password): TestResponse
     {
-        return $this->visit('/login')
-            ->type($email, '#email')
-            ->type($password, '#password')
-            ->press('Log In');
+        return $this->post('/login', compact('email', 'password'));
     }
 }
index eb0e2faf0f32d47134bb342928dcbca2a68016b0..59a2a41b5652ee7c2c7e423eab3e8b27d4a8a6cd 100644 (file)
@@ -4,6 +4,7 @@ namespace Tests\Auth;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Auth\Access\Mfa\MfaValue;
+use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use PragmaRX\Google2FA\Google2FA;
 use Tests\TestCase;
@@ -36,10 +37,12 @@ class MfaConfigurationTest extends TestCase
         $resp->assertSee('The provided code is not valid or has expired.');
         $revisitSvg = $resp->getElementHtml('#main-content .card svg');
         $this->assertTrue($svg === $revisitSvg);
+        $secret = decrypt(session()->get('mfa-setup-totp-secret'));
+
+        $resp->assertSee(htmlentities("?secret={$secret}&issuer=BookStack&algorithm=SHA1&digits=6&period=30"));
 
         // Successful confirmation
         $google2fa = new Google2FA();
-        $secret = decrypt(session()->get('mfa-setup-totp-secret'));
         $otp = $google2fa->getCurrentOtp($secret);
         $resp = $this->post('/mfa/totp/confirm', [
             'code' => $otp,
@@ -162,4 +165,22 @@ class MfaConfigurationTest extends TestCase
         $this->assertActivityExists(ActivityType::MFA_REMOVE_METHOD);
         $this->assertEquals(0, $admin->mfaValues()->count());
     }
+
+    public function test_totp_setup_url_shows_correct_user_when_setup_forced_upon_login()
+    {
+        $admin = $this->getAdmin();
+        /** @var Role $role */
+        $role = $admin->roles()->first();
+        $role->mfa_enforced = true;
+        $role->save();
+
+        $resp = $this->post('/login', ['email' => $admin->email, 'password' => 'password']);
+        $this->assertFalse(auth()->check());
+        $resp->assertRedirect('/mfa/verify');
+
+        $resp = $this->get('/mfa/totp/generate');
+        $resp->assertSeeText('Mobile App Setup');
+        $resp->assertDontSee('otpauth://totp/BookStack:guest%40example.com');
+        $resp->assertSee('otpauth://totp/BookStack:admin%40admin.com');
+    }
 }
diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php
new file mode 100644 (file)
index 0000000..6c806aa
--- /dev/null
@@ -0,0 +1,381 @@
+<?php
+
+namespace Tests\Auth;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Auth\User;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\Psr7\Response;
+use Illuminate\Filesystem\Cache;
+use Tests\Helpers\OidcJwtHelper;
+use Tests\TestCase;
+use Tests\TestResponse;
+
+class OidcTest extends TestCase
+{
+    protected $keyFilePath;
+    protected $keyFile;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+        // Set default config for OpenID Connect
+
+        $this->keyFile = tmpfile();
+        $this->keyFilePath = 'file://' . stream_get_meta_data($this->keyFile)['uri'];
+        file_put_contents($this->keyFilePath, OidcJwtHelper::publicPemKey());
+
+        config()->set([
+            'auth.method'                 => 'oidc',
+            'auth.defaults.guard'         => 'oidc',
+            'oidc.name'                   => 'SingleSignOn-Testing',
+            'oidc.display_name_claims'    => ['name'],
+            'oidc.client_id'              => OidcJwtHelper::defaultClientId(),
+            'oidc.client_secret'          => 'testpass',
+            'oidc.jwt_public_key'         => $this->keyFilePath,
+            'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
+            'oidc.authorization_endpoint' => 'https://oidc.local/auth',
+            'oidc.token_endpoint'         => 'https://oidc.local/token',
+            'oidc.discover'               => false,
+            'oidc.dump_user_details'      => false,
+        ]);
+    }
+
+    public function tearDown(): void
+    {
+        parent::tearDown();
+        if (file_exists($this->keyFilePath)) {
+            unlink($this->keyFilePath);
+        }
+    }
+
+    public function test_login_option_shows_on_login_page()
+    {
+        $req = $this->get('/login');
+        $req->assertSeeText('SingleSignOn-Testing');
+        $req->assertElementExists('form[action$="/oidc/login"][method=POST] button');
+    }
+
+    public function test_oidc_routes_are_only_active_if_oidc_enabled()
+    {
+        config()->set(['auth.method' => 'standard']);
+        $routes = ['/login' => 'post', '/callback' => 'get'];
+        foreach ($routes as $uri => $method) {
+            $req = $this->call($method, '/oidc' . $uri);
+            $this->assertPermissionError($req);
+        }
+    }
+
+    public function test_forgot_password_routes_inaccessible()
+    {
+        $resp = $this->get('/password/email');
+        $this->assertPermissionError($resp);
+
+        $resp = $this->post('/password/email');
+        $this->assertPermissionError($resp);
+
+        $resp = $this->get('/password/reset/abc123');
+        $this->assertPermissionError($resp);
+
+        $resp = $this->post('/password/reset');
+        $this->assertPermissionError($resp);
+    }
+
+    public function test_standard_login_routes_inaccessible()
+    {
+        $resp = $this->post('/login');
+        $this->assertPermissionError($resp);
+    }
+
+    public function test_logout_route_functions()
+    {
+        $this->actingAs($this->getEditor());
+        $this->get('/logout');
+        $this->assertFalse(auth()->check());
+    }
+
+    public function test_user_invite_routes_inaccessible()
+    {
+        $resp = $this->get('/register/invite/abc123');
+        $this->assertPermissionError($resp);
+
+        $resp = $this->post('/register/invite/abc123');
+        $this->assertPermissionError($resp);
+    }
+
+    public function test_user_register_routes_inaccessible()
+    {
+        $resp = $this->get('/register');
+        $this->assertPermissionError($resp);
+
+        $resp = $this->post('/register');
+        $this->assertPermissionError($resp);
+    }
+
+    public function test_login()
+    {
+        $req = $this->post('/oidc/login');
+        $redirect = $req->headers->get('location');
+
+        $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location');
+        $this->assertFalse($this->isAuthenticated());
+        $this->assertStringContainsString('scope=openid%20profile%20email', $redirect);
+        $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect);
+        $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect);
+    }
+
+    public function test_login_success_flow()
+    {
+        // Start auth
+        $this->post('/oidc/login');
+        $state = session()->get('oidc_state');
+
+        $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([
+            'email' => '[email protected]',
+            'sub'   => 'benny1010101',
+        ])]);
+
+        // Callback from auth provider
+        // App calls token endpoint to get id token
+        $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
+        $resp->assertRedirect('/');
+        $this->assertCount(1, $transactions);
+        /** @var Request $tokenRequest */
+        $tokenRequest = $transactions[0]['request'];
+        $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri());
+        $this->assertEquals('POST', $tokenRequest->getMethod());
+        $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]);
+        $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody());
+        $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody());
+        $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody());
+
+        $this->assertTrue(auth()->check());
+        $this->assertDatabaseHas('users', [
+            'email'            => '[email protected]',
+            'external_auth_id' => 'benny1010101',
+            'email_confirmed'  => false,
+        ]);
+
+        $user = User::query()->where('email', '=', '[email protected]')->first();
+        $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
+    }
+
+    public function test_callback_fails_if_no_state_present_or_matching()
+    {
+        $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
+        $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
+
+        $this->post('/oidc/login');
+        $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
+        $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);
+
+        $resp = $this->runLogin([
+            'email' => '[email protected]',
+            'sub'   => 'benny505',
+        ]);
+
+        $resp->assertStatus(200);
+        $resp->assertJson([
+            'email' => '[email protected]',
+            'sub'   => 'benny505',
+            'iss'   => OidcJwtHelper::defaultIssuer(),
+            'aud'   => OidcJwtHelper::defaultClientId(),
+        ]);
+        $this->assertFalse(auth()->check());
+    }
+
+    public function test_auth_fails_if_no_email_exists_in_user_data()
+    {
+        $this->runLogin([
+            'email' => '',
+            'sub'   => 'benny505',
+        ]);
+
+        $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system');
+    }
+
+    public function test_auth_fails_if_already_logged_in()
+    {
+        $this->asEditor();
+
+        $this->runLogin([
+            'email' => '[email protected]',
+            'sub'   => 'benny505',
+        ]);
+
+        $this->assertSessionError('Already logged in');
+    }
+
+    public function test_auth_login_as_existing_user()
+    {
+        $editor = $this->getEditor();
+        $editor->external_auth_id = 'benny505';
+        $editor->save();
+
+        $this->assertFalse(auth()->check());
+
+        $this->runLogin([
+            'email' => '[email protected]',
+            'sub'   => 'benny505',
+        ]);
+
+        $this->assertTrue(auth()->check());
+        $this->assertEquals($editor->id, auth()->user()->id);
+    }
+
+    public function test_auth_login_as_existing_user_email_with_different_auth_id_fails()
+    {
+        $editor = $this->getEditor();
+        $editor->external_auth_id = 'editor101';
+        $editor->save();
+
+        $this->assertFalse(auth()->check());
+
+        $this->runLogin([
+            'email' => $editor->email,
+            'sub'   => 'benny505',
+        ]);
+
+        $this->assertSessionError('A user with the email ' . $editor->email . ' already exists but with different credentials.');
+        $this->assertFalse(auth()->check());
+    }
+
+    public function test_auth_login_with_invalid_token_fails()
+    {
+        $this->runLogin([
+            'sub' => null,
+        ]);
+
+        $this->assertSessionError('ID token validate failed with error: Missing token subject value');
+        $this->assertFalse(auth()->check());
+    }
+
+    public function test_auth_login_with_autodiscovery()
+    {
+        $this->withAutodiscovery();
+
+        $transactions = &$this->mockHttpClient([
+            $this->getAutoDiscoveryResponse(),
+            $this->getJwksResponse(),
+        ]);
+
+        $this->assertFalse(auth()->check());
+
+        $this->runLogin();
+
+        $this->assertTrue(auth()->check());
+        /** @var Request $discoverRequest */
+        $discoverRequest = $transactions[0]['request'];
+        /** @var Request $discoverRequest */
+        $keysRequest = $transactions[1]['request'];
+
+        $this->assertEquals('GET', $keysRequest->getMethod());
+        $this->assertEquals('GET', $discoverRequest->getMethod());
+        $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri());
+        $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri());
+    }
+
+    public function test_auth_fails_if_autodiscovery_fails()
+    {
+        $this->withAutodiscovery();
+        $this->mockHttpClient([
+            new Response(404, [], 'Not found'),
+        ]);
+
+        $this->runLogin();
+        $this->assertFalse(auth()->check());
+        $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
+    }
+
+    public function test_autodiscovery_calls_are_cached()
+    {
+        $this->withAutodiscovery();
+
+        $transactions = &$this->mockHttpClient([
+            $this->getAutoDiscoveryResponse(),
+            $this->getJwksResponse(),
+            $this->getAutoDiscoveryResponse([
+                'issuer' => 'https://auto.example.com',
+            ]),
+            $this->getJwksResponse(),
+        ]);
+
+        // Initial run
+        $this->post('/oidc/login');
+        $this->assertCount(2, $transactions);
+        // Second run, hits cache
+        $this->post('/oidc/login');
+        $this->assertCount(2, $transactions);
+
+        // Third run, different issuer, new cache key
+        config()->set(['oidc.issuer' => 'https://auto.example.com']);
+        $this->post('/oidc/login');
+        $this->assertCount(4, $transactions);
+    }
+
+    protected function withAutodiscovery()
+    {
+        config()->set([
+            'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
+            'oidc.discover'               => true,
+            'oidc.authorization_endpoint' => null,
+            'oidc.token_endpoint'         => null,
+            'oidc.jwt_public_key'         => null,
+        ]);
+    }
+
+    protected function runLogin($claimOverrides = []): TestResponse
+    {
+        $this->post('/oidc/login');
+        $state = session()->get('oidc_state');
+        $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]);
+
+        return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
+    }
+
+    protected function getAutoDiscoveryResponse($responseOverrides = []): Response
+    {
+        return new Response(200, [
+            'Content-Type'  => 'application/json',
+            'Cache-Control' => 'no-cache, no-store',
+            'Pragma'        => 'no-cache',
+        ], json_encode(array_merge([
+            'token_endpoint'         => OidcJwtHelper::defaultIssuer() . '/oidc/token',
+            'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
+            'jwks_uri'               => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
+            'issuer'                 => OidcJwtHelper::defaultIssuer(),
+        ], $responseOverrides)));
+    }
+
+    protected function getJwksResponse(): Response
+    {
+        return new Response(200, [
+            'Content-Type'  => 'application/json',
+            'Cache-Control' => 'no-cache, no-store',
+            'Pragma'        => 'no-cache',
+        ], json_encode([
+            'keys' => [
+                OidcJwtHelper::publicJwkKeyArray(),
+            ],
+        ]));
+    }
+
+    protected function getMockAuthorizationResponse($claimOverrides = []): Response
+    {
+        return new Response(200, [
+            'Content-Type'  => 'application/json',
+            'Cache-Control' => 'no-cache, no-store',
+            'Pragma'        => 'no-cache',
+        ], json_encode([
+            'access_token' => 'abc123',
+            'token_type'   => 'Bearer',
+            'expires_in'   => 3600,
+            'id_token'     => OidcJwtHelper::idToken($claimOverrides),
+        ]));
+    }
+}
index 8ace3e2ee4f19dd9ea8fee11f606f2225f601277..7fb8d6ddbe4cd40fa8f82ca76ff41149f60ae46a 100644 (file)
@@ -68,17 +68,47 @@ class Saml2Test extends TestCase
         config()->set(['saml2.onelogin.strict' => false]);
         $this->assertFalse($this->isAuthenticated());
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
-            $acsPost = $this->post('/saml2/acs');
-            $acsPost->assertRedirect('/');
-            $this->assertTrue($this->isAuthenticated());
-            $this->assertDatabaseHas('users', [
-                'email'            => '[email protected]',
-                'external_auth_id' => 'user',
-                'email_confirmed'  => false,
-                'name'             => 'Barry Scott',
-            ]);
-        });
+        $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $redirect = $acsPost->headers->get('Location');
+        $acsId = explode('?id=', $redirect)[1];
+        $this->assertTrue(strlen($acsId) > 12);
+
+        $this->assertStringContainsString('/saml2/acs?id=', $redirect);
+        $this->assertTrue(cache()->has('saml2_acs:' . $acsId));
+
+        $acsGet = $this->get($redirect);
+        $acsGet->assertRedirect('/');
+        $this->assertFalse(cache()->has('saml2_acs:' . $acsId));
+
+        $this->assertTrue($this->isAuthenticated());
+        $this->assertDatabaseHas('users', [
+            'email'            => '[email protected]',
+            'external_auth_id' => 'user',
+            'email_confirmed'  => false,
+            'name'             => 'Barry Scott',
+        ]);
+    }
+
+    public function test_acs_process_id_randomly_generated()
+    {
+        $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $redirectA = $acsPost->headers->get('Location');
+
+        $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $redirectB = $acsPost->headers->get('Location');
+
+        $this->assertFalse($redirectA === $redirectB);
+    }
+
+    public function test_process_acs_endpoint_cant_be_called_with_invalid_id()
+    {
+        $resp = $this->get('/saml2/acs');
+        $resp->assertRedirect('/login');
+        $this->followRedirects($resp)->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
+
+        $resp = $this->get('/saml2/acs?id=abc123');
+        $resp->assertRedirect('/login');
+        $this->followRedirects($resp)->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
     }
 
     public function test_group_role_sync_on_login()
@@ -92,14 +122,12 @@ class Saml2Test extends TestCase
         $memberRole = factory(Role::class)->create(['external_auth_id' => 'member']);
         $adminRole = Role::getSystemRole('admin');
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () use ($memberRole, $adminRole) {
-            $acsPost = $this->post('/saml2/acs');
-            $user = User::query()->where('external_auth_id', '=', 'user')->first();
+        $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $user = User::query()->where('external_auth_id', '=', 'user')->first();
 
-            $userRoleIds = $user->roles()->pluck('id');
-            $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
-            $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
-        });
+        $userRoleIds = $user->roles()->pluck('id');
+        $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
+        $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
     }
 
     public function test_group_role_sync_removal_option_works_as_expected()
@@ -110,18 +138,16 @@ class Saml2Test extends TestCase
             'saml2.remove_from_groups' => true,
         ]);
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
-            $acsPost = $this->post('/saml2/acs');
-            $user = User::query()->where('external_auth_id', '=', 'user')->first();
+        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $user = User::query()->where('external_auth_id', '=', 'user')->first();
 
-            $randomRole = factory(Role::class)->create(['external_auth_id' => 'random']);
-            $user->attachRole($randomRole);
-            $this->assertContains($randomRole->id, $user->roles()->pluck('id'));
+        $randomRole = factory(Role::class)->create(['external_auth_id' => 'random']);
+        $user->attachRole($randomRole);
+        $this->assertContains($randomRole->id, $user->roles()->pluck('id'));
 
-            auth()->logout();
-            $acsPost = $this->post('/saml2/acs');
-            $this->assertNotContains($randomRole->id, $user->roles()->pluck('id'));
-        });
+        auth()->logout();
+        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $this->assertNotContains($randomRole->id, $user->roles()->pluck('id'));
     }
 
     public function test_logout_link_directs_to_saml_path()
@@ -149,16 +175,12 @@ class Saml2Test extends TestCase
             $this->assertFalse($this->isAuthenticated());
         };
 
-        $loginAndStartLogout = function () use ($handleLogoutResponse) {
-            $this->post('/saml2/acs');
+        $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
 
-            $req = $this->get('/saml2/logout');
-            $redirect = $req->headers->get('location');
-            $this->assertStringStartsWith('http://saml.local/saml2/idp/SingleLogoutService.php', $redirect);
-            $this->withGet(['SAMLResponse' => $this->sloResponseData], $handleLogoutResponse);
-        };
-
-        $this->withPost(['SAMLResponse' => $this->acsPostData], $loginAndStartLogout);
+        $req = $this->get('/saml2/logout');
+        $redirect = $req->headers->get('location');
+        $this->assertStringStartsWith('http://saml.local/saml2/idp/SingleLogoutService.php', $redirect);
+        $this->withGet(['SAMLResponse' => $this->sloResponseData], $handleLogoutResponse);
     }
 
     public function test_logout_sls_flow_when_sls_not_configured()
@@ -168,15 +190,12 @@ class Saml2Test extends TestCase
             'saml2.onelogin.idp.singleLogoutService.url' => null,
         ]);
 
-        $loginAndStartLogout = function () {
-            $this->post('/saml2/acs');
+        $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $this->assertTrue($this->isAuthenticated());
 
-            $req = $this->get('/saml2/logout');
-            $req->assertRedirect('/');
-            $this->assertFalse($this->isAuthenticated());
-        };
-
-        $this->withPost(['SAMLResponse' => $this->acsPostData], $loginAndStartLogout);
+        $req = $this->get('/saml2/logout');
+        $req->assertRedirect('/');
+        $this->assertFalse($this->isAuthenticated());
     }
 
     public function test_dump_user_details_option_works()
@@ -186,14 +205,12 @@ class Saml2Test extends TestCase
             'saml2.dump_user_details' => true,
         ]);
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
-            $acsPost = $this->post('/saml2/acs');
-            $acsPost->assertJsonStructure([
-                'id_from_idp',
-                'attrs_from_idp'      => [],
-                'attrs_after_parsing' => [],
-            ]);
-        });
+        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $acsPost->assertJsonStructure([
+            'id_from_idp',
+            'attrs_from_idp'      => [],
+            'attrs_after_parsing' => [],
+        ]);
     }
 
     public function test_saml_routes_are_only_active_if_saml_enabled()
@@ -263,13 +280,10 @@ class Saml2Test extends TestCase
             'saml2.onelogin.strict' => false,
         ]);
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
-            $acsPost = $this->post('/saml2/acs');
-            $acsPost->assertRedirect('/login');
-            $errorMessage = session()->get('error');
-            $this->assertStringContainsString('That email domain does not have access to this application', $errorMessage);
-            $this->assertDatabaseMissing('users', ['email' => '[email protected]']);
-        });
+        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $acsPost->assertSeeText('That email domain does not have access to this application');
+        $this->assertFalse(auth()->check());
+        $this->assertDatabaseMissing('users', ['email' => '[email protected]']);
     }
 
     public function test_group_sync_functions_when_email_confirmation_required()
@@ -284,19 +298,17 @@ class Saml2Test extends TestCase
         $memberRole = factory(Role::class)->create(['external_auth_id' => 'member']);
         $adminRole = Role::getSystemRole('admin');
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () use ($memberRole, $adminRole) {
-            $acsPost = $this->followingRedirects()->post('/saml2/acs');
+        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
 
-            $this->assertEquals('http://localhost/register/confirm', url()->current());
-            $acsPost->assertSee('Please check your email and click the confirmation button to access BookStack.');
-            /** @var User $user */
-            $user = User::query()->where('external_auth_id', '=', 'user')->first();
+        $this->assertEquals('http://localhost/register/confirm', url()->current());
+        $acsPost->assertSee('Please check your email and click the confirmation button to access BookStack.');
+        /** @var User $user */
+        $user = User::query()->where('external_auth_id', '=', 'user')->first();
 
-            $userRoleIds = $user->roles()->pluck('id');
-            $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
-            $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
-            $this->assertFalse(boolval($user->email_confirmed), 'User email remains unconfirmed');
-        });
+        $userRoleIds = $user->roles()->pluck('id');
+        $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
+        $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
+        $this->assertFalse(boolval($user->email_confirmed), 'User email remains unconfirmed');
 
         $this->assertNull(auth()->user());
         $homeGet = $this->get('/');
@@ -316,18 +328,14 @@ class Saml2Test extends TestCase
             'name'             => 'Barry Scott',
         ]);
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
-            $acsPost = $this->post('/saml2/acs');
-            $acsPost->assertRedirect('/login');
-            $this->assertFalse($this->isAuthenticated());
-            $this->assertDatabaseHas('users', [
-                'email'            => '[email protected]',
-                'external_auth_id' => 'old_system_user_id',
-            ]);
-
-            $loginGet = $this->get('/login');
-            $loginGet->assertSee('A user with the email [email protected] already exists but with different credentials');
-        });
+        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $this->assertFalse($this->isAuthenticated());
+        $this->assertDatabaseHas('users', [
+            'email'            => '[email protected]',
+            'external_auth_id' => 'old_system_user_id',
+        ]);
+
+        $acsPost->assertSee('A user with the email [email protected] already exists but with different credentials');
     }
 
     public function test_login_request_contains_expected_default_authncontext()
@@ -370,11 +378,6 @@ class Saml2Test extends TestCase
         return $this->withGlobal($_GET, $options, $callback);
     }
 
-    protected function withPost(array $options, callable $callback)
-    {
-        return $this->withGlobal($_POST, $options, $callback);
-    }
-
     protected function withGlobal(array &$global, array $options, callable $callback)
     {
         $original = [];
index 5818cbb742bbe57c68292fef25ed59f0deacc473..f70263dd278ae1a8bf93efe26c896bda300d97e3 100644 (file)
@@ -2,9 +2,10 @@
 
 namespace Tests\Auth;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\SocialAccount;
 use BookStack\Auth\User;
-use DB;
+use Illuminate\Support\Facades\DB;
 use Laravel\Socialite\Contracts\Factory;
 use Laravel\Socialite\Contracts\Provider;
 use Mockery;
@@ -82,6 +83,7 @@ class SocialAuthTest extends TestCase
         ]);
         $resp = $this->followingRedirects()->get('/login/service/github/callback');
         $resp->assertDontSee('login-form');
+        $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, 'github; (' . $this->getAdmin()->id . ') ' . $this->getAdmin()->name);
     }
 
     public function test_social_account_detach()
index c5c4b01af065ea607471c64562e554f8c51feed3..dcf9e23df9b829a10cfd175800d058247c1333fe 100644 (file)
@@ -6,9 +6,9 @@ use BookStack\Auth\Access\UserInviteService;
 use BookStack\Auth\User;
 use BookStack\Notifications\UserInvite;
 use Carbon\Carbon;
-use DB;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Notification;
 use Illuminate\Support\Str;
-use Notification;
 use Tests\TestCase;
 
 class UserInviteTest extends TestCase
diff --git a/tests/BrowserKitTest.php b/tests/BrowserKitTest.php
deleted file mode 100644 (file)
index 23eb108..0000000
+++ /dev/null
@@ -1,218 +0,0 @@
-<?php
-
-namespace Tests;
-
-use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Auth\User;
-use BookStack\Entities\Models\Book;
-use BookStack\Entities\Models\Chapter;
-use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\Page;
-use BookStack\Settings\SettingService;
-use DB;
-use Illuminate\Contracts\Console\Kernel;
-use Illuminate\Foundation\Application;
-use Illuminate\Foundation\Testing\DatabaseTransactions;
-use Laravel\BrowserKitTesting\TestCase;
-use Symfony\Component\DomCrawler\Crawler;
-
-abstract class BrowserKitTest extends TestCase
-{
-    use DatabaseTransactions;
-    use SharedTestHelpers;
-
-    /**
-     * The base URL to use while testing the application.
-     *
-     * @var string
-     */
-    protected $baseUrl = 'http://localhost';
-
-    public function tearDown(): void
-    {
-        DB::disconnect();
-        parent::tearDown();
-    }
-
-    /**
-     * Creates the application.
-     *
-     * @return Application
-     */
-    public function createApplication()
-    {
-        $app = require __DIR__ . '/../bootstrap/app.php';
-
-        $app->make(Kernel::class)->bootstrap();
-
-        return $app;
-    }
-
-    /**
-     * Quickly sets an array of settings.
-     *
-     * @param $settingsArray
-     */
-    protected function setSettings($settingsArray)
-    {
-        $settings = app(SettingService::class);
-        foreach ($settingsArray as $key => $value) {
-            $settings->put($key, $value);
-        }
-    }
-
-    /**
-     * Create a group of entities that belong to a specific user.
-     */
-    protected function createEntityChainBelongingToUser(User $creatorUser, ?User $updaterUser = null): array
-    {
-        if (empty($updaterUser)) {
-            $updaterUser = $creatorUser;
-        }
-
-        $userAttrs = ['created_by' => $creatorUser->id, 'owned_by' => $creatorUser->id, 'updated_by' => $updaterUser->id];
-        $book = factory(Book::class)->create($userAttrs);
-        $chapter = factory(Chapter::class)->create(array_merge(['book_id' => $book->id], $userAttrs));
-        $page = factory(Page::class)->create(array_merge(['book_id' => $book->id, 'chapter_id' => $chapter->id], $userAttrs));
-        $restrictionService = $this->app[PermissionService::class];
-        $restrictionService->buildJointPermissionsForEntity($book);
-
-        return compact('book', 'chapter', 'page');
-    }
-
-    /**
-     * Helper for updating entity permissions.
-     *
-     * @param Entity $entity
-     */
-    protected function updateEntityPermissions(Entity $entity)
-    {
-        $restrictionService = $this->app[PermissionService::class];
-        $restrictionService->buildJointPermissionsForEntity($entity);
-    }
-
-    /**
-     * Quick way to create a new user without any permissions.
-     *
-     * @param array $attributes
-     *
-     * @return mixed
-     */
-    protected function getNewBlankUser($attributes = [])
-    {
-        $user = factory(User::class)->create($attributes);
-
-        return $user;
-    }
-
-    /**
-     * Assert that a given string is seen inside an element.
-     *
-     * @param bool|string|null $element
-     * @param int              $position
-     * @param string           $text
-     * @param bool             $negate
-     *
-     * @return $this
-     */
-    protected function seeInNthElement($element, $position, $text, $negate = false)
-    {
-        $method = $negate ? 'assertDoesNotMatchRegularExpression' : 'assertMatchesRegularExpression';
-
-        $rawPattern = preg_quote($text, '/');
-
-        $escapedPattern = preg_quote(e($text), '/');
-
-        $content = $this->crawler->filter($element)->eq($position)->html();
-
-        $pattern = $rawPattern == $escapedPattern
-            ? $rawPattern : "({$rawPattern}|{$escapedPattern})";
-
-        $this->$method("/$pattern/i", $content);
-
-        return $this;
-    }
-
-    /**
-     * Assert that the current page matches a given URI.
-     *
-     * @param string $uri
-     *
-     * @return $this
-     */
-    protected function seePageUrlIs($uri)
-    {
-        $this->assertEquals(
-            $uri,
-            $this->currentUri,
-            "Did not land on expected page [{$uri}].\n"
-        );
-
-        return $this;
-    }
-
-    /**
-     * Do a forced visit that does not error out on exception.
-     *
-     * @param string $uri
-     * @param array  $parameters
-     * @param array  $cookies
-     * @param array  $files
-     *
-     * @return $this
-     */
-    protected function forceVisit($uri, $parameters = [], $cookies = [], $files = [])
-    {
-        $method = 'GET';
-        $uri = $this->prepareUrlForRequest($uri);
-        $this->call($method, $uri, $parameters, $cookies, $files);
-        $this->clearInputs()->followRedirects();
-        $this->currentUri = $this->app->make('request')->fullUrl();
-        $this->crawler = new Crawler($this->response->getContent(), $uri);
-
-        return $this;
-    }
-
-    /**
-     * Click the text within the selected element.
-     *
-     * @param $parentElement
-     * @param $linkText
-     *
-     * @return $this
-     */
-    protected function clickInElement($parentElement, $linkText)
-    {
-        $elem = $this->crawler->filter($parentElement);
-        $link = $elem->selectLink($linkText);
-        $this->visit($link->link()->getUri());
-
-        return $this;
-    }
-
-    /**
-     * Check if the page contains the given element.
-     *
-     * @param string $selector
-     */
-    protected function pageHasElement($selector)
-    {
-        $elements = $this->crawler->filter($selector);
-        $this->assertTrue(count($elements) > 0, 'The page does not contain an element matching ' . $selector);
-
-        return $this;
-    }
-
-    /**
-     * Check if the page contains the given element.
-     *
-     * @param string $selector
-     */
-    protected function pageNotHasElement($selector)
-    {
-        $elements = $this->crawler->filter($selector);
-        $this->assertFalse(count($elements) > 0, 'The page contains ' . count($elements) . ' elements matching ' . $selector);
-
-        return $this;
-    }
-}
diff --git a/tests/DebugViewTest.php b/tests/DebugViewTest.php
new file mode 100644 (file)
index 0000000..63d6472
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+
+namespace Tests;
+
+use BookStack\Auth\Access\SocialAuthService;
+
+class DebugViewTest extends TestCase
+{
+    public function test_debug_view_shows_expected_details()
+    {
+        config()->set('app.debug', true);
+        $resp = $this->getDebugViewForException(new \InvalidArgumentException('An error occurred during testing'));
+
+        // Error message
+        $resp->assertSeeText('An error occurred during testing');
+        // Exception Class
+        $resp->assertSeeText('InvalidArgumentException');
+        // Stack trace
+        $resp->assertSeeText('#0');
+        $resp->assertSeeText('#1');
+        // Warning message
+        $resp->assertSeeText('WARNING: Application is in debug mode. This mode has the potential to leak');
+        // PHP version
+        $resp->assertSeeText('PHP Version: ' . phpversion());
+        // BookStack version
+        $resp->assertSeeText('BookStack Version: ' . trim(file_get_contents(base_path('version'))));
+        // Dynamic help links
+        $resp->assertElementExists('a[href*="q=' . urlencode('BookStack An error occurred during testing') . '"]');
+        $resp->assertElementExists('a[href*="?q=is%3Aissue+' . urlencode('An error occurred during testing') . '"]');
+    }
+
+    public function test_debug_view_only_shows_when_debug_mode_is_enabled()
+    {
+        config()->set('app.debug', true);
+        $resp = $this->getDebugViewForException(new \InvalidArgumentException('An error occurred during testing'));
+        $resp->assertSeeText('Stack Trace');
+        $resp->assertDontSeeText('An unknown error occurred');
+
+        config()->set('app.debug', false);
+        $resp = $this->getDebugViewForException(new \InvalidArgumentException('An error occurred during testing'));
+        $resp->assertDontSeeText('Stack Trace');
+        $resp->assertSeeText('An unknown error occurred');
+    }
+
+    protected function getDebugViewForException(\Exception $exception): TestResponse
+    {
+        // Fake an error via social auth service used on login page
+        $mockService = $this->mock(SocialAuthService::class);
+        $mockService->shouldReceive('getActiveDrivers')->andThrow($exception);
+
+        return $this->get('/login');
+    }
+}
index 480d290028153ef1f7e789924f76da2da604dc7f..1780ddee819504ebbd94bae50163653934a0198e 100644 (file)
@@ -369,4 +369,12 @@ class BookShelfTest extends TestCase
         $resp = $this->asEditor()->get($newBook->getUrl());
         $resp->assertDontSee($shelfInfo['name']);
     }
+
+    public function test_cancel_on_child_book_creation_returns_to_original_shelf()
+    {
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
+        $resp = $this->asEditor()->get($shelf->getUrl('/create-book'));
+        $resp->assertElementContains('form a[href="' . $shelf->getUrl() . '"]', 'Cancel');
+    }
 }
index b4ba2fa8229a82ea7c9c709204dce46dda4781bb..fa63c0bf98c1ef3bbf8865294685b05e45ca3d24 100644 (file)
@@ -7,7 +7,69 @@ use Tests\TestCase;
 
 class BookTest extends TestCase
 {
-    public function test_book_delete()
+    public function test_create()
+    {
+        $book = factory(Book::class)->make([
+            'name' => 'My First Book',
+        ]);
+
+        $resp = $this->asEditor()->get('/books');
+        $resp->assertElementContains('a[href="' . url('/create-book') . '"]', 'Create New Book');
+
+        $resp = $this->get('/create-book');
+        $resp->assertElementContains('form[action="' . url('/books') . '"][method="POST"]', 'Save Book');
+
+        $resp = $this->post('/books', $book->only('name', 'description'));
+        $resp->assertRedirect('/books/my-first-book');
+
+        $resp = $this->get('/books/my-first-book');
+        $resp->assertSee($book->name);
+        $resp->assertSee($book->description);
+    }
+
+    public function test_create_uses_different_slugs_when_name_reused()
+    {
+        $book = factory(Book::class)->make([
+            'name' => 'My First Book',
+        ]);
+
+        $this->asEditor()->post('/books', $book->only('name', 'description'));
+        $this->asEditor()->post('/books', $book->only('name', 'description'));
+
+        $books = Book::query()->where('name', '=', $book->name)
+            ->orderBy('id', 'desc')
+            ->take(2)
+            ->get();
+
+        $this->assertMatchesRegularExpression('/my-first-book-[0-9a-zA-Z]{3}/', $books[0]->slug);
+        $this->assertEquals('my-first-book', $books[1]->slug);
+    }
+
+    public function test_update()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        // Cheeky initial update to refresh slug
+        $this->asEditor()->put($book->getUrl(), ['name' => $book->name . '5', 'description' => $book->description]);
+        $book->refresh();
+
+        $newName = $book->name . ' Updated';
+        $newDesc = $book->description . ' with more content';
+
+        $resp = $this->get($book->getUrl('/edit'));
+        $resp->assertSee($book->name);
+        $resp->assertSee($book->description);
+        $resp->assertElementContains('form[action="' . $book->getUrl() . '"]', 'Save Book');
+
+        $resp = $this->put($book->getUrl(), ['name' => $newName, 'description' => $newDesc]);
+        $resp->assertRedirect($book->getUrl() . '-updated');
+
+        $resp = $this->get($book->getUrl() . '-updated');
+        $resp->assertSee($newName);
+        $resp->assertSee($newDesc);
+    }
+
+    public function test_delete()
     {
         $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
         $this->assertNull($book->deleted_at);
@@ -34,6 +96,20 @@ class BookTest extends TestCase
         $redirectReq->assertNotificationContains('Book Successfully Deleted');
     }
 
+    public function test_cancel_on_create_page_leads_back_to_books_listing()
+    {
+        $resp = $this->asEditor()->get('/create-book');
+        $resp->assertElementContains('form a[href="' . url('/books') . '"]', 'Cancel');
+    }
+
+    public function test_cancel_on_edit_book_page_leads_back_to_book()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $resp = $this->asEditor()->get($book->getUrl('/edit'));
+        $resp->assertElementContains('form a[href="' . $book->getUrl() . '"]', 'Cancel');
+    }
+
     public function test_next_previous_navigation_controls_show_within_book_content()
     {
         $book = Book::query()->first();
@@ -48,4 +124,84 @@ class BookTest extends TestCase
         $resp->assertElementContains('#sibling-navigation', 'Previous');
         $resp->assertElementContains('#sibling-navigation', substr($chapter->name, 0, 20));
     }
+
+    public function test_recently_viewed_books_updates_as_expected()
+    {
+        $books = Book::all()->take(2);
+
+        $this->asAdmin()->get('/books')
+            ->assertElementNotContains('#recents', $books[0]->name)
+            ->assertElementNotContains('#recents', $books[1]->name);
+
+        $this->get($books[0]->getUrl());
+        $this->get($books[1]->getUrl());
+
+        $this->get('/books')
+            ->assertElementContains('#recents', $books[0]->name)
+            ->assertElementContains('#recents', $books[1]->name);
+    }
+
+    public function test_popular_books_updates_upon_visits()
+    {
+        $books = Book::all()->take(2);
+
+        $this->asAdmin()->get('/books')
+            ->assertElementNotContains('#popular', $books[0]->name)
+            ->assertElementNotContains('#popular', $books[1]->name);
+
+        $this->get($books[0]->getUrl());
+        $this->get($books[1]->getUrl());
+        $this->get($books[0]->getUrl());
+
+        $this->get('/books')
+            ->assertElementContains('#popular .book:nth-child(1)', $books[0]->name)
+            ->assertElementContains('#popular .book:nth-child(2)', $books[1]->name);
+    }
+
+    public function test_books_view_shows_view_toggle_option()
+    {
+        /** @var Book $book */
+        $editor = $this->getEditor();
+        setting()->putUser($editor, 'books_view_type', 'list');
+
+        $resp = $this->actingAs($editor)->get('/books');
+        $resp->assertElementContains('form[action$="/settings/users/' . $editor->id . '/switch-books-view"]', 'Grid View');
+        $resp->assertElementExists('input[name="view_type"][value="grid"]');
+
+        $resp = $this->patch("/settings/users/{$editor->id}/switch-books-view", ['view_type' => 'grid']);
+        $resp->assertRedirect();
+        $this->assertEquals('grid', setting()->getUser($editor, 'books_view_type'));
+
+        $resp = $this->actingAs($editor)->get('/books');
+        $resp->assertElementContains('form[action$="/settings/users/' . $editor->id . '/switch-books-view"]', 'List View');
+        $resp->assertElementExists('input[name="view_type"][value="list"]');
+
+        $resp = $this->patch("/settings/users/{$editor->id}/switch-books-view", ['view_type' => 'list']);
+        $resp->assertRedirect();
+        $this->assertEquals('list', setting()->getUser($editor, 'books_view_type'));
+    }
+
+    public function test_slug_multi_byte_url_safe()
+    {
+        $book = $this->newBook([
+            'name' => 'информация',
+        ]);
+
+        $this->assertEquals('informatsiya', $book->slug);
+
+        $book = $this->newBook([
+            'name' => '¿Qué?',
+        ]);
+
+        $this->assertEquals('que', $book->slug);
+    }
+
+    public function test_slug_format()
+    {
+        $book = $this->newBook([
+            'name' => 'PartA / PartB / PartC',
+        ]);
+
+        $this->assertEquals('parta-partb-partc', $book->slug);
+    }
 }
index 45c132e8917428d90b8333243513e77c3a38936c..ea29ece5d2c51db2633e13f2236e9e3e8af043c9 100644 (file)
@@ -2,12 +2,36 @@
 
 namespace Tests\Entity;
 
+use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use Tests\TestCase;
 
 class ChapterTest extends TestCase
 {
-    public function test_chapter_delete()
+    public function test_create()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+
+        $chapter = factory(Chapter::class)->make([
+            'name' => 'My First Chapter',
+        ]);
+
+        $resp = $this->asEditor()->get($book->getUrl());
+        $resp->assertElementContains('a[href="' . $book->getUrl('/create-chapter') . '"]', 'New Chapter');
+
+        $resp = $this->get($book->getUrl('/create-chapter'));
+        $resp->assertElementContains('form[action="' . $book->getUrl('/create-chapter') . '"][method="POST"]', 'Save Chapter');
+
+        $resp = $this->post($book->getUrl('/create-chapter'), $chapter->only('name', 'description'));
+        $resp->assertRedirect($book->getUrl('/chapter/my-first-chapter'));
+
+        $resp = $this->get($book->getUrl('/chapter/my-first-chapter'));
+        $resp->assertSee($chapter->name);
+        $resp->assertSee($chapter->description);
+    }
+
+    public function test_delete()
     {
         $chapter = Chapter::query()->whereHas('pages')->first();
         $this->assertNull($chapter->deleted_at);
diff --git a/tests/Entity/EntityAccessTest.php b/tests/Entity/EntityAccessTest.php
new file mode 100644 (file)
index 0000000..f2f2445
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Auth\UserRepo;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Repos\PageRepo;
+use Tests\TestCase;
+
+class EntityAccessTest extends TestCase
+{
+    public function test_entities_viewable_after_creator_deletion()
+    {
+        // Create required assets and revisions
+        $creator = $this->getEditor();
+        $updater = $this->getViewer();
+        $entities = $this->createEntityChainBelongingToUser($creator, $updater);
+        app()->make(UserRepo::class)->destroy($creator);
+        app()->make(PageRepo::class)->update($entities['page'], ['html' => '<p>hello!</p>>']);
+
+        $this->checkEntitiesViewable($entities);
+    }
+
+    public function test_entities_viewable_after_updater_deletion()
+    {
+        // Create required assets and revisions
+        $creator = $this->getViewer();
+        $updater = $this->getEditor();
+        $entities = $this->createEntityChainBelongingToUser($creator, $updater);
+        app()->make(UserRepo::class)->destroy($updater);
+        app()->make(PageRepo::class)->update($entities['page'], ['html' => '<p>Hello there!</p>']);
+
+        $this->checkEntitiesViewable($entities);
+    }
+
+    /**
+     * @param array<string, Entity> $entities
+     */
+    private function checkEntitiesViewable(array $entities)
+    {
+        // Check pages and books are visible.
+        $this->asAdmin();
+        foreach ($entities as $entity) {
+            $this->get($entity->getUrl())
+                ->assertStatus(200)
+                ->assertSee($entity->name);
+        }
+
+        // Check revision listing shows no errors.
+        $this->get($entities['page']->getUrl('/revisions'))->assertStatus(200);
+    }
+}
diff --git a/tests/Entity/EntityTest.php b/tests/Entity/EntityTest.php
deleted file mode 100644 (file)
index f8c88b1..0000000
+++ /dev/null
@@ -1,319 +0,0 @@
-<?php
-
-namespace Tests\Entity;
-
-use BookStack\Auth\UserRepo;
-use BookStack\Entities\Models\Book;
-use BookStack\Entities\Models\Bookshelf;
-use BookStack\Entities\Models\Chapter;
-use BookStack\Entities\Models\Page;
-use BookStack\Entities\Repos\PageRepo;
-use Carbon\Carbon;
-use Tests\BrowserKitTest;
-
-class EntityTest extends BrowserKitTest
-{
-    public function test_entity_creation()
-    {
-        // Test Creation
-        $book = $this->bookCreation();
-        $chapter = $this->chapterCreation($book);
-        $this->pageCreation($chapter);
-
-        // Test Updating
-        $this->bookUpdate($book);
-    }
-
-    public function bookUpdate(Book $book)
-    {
-        $newName = $book->name . ' Updated';
-        $this->asAdmin()
-            // Go to edit screen
-            ->visit($book->getUrl() . '/edit')
-            ->see($book->name)
-            // Submit new name
-            ->type($newName, '#name')
-            ->press('Save Book')
-            // Check page url and text
-            ->seePageIs($book->getUrl() . '-updated')
-            ->see($newName);
-
-        return Book::find($book->id);
-    }
-
-    public function test_book_sort_page_shows()
-    {
-        $books = Book::all();
-        $bookToSort = $books[0];
-        $this->asAdmin()
-            ->visit($bookToSort->getUrl())
-            ->click('Sort')
-            ->seePageIs($bookToSort->getUrl() . '/sort')
-            ->seeStatusCode(200)
-            ->see($bookToSort->name);
-    }
-
-    public function test_book_sort_item_returns_book_content()
-    {
-        $books = Book::all();
-        $bookToSort = $books[0];
-        $firstPage = $bookToSort->pages[0];
-        $firstChapter = $bookToSort->chapters[0];
-        $this->asAdmin()
-            ->visit($bookToSort->getUrl() . '/sort-item')
-            // Ensure book details are returned
-            ->see($bookToSort->name)
-            ->see($firstPage->name)
-            ->see($firstChapter->name);
-    }
-
-    public function test_toggle_book_view()
-    {
-        $editor = $this->getEditor();
-        setting()->putUser($editor, 'books_view_type', 'grid');
-
-        $this->actingAs($editor)
-            ->visit('/books')
-            ->pageHasElement('.featured-image-container')
-            ->submitForm('List View')
-            // Check redirection.
-            ->seePageIs('/books')
-            ->pageNotHasElement('.featured-image-container');
-
-        $this->actingAs($editor)
-            ->visit('/books')
-            ->submitForm('Grid View')
-            ->seePageIs('/books')
-            ->pageHasElement('.featured-image-container');
-    }
-
-    public function pageCreation($chapter)
-    {
-        $page = factory(Page::class)->make([
-            'name' => 'My First Page',
-        ]);
-
-        $this->asAdmin()
-            // Navigate to page create form
-            ->visit($chapter->getUrl())
-            ->click('New Page');
-
-        $draftPage = Page::where('draft', '=', true)->orderBy('created_at', 'desc')->first();
-
-        $this->seePageIs($draftPage->getUrl())
-            // Fill out form
-            ->type($page->name, '#name')
-            ->type($page->html, '#html')
-            ->press('Save Page')
-            // Check redirect and page
-            ->seePageIs($chapter->book->getUrl() . '/page/my-first-page')
-            ->see($page->name);
-
-        $page = Page::where('slug', '=', 'my-first-page')->where('chapter_id', '=', $chapter->id)->first();
-
-        return $page;
-    }
-
-    public function chapterCreation(Book $book)
-    {
-        $chapter = factory(Chapter::class)->make([
-            'name' => 'My First Chapter',
-        ]);
-
-        $this->asAdmin()
-            // Navigate to chapter create page
-            ->visit($book->getUrl())
-            ->click('New Chapter')
-            ->seePageIs($book->getUrl() . '/create-chapter')
-            // Fill out form
-            ->type($chapter->name, '#name')
-            ->type($chapter->description, '#description')
-            ->press('Save Chapter')
-            // Check redirect and landing page
-            ->seePageIs($book->getUrl() . '/chapter/my-first-chapter')
-            ->see($chapter->name)->see($chapter->description);
-
-        $chapter = Chapter::where('slug', '=', 'my-first-chapter')->where('book_id', '=', $book->id)->first();
-
-        return $chapter;
-    }
-
-    public function bookCreation()
-    {
-        $book = factory(Book::class)->make([
-            'name' => 'My First Book',
-        ]);
-        $this->asAdmin()
-            ->visit('/books')
-            // Choose to create a book
-            ->click('Create New Book')
-            ->seePageIs('/create-book')
-            // Fill out form & save
-            ->type($book->name, '#name')
-            ->type($book->description, '#description')
-            ->press('Save Book')
-            // Check it redirects correctly
-            ->seePageIs('/books/my-first-book')
-            ->see($book->name)->see($book->description);
-
-        // Ensure duplicate names are given different slugs
-        $this->asAdmin()
-            ->visit('/create-book')
-            ->type($book->name, '#name')
-            ->type($book->description, '#description')
-            ->press('Save Book');
-
-        $expectedPattern = '/\/books\/my-first-book-[0-9a-zA-Z]{3}/';
-        $this->assertMatchesRegularExpression($expectedPattern, $this->currentUri, "Did not land on expected page [$expectedPattern].\n");
-
-        $book = Book::where('slug', '=', 'my-first-book')->first();
-
-        return $book;
-    }
-
-    public function test_entities_viewable_after_creator_deletion()
-    {
-        // Create required assets and revisions
-        $creator = $this->getEditor();
-        $updater = $this->getEditor();
-        $entities = $this->createEntityChainBelongingToUser($creator, $updater);
-        $this->actingAs($creator);
-        app(UserRepo::class)->destroy($creator);
-        app(PageRepo::class)->update($entities['page'], ['html' => '<p>hello!</p>>']);
-
-        $this->checkEntitiesViewable($entities);
-    }
-
-    public function test_entities_viewable_after_updater_deletion()
-    {
-        // Create required assets and revisions
-        $creator = $this->getEditor();
-        $updater = $this->getEditor();
-        $entities = $this->createEntityChainBelongingToUser($creator, $updater);
-        $this->actingAs($updater);
-        app(UserRepo::class)->destroy($updater);
-        app(PageRepo::class)->update($entities['page'], ['html' => '<p>Hello there!</p>']);
-
-        $this->checkEntitiesViewable($entities);
-    }
-
-    private function checkEntitiesViewable($entities)
-    {
-        // Check pages and books are visible.
-        $this->asAdmin();
-        $this->visit($entities['book']->getUrl())->seeStatusCode(200)
-            ->visit($entities['chapter']->getUrl())->seeStatusCode(200)
-            ->visit($entities['page']->getUrl())->seeStatusCode(200);
-        // Check revision listing shows no errors.
-        $this->visit($entities['page']->getUrl())
-            ->click('Revisions')->seeStatusCode(200);
-    }
-
-    public function test_recently_updated_pages_view()
-    {
-        $user = $this->getEditor();
-        $content = $this->createEntityChainBelongingToUser($user);
-
-        $this->asAdmin()->visit('/pages/recently-updated')
-            ->seeInNthElement('.entity-list .page', 0, $content['page']->name);
-    }
-
-    public function test_old_page_slugs_redirect_to_new_pages()
-    {
-        $page = Page::first();
-        $pageUrl = $page->getUrl();
-        $newPageUrl = '/books/' . $page->book->slug . '/page/super-test-page';
-        // Need to save twice since revisions are not generated in seeder.
-        $this->asAdmin()->visit($pageUrl)
-            ->clickInElement('#content', 'Edit')
-            ->type('super test', '#name')
-            ->press('Save Page');
-
-        $page = Page::first();
-        $pageUrl = $page->getUrl();
-
-        // Second Save
-        $this->visit($pageUrl)
-            ->clickInElement('#content', 'Edit')
-            ->type('super test page', '#name')
-            ->press('Save Page')
-            // Check redirect
-            ->seePageIs($newPageUrl);
-
-        $this->visit($pageUrl)
-            ->seePageIs($newPageUrl);
-    }
-
-    public function test_recently_updated_pages_on_home()
-    {
-        $page = Page::orderBy('updated_at', 'asc')->first();
-        Page::where('id', '!=', $page->id)->update([
-            'updated_at' => Carbon::now()->subSecond(1),
-        ]);
-        $this->asAdmin()->visit('/')
-            ->dontSeeInElement('#recently-updated-pages', $page->name);
-        $this->visit($page->getUrl() . '/edit')
-            ->press('Save Page')
-            ->visit('/')
-            ->seeInElement('#recently-updated-pages', $page->name);
-    }
-
-    public function test_slug_multi_byte_url_safe()
-    {
-        $book = $this->newBook([
-            'name' => 'информация',
-        ]);
-
-        $this->assertEquals('informatsiya', $book->slug);
-
-        $book = $this->newBook([
-            'name' => '¿Qué?',
-        ]);
-
-        $this->assertEquals('que', $book->slug);
-    }
-
-    public function test_slug_format()
-    {
-        $book = $this->newBook([
-            'name' => 'PartA / PartB / PartC',
-        ]);
-
-        $this->assertEquals('parta-partb-partc', $book->slug);
-    }
-
-    public function test_shelf_cancel_creation_returns_to_correct_place()
-    {
-        $shelf = Bookshelf::first();
-
-        // Cancel button from shelf goes back to shelf
-        $this->asEditor()->visit($shelf->getUrl('/create-book'))
-            ->see('Cancel')
-            ->click('Cancel')
-            ->seePageIs($shelf->getUrl());
-
-        // Cancel button from books goes back to books
-        $this->asEditor()->visit('/create-book')
-            ->see('Cancel')
-            ->click('Cancel')
-            ->seePageIs('/books');
-
-        // Cancel button from book edit goes back to book
-        $book = Book::first();
-
-        $this->asEditor()->visit($book->getUrl('/edit'))
-            ->see('Cancel')
-            ->click('Cancel')
-            ->seePageIs($book->getUrl());
-    }
-
-    public function test_page_within_chapter_deletion_returns_to_chapter()
-    {
-        $chapter = Chapter::query()->first();
-        $page = $chapter->pages()->first();
-
-        $this->asEditor()->visit($page->getUrl('/delete'))
-            ->submitForm('Confirm')
-            ->seePageIs($chapter->getUrl());
-    }
-}
index 7031c3875a77edb925527d4dc4cd272d18111e55..c8397b6956c27da7bba766bdeaae888e53edf688 100644 (file)
@@ -229,6 +229,34 @@ class ExportTest extends TestCase
         $resp->assertSee('src="/uploads/svg_test.svg"');
     }
 
+    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 = Page::query()->first();
+        $page->html = '<img src="http://localhost/uploads/images/../../.htaccess"/>';
+        $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 = Page::query()->first();
+        $page->html = '<img src="http://localhost/uploads/images/../../logs/test.txt"/>';
+        $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 = [
@@ -366,4 +394,20 @@ class ExportTest extends TestCase
             $this->assertPermissionError($resp);
         }
     }
+
+    public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        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
+    }
 }
diff --git a/tests/Entity/MarkdownTest.php b/tests/Entity/MarkdownTest.php
deleted file mode 100644 (file)
index 7de7ea1..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-<?php
-
-namespace Tests\Entity;
-
-use Tests\BrowserKitTest;
-
-class MarkdownTest extends BrowserKitTest
-{
-    protected $page;
-
-    public function setUp(): void
-    {
-        parent::setUp();
-        $this->page = \BookStack\Entities\Models\Page::first();
-    }
-
-    protected function setMarkdownEditor()
-    {
-        $this->setSettings(['app-editor' => 'markdown']);
-    }
-
-    public function test_default_editor_is_wysiwyg()
-    {
-        $this->assertEquals(setting('app-editor'), 'wysiwyg');
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
-            ->pageHasElement('#html-editor');
-    }
-
-    public function test_markdown_setting_shows_markdown_editor()
-    {
-        $this->setMarkdownEditor();
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
-            ->pageNotHasElement('#html-editor')
-            ->pageHasElement('#markdown-editor');
-    }
-
-    public function test_markdown_content_given_to_editor()
-    {
-        $this->setMarkdownEditor();
-        $mdContent = '# hello. This is a test';
-        $this->page->markdown = $mdContent;
-        $this->page->save();
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
-            ->seeInField('markdown', $mdContent);
-    }
-
-    public function test_html_content_given_to_editor_if_no_markdown()
-    {
-        $this->setMarkdownEditor();
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
-            ->seeInField('markdown', $this->page->html);
-    }
-}
index 5aee9788786e17e453b98730fdd1ad908717028f..ca2a241314baa7082f2101c7717a72d1c36c32ee 100644 (file)
@@ -135,14 +135,26 @@ class PageContentTest extends TestCase
         }
     }
 
-    public function test_iframe_js_and_base64_urls_are_removed()
+    public function test_js_and_base64_src_urls_are_removed()
     {
         $checks = [
             '<iframe src="javascript:alert(document.cookie)"></iframe>',
+            '<iframe src="JavAScRipT:alert(document.cookie)"></iframe>',
+            '<iframe src="JavAScRipT:alert(document.cookie)"></iframe>',
             '<iframe SRC=" javascript: alert(document.cookie)"></iframe>',
             '<iframe src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
+            '<iframe src="DaTa:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
             '<iframe src=" data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
+            '<img src="javascript:alert(document.cookie)"/>',
+            '<img src="JavAScRipT:alert(document.cookie)"/>',
+            '<img src="JavAScRipT:alert(document.cookie)"/>',
+            '<img SRC=" javascript: alert(document.cookie)"/>',
+            '<img src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
+            '<img src="DaTa:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
+            '<img src=" data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
             '<iframe srcdoc="<script>window.alert(document.cookie)</script>"></iframe>',
+            '<iframe SRCdoc="<script>window.alert(document.cookie)</script>"></iframe>',
+            '<IMG SRC=`javascript:alert("RSnake says, \'XSS\'")`>',
         ];
 
         $this->asEditor();
@@ -155,6 +167,7 @@ class PageContentTest extends TestCase
             $pageView = $this->get($page->getUrl());
             $pageView->assertStatus(200);
             $pageView->assertElementNotContains('.page-content', '<iframe>');
+            $pageView->assertElementNotContains('.page-content', '<img');
             $pageView->assertElementNotContains('.page-content', '</iframe>');
             $pageView->assertElementNotContains('.page-content', 'src=');
             $pageView->assertElementNotContains('.page-content', 'javascript:');
@@ -168,6 +181,8 @@ class PageContentTest extends TestCase
         $checks = [
             '<a id="xss" href="javascript:alert(document.cookie)>Click me</a>',
             '<a id="xss" href="javascript: alert(document.cookie)>Click me</a>',
+            '<a id="xss" href="JaVaScRiPt: alert(document.cookie)>Click me</a>',
+            '<a id="xss" href=" JaVaScRiPt: alert(document.cookie)>Click me</a>',
         ];
 
         $this->asEditor();
@@ -179,7 +194,7 @@ class PageContentTest extends TestCase
 
             $pageView = $this->get($page->getUrl());
             $pageView->assertStatus(200);
-            $pageView->assertElementNotContains('.page-content', '<a id="xss">');
+            $pageView->assertElementNotContains('.page-content', '<a id="xss"');
             $pageView->assertElementNotContains('.page-content', 'href=javascript:');
         }
     }
@@ -188,8 +203,10 @@ class PageContentTest extends TestCase
     {
         $checks = [
             '<form><input id="xss" type=submit formaction=javascript:alert(document.domain) value=Submit><input></form>',
+            '<form ><button id="xss" formaction="JaVaScRiPt:alert(document.domain)">Click me</button></form>',
             '<form ><button id="xss" formaction=javascript:alert(document.domain)>Click me</button></form>',
             '<form id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></form>',
+            '<form id="xss" action="JaVaScRiPt:alert(document.domain)"><input type=submit value=Submit></form>',
         ];
 
         $this->asEditor();
@@ -213,6 +230,8 @@ class PageContentTest extends TestCase
     {
         $checks = [
             '<meta http-equiv="refresh" content="0; url=//external_url">',
+            '<meta http-equiv="refresh" ConTeNt="0; url=//external_url">',
+            '<meta http-equiv="refresh" content="0; UrL=//external_url">',
         ];
 
         $this->asEditor();
@@ -249,11 +268,13 @@ class PageContentTest extends TestCase
     {
         $checks = [
             '<p onclick="console.log(\'test\')">Hello</p>',
+            '<p OnCliCk="console.log(\'test\')">Hello</p>',
             '<div>Lorem ipsum dolor sit amet.</div><p onclick="console.log(\'test\')">Hello</p>',
             '<div>Lorem ipsum dolor sit amet.<p onclick="console.log(\'test\')">Hello</p></div>',
             '<div><div><div><div>Lorem ipsum dolor sit amet.<p onclick="console.log(\'test\')">Hello</p></div></div></div></div>',
             '<div onclick="console.log(\'test\')">Lorem ipsum dolor sit amet.</div><p onclick="console.log(\'test\')">Hello</p><div></div>',
             '<a a="<img src=1 onerror=\'alert(1)\'> ',
+            '\<a onclick="alert(document.cookie)"\>xss link\</a\>',
         ];
 
         $this->asEditor();
@@ -284,6 +305,28 @@ class PageContentTest extends TestCase
         $pageView->assertDontSee('abc123abc123');
     }
 
+    public function test_svg_xlink_hrefs_are_removed()
+    {
+        $checks = [
+            '<svg id="test" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100"><a xlink:href="javascript:alert(document.domain)"><rect x="0" y="0" width="100" height="100" /></a></svg>',
+            '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="data:application/xml;base64 ,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4KPGRlZnM+CjxjaXJjbGUgaWQ9InRlc3QiIHI9IjAiIGN4PSIwIiBjeT0iMCIgc3R5bGU9ImZpbGw6ICNGMDAiPgo8c2V0IGF0dHJpYnV0ZU5hbWU9ImZpbGwiIGF0dHJpYnV0ZVR5cGU9IkNTUyIgb25iZWdpbj0nYWxlcnQoZG9jdW1lbnQuZG9tYWluKScKb25lbmQ9J2FsZXJ0KCJvbmVuZCIpJyB0bz0iIzAwRiIgYmVnaW49IjBzIiBkdXI9Ijk5OXMiIC8+CjwvY2lyY2xlPgo8L2RlZnM+Cjx1c2UgeGxpbms6aHJlZj0iI3Rlc3QiLz4KPC9zdmc+#test"/></svg>',
+        ];
+
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        foreach ($checks as $check) {
+            $page->html = $check;
+            $page->save();
+
+            $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
+            $pageView->assertElementNotContains('.page-content', 'alert');
+            $pageView->assertElementNotContains('.page-content', 'xlink:href');
+            $pageView->assertElementNotContains('.page-content', 'application/xml');
+        }
+    }
+
     public function test_page_inline_on_attributes_show_if_configured()
     {
         $this->asEditor();
@@ -551,7 +594,7 @@ class PageContentTest extends TestCase
         $this->deleteImage($imagePath);
     }
 
-    public function test_base64_images_blanked_if_not_supported_extension_for_extract()
+    public function test_base64_images_within_html_blanked_if_not_supported_extension_for_extract()
     {
         $this->asEditor();
         $page = Page::query()->first();
@@ -564,4 +607,40 @@ class PageContentTest extends TestCase
         $page->refresh();
         $this->assertStringContainsString('<img src=""', $page->html);
     }
+
+    public function test_base64_images_get_extracted_from_markdown_page_content()
+    {
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        $this->put($page->getUrl(), [
+            'name'     => $page->name, 'summary' => '',
+            'markdown' => 'test ![test](data:image/jpeg;base64,' . $this->base64Jpeg . ')',
+        ]);
+
+        $page->refresh();
+        $this->assertStringMatchesFormat('%A<p%A>test <img src="http://localhost/uploads/images/gallery/%A.jpeg" alt="test">%A</p>%A', $page->html);
+
+        $matches = [];
+        preg_match('/src="https:\/\/p.rizon.top:443\/http\/localhost(.*?)"/', $page->html, $matches);
+        $imagePath = $matches[1];
+        $imageFile = public_path($imagePath);
+        $this->assertEquals(base64_decode($this->base64Jpeg), file_get_contents($imageFile));
+
+        $this->deleteImage($imagePath);
+    }
+
+    public function test_base64_images_within_markdown_blanked_if_not_supported_extension_for_extract()
+    {
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        $this->put($page->getUrl(), [
+            'name'     => $page->name, 'summary' => '',
+            'markdown' => 'test ![test](data:image/jiff;base64,' . $this->base64Jpeg . ')',
+        ]);
+
+        $page->refresh();
+        $this->assertStringContainsString('<img src=""', $page->html);
+    }
 }
index 68059af6e7126d1c2b75487d425b6c684a8aca3e..4fb7d7ab6e5226d01910ef57ac4cda633057a00d 100644 (file)
@@ -2,12 +2,17 @@
 
 namespace Tests\Entity;
 
+use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\PageRevision;
 use BookStack\Entities\Repos\PageRepo;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
 
-class PageDraftTest extends BrowserKitTest
+class PageDraftTest extends TestCase
 {
+    /**
+     * @var Page
+     */
     protected $page;
 
     /**
@@ -18,99 +23,158 @@ class PageDraftTest extends BrowserKitTest
     public function setUp(): void
     {
         parent::setUp();
-        $this->page = \BookStack\Entities\Models\Page::first();
-        $this->pageRepo = app(PageRepo::class);
+        $this->page = Page::query()->first();
+        $this->pageRepo = app()->make(PageRepo::class);
     }
 
     public function test_draft_content_shows_if_available()
     {
         $addedContent = '<p>test message content</p>';
-        $this->asAdmin()->visit($this->page->getUrl('/edit'))
-            ->dontSeeInField('html', $addedContent);
+
+        $this->asAdmin()->get($this->page->getUrl('/edit'))
+            ->assertElementNotContains('[name="html"]', $addedContent);
 
         $newContent = $this->page->html . $addedContent;
         $this->pageRepo->updatePageDraft($this->page, ['html' => $newContent]);
-        $this->asAdmin()->visit($this->page->getUrl('/edit'))
-            ->seeInField('html', $newContent);
+        $this->asAdmin()->get($this->page->getUrl('/edit'))
+            ->assertElementContains('[name="html"]', $newContent);
     }
 
     public function test_draft_not_visible_by_others()
     {
         $addedContent = '<p>test message content</p>';
-        $this->asAdmin()->visit($this->page->getUrl('/edit'))
-            ->dontSeeInField('html', $addedContent);
+        $this->asAdmin()->get($this->page->getUrl('/edit'))
+            ->assertElementNotContains('[name="html"]', $addedContent);
 
         $newContent = $this->page->html . $addedContent;
         $newUser = $this->getEditor();
         $this->pageRepo->updatePageDraft($this->page, ['html' => $newContent]);
 
-        $this->actingAs($newUser)->visit($this->page->getUrl('/edit'))
-            ->dontSeeInField('html', $newContent);
+        $this->actingAs($newUser)->get($this->page->getUrl('/edit'))
+            ->assertElementNotContains('[name="html"]', $newContent);
     }
 
     public function test_alert_message_shows_if_editing_draft()
     {
         $this->asAdmin();
         $this->pageRepo->updatePageDraft($this->page, ['html' => 'test content']);
-        $this->asAdmin()->visit($this->page->getUrl('/edit'))
-            ->see('You are currently editing a draft');
+        $this->asAdmin()->get($this->page->getUrl('/edit'))
+            ->assertSee('You are currently editing a draft');
     }
 
     public function test_alert_message_shows_if_someone_else_editing()
     {
-        $nonEditedPage = \BookStack\Entities\Models\Page::take(10)->get()->last();
+        $nonEditedPage = Page::query()->take(10)->get()->last();
         $addedContent = '<p>test message content</p>';
-        $this->asAdmin()->visit($this->page->getUrl('/edit'))
-            ->dontSeeInField('html', $addedContent);
+        $this->asAdmin()->get($this->page->getUrl('/edit'))
+            ->assertElementNotContains('[name="html"]', $addedContent);
 
         $newContent = $this->page->html . $addedContent;
         $newUser = $this->getEditor();
         $this->pageRepo->updatePageDraft($this->page, ['html' => $newContent]);
 
         $this->actingAs($newUser)
-            ->visit($this->page->getUrl('/edit'))
-            ->see('Admin has started editing this page');
+            ->get($this->page->getUrl('/edit'))
+            ->assertSee('Admin has started editing this page');
         $this->flushSession();
-        $this->visit($nonEditedPage->getUrl() . '/edit')
-            ->dontSeeInElement('.notification', 'Admin has started editing this page');
+        $this->get($nonEditedPage->getUrl() . '/edit')
+            ->assertElementNotContains('.notification', 'Admin has started editing this page');
+    }
+
+    public function test_draft_save_shows_alert_if_draft_older_than_last_page_update()
+    {
+        $admin = $this->getAdmin();
+        $editor = $this->getEditor();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $this->actingAs($editor)->put('/ajax/page/' . $page->id . '/save-draft', [
+            'name' => $page->name,
+            'html' => '<p>updated draft</p>',
+        ]);
+
+        /** @var PageRevision $draft */
+        $draft = $page->allRevisions()
+            ->where('type', '=', 'update_draft')
+            ->where('created_by', '=', $editor->id)
+            ->first();
+        $draft->created_at = now()->subMinute(1);
+        $draft->save();
+
+        $this->actingAs($admin)->put($page->refresh()->getUrl(), [
+            'name' => $page->name,
+            'html' => '<p>admin update</p>',
+        ]);
+
+        $resp = $this->actingAs($editor)->put('/ajax/page/' . $page->id . '/save-draft', [
+            'name' => $page->name,
+            'html' => '<p>updated draft again</p>',
+        ]);
+
+        $resp->assertJson([
+            'warning' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
+        ]);
+    }
+
+    public function test_draft_save_shows_alert_if_draft_edit_started_by_someone_else()
+    {
+        $admin = $this->getAdmin();
+        $editor = $this->getEditor();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $this->actingAs($admin)->put('/ajax/page/' . $page->id . '/save-draft', [
+            'name' => $page->name,
+            'html' => '<p>updated draft</p>',
+        ]);
+
+        $resp = $this->actingAs($editor)->put('/ajax/page/' . $page->id . '/save-draft', [
+            'name' => $page->name,
+            'html' => '<p>updated draft again</p>',
+        ]);
+
+        $resp->assertJson([
+            'warning' => 'Admin has started editing this page in the last 60 minutes. Take care not to overwrite each other\'s updates!',
+        ]);
     }
 
     public function test_draft_pages_show_on_homepage()
     {
-        $book = \BookStack\Entities\Models\Book::first();
-        $this->asAdmin()->visit('/')
-            ->dontSeeInElement('#recent-drafts', 'New Page')
-            ->visit($book->getUrl() . '/create-page')
-            ->visit('/')
-            ->seeInElement('#recent-drafts', 'New Page');
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $this->asAdmin()->get('/')
+            ->assertElementNotContains('#recent-drafts', 'New Page');
+
+        $this->get($book->getUrl() . '/create-page');
+
+        $this->get('/')->assertElementContains('#recent-drafts', 'New Page');
     }
 
     public function test_draft_pages_not_visible_by_others()
     {
-        $book = \BookStack\Entities\Models\Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $chapter = $book->chapters->first();
         $newUser = $this->getEditor();
 
-        $this->actingAs($newUser)->visit('/')
-            ->visit($book->getUrl('/create-page'))
-            ->visit($chapter->getUrl('/create-page'))
-            ->visit($book->getUrl())
-            ->seeInElement('.book-contents', 'New Page');
-
-        $this->asAdmin()
-            ->visit($book->getUrl())
-            ->dontSeeInElement('.book-contents', 'New Page')
-            ->visit($chapter->getUrl())
-            ->dontSeeInElement('.book-contents', 'New Page');
+        $this->actingAs($newUser)->get($book->getUrl('/create-page'));
+        $this->get($chapter->getUrl('/create-page'));
+        $this->get($book->getUrl())
+            ->assertElementContains('.book-contents', 'New Page');
+
+        $this->asAdmin()->get($book->getUrl())
+            ->assertElementNotContains('.book-contents', 'New Page');
+        $this->get($chapter->getUrl())
+            ->assertElementNotContains('.book-contents', 'New Page');
     }
 
     public function test_page_html_in_ajax_fetch_response()
     {
         $this->asAdmin();
+        /** @var Page $page */
         $page = Page::query()->first();
 
-        $this->getJson('/ajax/page/' . $page->id);
-        $this->seeJson([
+        $this->getJson('/ajax/page/' . $page->id)->assertJson([
             'html' => $page->html,
         ]);
     }
diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php
new file mode 100644 (file)
index 0000000..9b0a8f1
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class PageEditorTest extends TestCase
+{
+    /** @var Page */
+    protected $page;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+        $this->page = Page::query()->first();
+    }
+
+    public function test_default_editor_is_wysiwyg()
+    {
+        $this->assertEquals('wysiwyg', setting('app-editor'));
+        $this->asAdmin()->get($this->page->getUrl() . '/edit')
+            ->assertElementExists('#html-editor');
+    }
+
+    public function test_markdown_setting_shows_markdown_editor()
+    {
+        $this->setSettings(['app-editor' => 'markdown']);
+        $this->asAdmin()->get($this->page->getUrl() . '/edit')
+            ->assertElementNotExists('#html-editor')
+            ->assertElementExists('#markdown-editor');
+    }
+
+    public function test_markdown_content_given_to_editor()
+    {
+        $this->setSettings(['app-editor' => 'markdown']);
+
+        $mdContent = '# hello. This is a test';
+        $this->page->markdown = $mdContent;
+        $this->page->save();
+
+        $this->asAdmin()->get($this->page->getUrl() . '/edit')
+            ->assertElementContains('[name="markdown"]', $mdContent);
+    }
+
+    public function test_html_content_given_to_editor_if_no_markdown()
+    {
+        $this->setSettings(['app-editor' => 'markdown']);
+        $this->asAdmin()->get($this->page->getUrl() . '/edit')
+            ->assertElementContains('[name="markdown"]', $this->page->html);
+    }
+
+    public function test_empty_markdown_still_saves_without_error()
+    {
+        $this->setSettings(['app-editor' => 'markdown']);
+        /** @var Book $book */
+        $book = Book::query()->first();
+
+        $this->asEditor()->get($book->getUrl('/create-page'));
+        $draft = Page::query()->where('book_id', '=', $book->id)
+            ->where('draft', '=', true)->first();
+
+        $details = [
+            'name'     => 'my page',
+            'markdown' => '',
+        ];
+        $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details);
+        $resp->assertRedirect();
+
+        $this->assertDatabaseHas('pages', [
+            'markdown' => $details['markdown'],
+            'id'       => $draft->id,
+            'draft'    => false,
+        ]);
+    }
+}
index 2721c225cd66c487f823d6e075e552f406ce6549..313fc77f060f51e7ddb0c15bf9eba8070a18a1ed 100644 (file)
@@ -3,11 +3,40 @@
 namespace Tests\Entity;
 
 use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
+use Carbon\Carbon;
 use Tests\TestCase;
 
 class PageTest extends TestCase
 {
+    public function test_create()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+        $page = factory(Page::class)->make([
+            'name' => 'My First Page',
+        ]);
+
+        $resp = $this->asEditor()->get($chapter->getUrl());
+        $resp->assertElementContains('a[href="' . $chapter->getUrl('/create-page') . '"]', 'New Page');
+
+        $resp = $this->get($chapter->getUrl('/create-page'));
+        /** @var Page $draftPage */
+        $draftPage = Page::query()
+            ->where('draft', '=', true)
+            ->orderBy('created_at', 'desc')
+            ->first();
+        $resp->assertRedirect($draftPage->getUrl());
+
+        $resp = $this->get($draftPage->getUrl());
+        $resp->assertElementContains('form[action="' . $draftPage->getUrl() . '"][method="POST"]', 'Save Page');
+
+        $resp = $this->post($draftPage->getUrl(), $draftPage->only('name', 'html'));
+        $draftPage->refresh();
+        $resp->assertRedirect($draftPage->getUrl());
+    }
+
     public function test_page_view_when_creator_is_deleted_but_owner_exists()
     {
         $page = Page::query()->first();
@@ -190,26 +219,65 @@ class PageTest extends TestCase
         ]);
     }
 
-    public function test_empty_markdown_still_saves_without_error()
+    public function test_old_page_slugs_redirect_to_new_pages()
     {
-        $this->setSettings(['app-editor' => 'markdown']);
-        $book = Book::query()->first();
+        /** @var Page $page */
+        $page = Page::query()->first();
 
-        $this->asEditor()->get($book->getUrl('/create-page'));
-        $draft = Page::query()->where('book_id', '=', $book->id)
-            ->where('draft', '=', true)->first();
+        // Need to save twice since revisions are not generated in seeder.
+        $this->asAdmin()->put($page->getUrl(), [
+            'name' => 'super test',
+            'html' => '<p></p>',
+        ]);
 
-        $details = [
-            'name'     => 'my page',
-            'markdown' => '',
-        ];
-        $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details);
-        $resp->assertRedirect();
+        $page->refresh();
+        $pageUrl = $page->getUrl();
 
-        $this->assertDatabaseHas('pages', [
-            'markdown' => $details['markdown'],
-            'id'       => $draft->id,
-            'draft'    => false,
+        $this->put($pageUrl, [
+            'name' => 'super test page',
+            'html' => '<p></p>',
+        ]);
+
+        $this->get($pageUrl)
+            ->assertRedirect("/books/{$page->book->slug}/page/super-test-page");
+    }
+
+    public function test_page_within_chapter_deletion_returns_to_chapter()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+        $page = $chapter->pages()->first();
+
+        $this->asEditor()->delete($page->getUrl())
+            ->assertRedirect($chapter->getUrl());
+    }
+
+    public function test_recently_updated_pages_view()
+    {
+        $user = $this->getEditor();
+        $content = $this->createEntityChainBelongingToUser($user);
+
+        $this->asAdmin()->get('/pages/recently-updated')
+            ->assertElementContains('.entity-list .page:nth-child(1)', $content['page']->name);
+    }
+
+    public function test_recently_updated_pages_on_home()
+    {
+        /** @var Page $page */
+        $page = Page::query()->orderBy('updated_at', 'asc')->first();
+        Page::query()->where('id', '!=', $page->id)->update([
+            'updated_at' => Carbon::now()->subSecond(1),
         ]);
+
+        $this->asAdmin()->get('/')
+            ->assertElementNotContains('#recently-updated-pages', $page->name);
+
+        $this->put($page->getUrl(), [
+            'name' => $page->name,
+            'html' => $page->html,
+        ]);
+
+        $this->get('/')
+            ->assertElementContains('#recently-updated-pages', $page->name);
     }
 }
index f3d50b67d96940dec49edc73b02de062edac2e4d..5cfc5c3c5b935f9f2396fc38b4bf0521bfed1b6f 100644 (file)
@@ -216,6 +216,19 @@ class SortTest extends TestCase
         $this->assertEquals($newBook->id, $pageToCheck->book_id);
     }
 
+    public function test_book_sort_page_shows()
+    {
+        /** @var Book $bookToSort */
+        $bookToSort = Book::query()->first();
+
+        $resp = $this->asAdmin()->get($bookToSort->getUrl());
+        $resp->assertElementExists('a[href="' . $bookToSort->getUrl('/sort') . '"]');
+
+        $resp = $this->get($bookToSort->getUrl('/sort'));
+        $resp->assertStatus(200);
+        $resp->assertSee($bookToSort->name);
+    }
+
     public function test_book_sort()
     {
         $oldBook = Book::query()->first();
@@ -258,4 +271,40 @@ class SortTest extends TestCase
         $checkResp = $this->get(Page::find($checkPage->id)->getUrl());
         $checkResp->assertSee($newBook->name);
     }
+
+    public function test_book_sort_item_returns_book_content()
+    {
+        $books = Book::all();
+        $bookToSort = $books[0];
+        $firstPage = $bookToSort->pages[0];
+        $firstChapter = $bookToSort->chapters[0];
+
+        $resp = $this->asAdmin()->get($bookToSort->getUrl() . '/sort-item');
+
+        // Ensure book details are returned
+        $resp->assertSee($bookToSort->name);
+        $resp->assertSee($firstPage->name);
+        $resp->assertSee($firstChapter->name);
+    }
+
+    public function test_pages_in_book_show_sorted_by_priority()
+    {
+        /** @var Book $book */
+        $book = Book::query()->whereHas('pages')->first();
+        $book->chapters()->forceDelete();
+        /** @var Page[] $pages */
+        $pages = $book->pages()->where('chapter_id', '=', 0)->take(2)->get();
+        $book->pages()->whereNotIn('id', $pages->pluck('id'))->delete();
+
+        $resp = $this->asEditor()->get($book->getUrl());
+        $resp->assertElementContains('.content-wrap a.page:nth-child(1)', $pages[0]->name);
+        $resp->assertElementContains('.content-wrap a.page:nth-child(2)', $pages[1]->name);
+
+        $pages[0]->forceFill(['priority' => 10])->save();
+        $pages[1]->forceFill(['priority' => 5])->save();
+
+        $resp = $this->asEditor()->get($book->getUrl());
+        $resp->assertElementContains('.content-wrap a.page:nth-child(1)', $pages[1]->name);
+        $resp->assertElementContains('.content-wrap a.page:nth-child(2)', $pages[0]->name);
+    }
 }
diff --git a/tests/Helpers/OidcJwtHelper.php b/tests/Helpers/OidcJwtHelper.php
new file mode 100644 (file)
index 0000000..55a34d4
--- /dev/null
@@ -0,0 +1,133 @@
+<?php
+
+namespace Tests\Helpers;
+
+use phpseclib3\Crypt\RSA;
+
+/**
+ * A collection of functions to help with OIDC JWT testing.
+ * By default, unless overridden, content is provided in a correct working state.
+ */
+class OidcJwtHelper
+{
+    public static function defaultIssuer(): string
+    {
+        return 'https://auth.example.com';
+    }
+
+    public static function defaultClientId(): string
+    {
+        return 'xxyyzz.aaa.bbccdd.123';
+    }
+
+    public static function defaultPayload(): array
+    {
+        return [
+            'sub'                => 'abc1234def',
+            'name'               => 'Barry Scott',
+            'email'              => '[email protected]',
+            'ver'                => 1,
+            'iss'                => static::defaultIssuer(),
+            'aud'                => static::defaultClientId(),
+            'iat'                => time(),
+            'exp'                => time() + 720,
+            'jti'                => 'ID.AaaBBBbbCCCcccDDddddddEEEeeeeee',
+            'amr'                => ['pwd'],
+            'idp'                => 'fghfghgfh546456dfgdfg',
+            'preferred_username' => 'xXBazzaXx',
+            'auth_time'          => time(),
+            'at_hash'            => 'sT4jbsdSGy9w12pq3iNYDA',
+        ];
+    }
+
+    public static function idToken($payloadOverrides = [], $headerOverrides = []): string
+    {
+        $payload = array_merge(static::defaultPayload(), $payloadOverrides);
+        $header = array_merge([
+            'kid' => 'xyz456',
+            'alg' => 'RS256',
+        ], $headerOverrides);
+
+        $top = implode('.', [
+            static::base64UrlEncode(json_encode($header)),
+            static::base64UrlEncode(json_encode($payload)),
+        ]);
+
+        $privateKey = static::privateKeyInstance();
+        $signature = $privateKey->sign($top);
+
+        return $top . '.' . static::base64UrlEncode($signature);
+    }
+
+    public static function privateKeyInstance()
+    {
+        static $key;
+        if (is_null($key)) {
+            $key = RSA::loadPrivateKey(static::privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1);
+        }
+
+        return $key;
+    }
+
+    public static function base64UrlEncode(string $decoded): string
+    {
+        return strtr(base64_encode($decoded), '+/', '-_');
+    }
+
+    public static function publicPemKey(): string
+    {
+        return '-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9
+DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm
+zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i
+iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl
++zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk
+WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw
+3wIDAQAB
+-----END PUBLIC KEY-----';
+    }
+
+    public static function privatePemKey(): string
+    {
+        return '-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb
+NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te
+g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC
+xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp
+06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT
+dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6
+sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ
+6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr
+4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF
+v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW
+fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv
+HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70
+SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf
+z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s
+HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA
+DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh
+ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y
+uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5
+K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi
+6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs
+IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd
+W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7
+9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf
+efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII
+ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl
+q/1PY4iJviGKddtmfClH3v4=
+-----END PRIVATE KEY-----';
+    }
+
+    public static function publicJwkKeyArray(): array
+    {
+        return [
+            'kty' => 'RSA',
+            'alg' => 'RS256',
+            'kid' => '066e52af-8884-4926-801d-032a276f9f2a',
+            'use' => 'sig',
+            'e'   => 'AQAB',
+            'n'   => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w',
+        ];
+    }
+}
index db4e94c6d11b1a03fe6a0a12e40e61f36dddbfdc..e27b787745ff3db74ed5db183fb2fe7b9afbd30b 100644 (file)
@@ -5,6 +5,7 @@ namespace Tests;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Page;
 
 class HomepageTest extends TestCase
 {
@@ -78,6 +79,25 @@ class HomepageTest extends TestCase
         $pageDeleteReq->assertSessionMissing('error');
     }
 
+    public function test_custom_homepage_renders_includes()
+    {
+        $this->asEditor();
+        /** @var Page $included */
+        $included = Page::query()->first();
+        $content = str_repeat('This is the body content of my custom homepage.', 20);
+        $included->html = $content;
+        $included->save();
+
+        $name = 'My custom homepage';
+        $customPage = $this->newPage(['name' => $name, 'html' => '{{@' . $included->id . '}}']);
+        $this->setSettings(['app-homepage' => $customPage->id]);
+        $this->setSettings(['app-homepage-type' => 'page']);
+
+        $homeVisit = $this->get('/');
+        $homeVisit->assertSee($name);
+        $homeVisit->assertSee($content);
+    }
+
     public function test_set_book_homepage()
     {
         $editor = $this->getEditor();
index 77c62fdb500b88352aa5354305d0a1612ae57047..bb011cfc6f5df2d0d59d837be2437859b6635f43 100644 (file)
@@ -9,9 +9,9 @@ use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
 use Illuminate\Support\Str;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
 
-class EntityPermissionsTest extends BrowserKitTest
+class EntityPermissionsTest extends TestCase
 {
     /**
      * @var User
@@ -41,608 +41,598 @@ class EntityPermissionsTest extends BrowserKitTest
 
     public function test_bookshelf_view_restriction()
     {
-        $shelf = Bookshelf::first();
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
 
         $this->actingAs($this->user)
-            ->visit($shelf->getUrl())
-            ->seePageIs($shelf->getUrl());
+            ->get($shelf->getUrl())
+            ->assertStatus(200);
 
         $this->setRestrictionsForTestRoles($shelf, []);
 
-        $this->forceVisit($shelf->getUrl())
-            ->see('Bookshelf not found');
+        $this->followingRedirects()->get($shelf->getUrl())
+            ->assertSee('Bookshelf not found');
 
         $this->setRestrictionsForTestRoles($shelf, ['view']);
 
-        $this->visit($shelf->getUrl())
-            ->see($shelf->name);
+        $this->get($shelf->getUrl())
+            ->assertSee($shelf->name);
     }
 
     public function test_bookshelf_update_restriction()
     {
-        $shelf = Bookshelf::first();
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
 
         $this->actingAs($this->user)
-            ->visit($shelf->getUrl('/edit'))
-            ->see('Edit Book');
+            ->get($shelf->getUrl('/edit'))
+            ->assertSee('Edit Book');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'delete']);
 
-        $this->forceVisit($shelf->getUrl('/edit'))
-            ->see('You do not have permission')->seePageIs('/');
+        $resp = $this->get($shelf->getUrl('/edit'))
+            ->assertRedirect('/');
+        $this->followRedirects($resp)->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'update']);
 
-        $this->visit($shelf->getUrl('/edit'))
-            ->seePageIs($shelf->getUrl('/edit'));
+        $this->get($shelf->getUrl('/edit'))
+            ->assertOk();
     }
 
     public function test_bookshelf_delete_restriction()
     {
-        $shelf = Book::first();
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
 
         $this->actingAs($this->user)
-            ->visit($shelf->getUrl('/delete'))
-            ->see('Delete Book');
+            ->get($shelf->getUrl('/delete'))
+            ->assertSee('Delete Book');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'update']);
 
-        $this->forceVisit($shelf->getUrl('/delete'))
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($shelf->getUrl('/delete'))->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'delete']);
 
-        $this->visit($shelf->getUrl('/delete'))
-            ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book');
+        $this->get($shelf->getUrl('/delete'))
+            ->assertOk()
+            ->assertSee('Delete Book');
     }
 
     public function test_book_view_restriction()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $bookPage = $book->pages->first();
         $bookChapter = $book->chapters->first();
 
         $bookUrl = $book->getUrl();
         $this->actingAs($this->user)
-            ->visit($bookUrl)
-            ->seePageIs($bookUrl);
+            ->get($bookUrl)
+            ->assertOk();
 
         $this->setRestrictionsForTestRoles($book, []);
 
-        $this->forceVisit($bookUrl)
-            ->see('Book not found');
-        $this->forceVisit($bookPage->getUrl())
-            ->see('Page not found');
-        $this->forceVisit($bookChapter->getUrl())
-            ->see('Chapter not found');
+        $this->followingRedirects()->get($bookUrl)
+            ->assertSee('Book not found');
+        $this->followingRedirects()->get($bookPage->getUrl())
+            ->assertSee('Page not found');
+        $this->followingRedirects()->get($bookChapter->getUrl())
+            ->assertSee('Chapter not found');
 
         $this->setRestrictionsForTestRoles($book, ['view']);
 
-        $this->visit($bookUrl)
-            ->see($book->name);
-        $this->visit($bookPage->getUrl())
-            ->see($bookPage->name);
-        $this->visit($bookChapter->getUrl())
-            ->see($bookChapter->name);
+        $this->get($bookUrl)
+            ->assertSee($book->name);
+        $this->get($bookPage->getUrl())
+            ->assertSee($bookPage->name);
+        $this->get($bookChapter->getUrl())
+            ->assertSee($bookChapter->name);
     }
 
     public function test_book_create_restriction()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
 
         $bookUrl = $book->getUrl();
         $this->actingAs($this->viewer)
-            ->visit($bookUrl)
-            ->dontSeeInElement('.actions', 'New Page')
-            ->dontSeeInElement('.actions', 'New Chapter');
+            ->get($bookUrl)
+            ->assertElementNotContains('.actions', 'New Page')
+            ->assertElementNotContains('.actions', 'New Chapter');
         $this->actingAs($this->user)
-            ->visit($bookUrl)
-            ->seeInElement('.actions', 'New Page')
-            ->seeInElement('.actions', 'New Chapter');
+            ->get($bookUrl)
+            ->assertElementContains('.actions', 'New Page')
+            ->assertElementContains('.actions', 'New Chapter');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'delete', 'update']);
 
-        $this->forceVisit($bookUrl . '/create-chapter')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookUrl . '/create-page')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->visit($bookUrl)->dontSeeInElement('.actions', 'New Page')
-            ->dontSeeInElement('.actions', 'New Chapter');
+        $this->get($bookUrl . '/create-chapter')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+
+        $this->get($bookUrl . '/create-page')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+
+        $this->get($bookUrl)
+            ->assertElementNotContains('.actions', 'New Page')
+            ->assertElementNotContains('.actions', 'New Chapter');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'create']);
 
-        $this->visit($bookUrl . '/create-chapter')
-            ->type('test chapter', 'name')
-            ->type('test description for chapter', 'description')
-            ->press('Save Chapter')
-            ->seePageIs($bookUrl . '/chapter/test-chapter');
-        $this->visit($bookUrl . '/create-page')
-            ->type('test page', 'name')
-            ->type('test content', 'html')
-            ->press('Save Page')
-            ->seePageIs($bookUrl . '/page/test-page');
-        $this->visit($bookUrl)->seeInElement('.actions', 'New Page')
-            ->seeInElement('.actions', 'New Chapter');
+        $resp = $this->post($book->getUrl('/create-chapter'), [
+            'name'        => 'test chapter',
+            'description' => 'desc',
+        ]);
+        $resp->assertRedirect($book->getUrl('/chapter/test-chapter'));
+
+        $this->get($book->getUrl('/create-page'));
+        /** @var Page $page */
+        $page = Page::query()->where('draft', '=', true)->orderBy('id', 'desc')->first();
+        $resp = $this->post($page->getUrl(), [
+            'name' => 'test page',
+            'html' => 'test content',
+        ]);
+        $resp->assertRedirect($book->getUrl('/page/test-page'));
+
+        $this->get($bookUrl)
+            ->assertElementContains('.actions', 'New Page')
+            ->assertElementContains('.actions', 'New Chapter');
     }
 
     public function test_book_update_restriction()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $bookPage = $book->pages->first();
         $bookChapter = $book->chapters->first();
 
         $bookUrl = $book->getUrl();
         $this->actingAs($this->user)
-            ->visit($bookUrl . '/edit')
-            ->see('Edit Book');
+            ->get($bookUrl . '/edit')
+            ->assertSee('Edit Book');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'delete']);
 
-        $this->forceVisit($bookUrl . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookPage->getUrl() . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookChapter->getUrl() . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($bookUrl . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookPage->getUrl() . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookChapter->getUrl() . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'update']);
 
-        $this->visit($bookUrl . '/edit')
-            ->seePageIs($bookUrl . '/edit');
-        $this->visit($bookPage->getUrl() . '/edit')
-            ->seePageIs($bookPage->getUrl() . '/edit');
-        $this->visit($bookChapter->getUrl() . '/edit')
-            ->see('Edit Chapter');
+        $this->get($bookUrl . '/edit')->assertOk();
+        $this->get($bookPage->getUrl() . '/edit')->assertOk();
+        $this->get($bookChapter->getUrl() . '/edit')->assertSee('Edit Chapter');
     }
 
     public function test_book_delete_restriction()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $bookPage = $book->pages->first();
         $bookChapter = $book->chapters->first();
 
         $bookUrl = $book->getUrl();
-        $this->actingAs($this->user)
-            ->visit($bookUrl . '/delete')
-            ->see('Delete Book');
+        $this->actingAs($this->user)->get($bookUrl . '/delete')
+            ->assertSee('Delete Book');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'update']);
 
-        $this->forceVisit($bookUrl . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookPage->getUrl() . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookChapter->getUrl() . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($bookUrl . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookPage->getUrl() . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookChapter->getUrl() . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'delete']);
 
-        $this->visit($bookUrl . '/delete')
-            ->seePageIs($bookUrl . '/delete')->see('Delete Book');
-        $this->visit($bookPage->getUrl() . '/delete')
-            ->seePageIs($bookPage->getUrl() . '/delete')->see('Delete Page');
-        $this->visit($bookChapter->getUrl() . '/delete')
-            ->see('Delete Chapter');
+        $this->get($bookUrl . '/delete')->assertOk()->assertSee('Delete Book');
+        $this->get($bookPage->getUrl('/delete'))->assertOk()->assertSee('Delete Page');
+        $this->get($bookChapter->getUrl('/delete'))->assertSee('Delete Chapter');
     }
 
     public function test_chapter_view_restriction()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $chapterPage = $chapter->pages->first();
 
         $chapterUrl = $chapter->getUrl();
-        $this->actingAs($this->user)
-            ->visit($chapterUrl)
-            ->seePageIs($chapterUrl);
+        $this->actingAs($this->user)->get($chapterUrl)->assertOk();
 
         $this->setRestrictionsForTestRoles($chapter, []);
 
-        $this->forceVisit($chapterUrl)
-            ->see('Chapter not found');
-        $this->forceVisit($chapterPage->getUrl())
-            ->see('Page not found');
+        $this->followingRedirects()->get($chapterUrl)->assertSee('Chapter not found');
+        $this->followingRedirects()->get($chapterPage->getUrl())->assertSee('Page not found');
 
         $this->setRestrictionsForTestRoles($chapter, ['view']);
 
-        $this->visit($chapterUrl)
-            ->see($chapter->name);
-        $this->visit($chapterPage->getUrl())
-            ->see($chapterPage->name);
+        $this->get($chapterUrl)->assertSee($chapter->name);
+        $this->get($chapterPage->getUrl())->assertSee($chapterPage->name);
     }
 
     public function test_chapter_create_restriction()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
 
         $chapterUrl = $chapter->getUrl();
         $this->actingAs($this->user)
-            ->visit($chapterUrl)
-            ->seeInElement('.actions', 'New Page');
+            ->get($chapterUrl)
+            ->assertElementContains('.actions', 'New Page');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'delete', 'update']);
 
-        $this->forceVisit($chapterUrl . '/create-page')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->visit($chapterUrl)->dontSeeInElement('.actions', 'New Page');
+        $this->get($chapterUrl . '/create-page')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($chapterUrl)->assertElementNotContains('.actions', 'New Page');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'create']);
 
-        $this->visit($chapterUrl . '/create-page')
-            ->type('test page', 'name')
-            ->type('test content', 'html')
-            ->press('Save Page')
-            ->seePageIs($chapter->book->getUrl() . '/page/test-page');
+        $this->get($chapter->getUrl('/create-page'));
+        /** @var Page $page */
+        $page = Page::query()->where('draft', '=', true)->orderBy('id', 'desc')->first();
+        $resp = $this->post($page->getUrl(), [
+            'name' => 'test page',
+            'html' => 'test content',
+        ]);
+        $resp->assertRedirect($chapter->book->getUrl('/page/test-page'));
 
-        $this->visit($chapterUrl)->seeInElement('.actions', 'New Page');
+        $this->get($chapterUrl)->assertElementContains('.actions', 'New Page');
     }
 
     public function test_chapter_update_restriction()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $chapterPage = $chapter->pages->first();
 
         $chapterUrl = $chapter->getUrl();
-        $this->actingAs($this->user)
-            ->visit($chapterUrl . '/edit')
-            ->see('Edit Chapter');
+        $this->actingAs($this->user)->get($chapterUrl . '/edit')
+            ->assertSee('Edit Chapter');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'delete']);
 
-        $this->forceVisit($chapterUrl . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($chapterPage->getUrl() . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($chapterUrl . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($chapterPage->getUrl() . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'update']);
 
-        $this->visit($chapterUrl . '/edit')
-            ->seePageIs($chapterUrl . '/edit')->see('Edit Chapter');
-        $this->visit($chapterPage->getUrl() . '/edit')
-            ->seePageIs($chapterPage->getUrl() . '/edit');
+        $this->get($chapterUrl . '/edit')->assertOk()->assertSee('Edit Chapter');
+        $this->get($chapterPage->getUrl() . '/edit')->assertOk();
     }
 
     public function test_chapter_delete_restriction()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $chapterPage = $chapter->pages->first();
 
         $chapterUrl = $chapter->getUrl();
         $this->actingAs($this->user)
-            ->visit($chapterUrl . '/delete')
-            ->see('Delete Chapter');
+            ->get($chapterUrl . '/delete')
+            ->assertSee('Delete Chapter');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'update']);
 
-        $this->forceVisit($chapterUrl . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($chapterPage->getUrl() . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($chapterUrl . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($chapterPage->getUrl() . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'delete']);
 
-        $this->visit($chapterUrl . '/delete')
-            ->seePageIs($chapterUrl . '/delete')->see('Delete Chapter');
-        $this->visit($chapterPage->getUrl() . '/delete')
-            ->seePageIs($chapterPage->getUrl() . '/delete')->see('Delete Page');
+        $this->get($chapterUrl . '/delete')->assertOk()->assertSee('Delete Chapter');
+        $this->get($chapterPage->getUrl() . '/delete')->assertOk()->assertSee('Delete Page');
     }
 
     public function test_page_view_restriction()
     {
-        $page = Page::first();
+        /** @var Page $page */
+        $page = Page::query()->first();
 
         $pageUrl = $page->getUrl();
-        $this->actingAs($this->user)
-            ->visit($pageUrl)
-            ->seePageIs($pageUrl);
+        $this->actingAs($this->user)->get($pageUrl)->assertOk();
 
         $this->setRestrictionsForTestRoles($page, ['update', 'delete']);
 
-        $this->forceVisit($pageUrl)
-            ->see('Page not found');
+        $this->get($pageUrl)->assertSee('Page not found');
 
         $this->setRestrictionsForTestRoles($page, ['view']);
 
-        $this->visit($pageUrl)
-            ->see($page->name);
+        $this->get($pageUrl)->assertSee($page->name);
     }
 
     public function test_page_update_restriction()
     {
-        $page = Chapter::first();
+        /** @var Page $page */
+        $page = Page::query()->first();
 
         $pageUrl = $page->getUrl();
         $this->actingAs($this->user)
-            ->visit($pageUrl . '/edit')
-            ->seeInField('name', $page->name);
+            ->get($pageUrl . '/edit')
+            ->assertElementExists('input[name="name"][value="' . $page->name . '"]');
 
         $this->setRestrictionsForTestRoles($page, ['view', 'delete']);
 
-        $this->forceVisit($pageUrl . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($pageUrl . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($page, ['view', 'update']);
 
-        $this->visit($pageUrl . '/edit')
-            ->seePageIs($pageUrl . '/edit')->seeInField('name', $page->name);
+        $this->get($pageUrl . '/edit')
+            ->assertOk()
+            ->assertElementExists('input[name="name"][value="' . $page->name . '"]');
     }
 
     public function test_page_delete_restriction()
     {
-        $page = Page::first();
+        /** @var Page $page */
+        $page = Page::query()->first();
 
         $pageUrl = $page->getUrl();
         $this->actingAs($this->user)
-            ->visit($pageUrl . '/delete')
-            ->see('Delete Page');
+            ->get($pageUrl . '/delete')
+            ->assertSee('Delete Page');
 
         $this->setRestrictionsForTestRoles($page, ['view', 'update']);
 
-        $this->forceVisit($pageUrl . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($pageUrl . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($page, ['view', 'delete']);
 
-        $this->visit($pageUrl . '/delete')
-            ->seePageIs($pageUrl . '/delete')->see('Delete Page');
+        $this->get($pageUrl . '/delete')->assertOk()->assertSee('Delete Page');
+    }
+
+    protected function entityRestrictionFormTest(string $model, string $title, string $permission, string $roleId)
+    {
+        /** @var Entity $modelInstance */
+        $modelInstance = $model::query()->first();
+        $this->asAdmin()->get($modelInstance->getUrl('/permissions'))
+            ->assertSee($title);
+
+        $this->put($modelInstance->getUrl('/permissions'), [
+            'restricted'   => 'true',
+            'restrictions' => [
+                $roleId => [
+                    $permission => 'true',
+                ],
+            ],
+        ]);
+
+        $this->assertDatabaseHas($modelInstance->getTable(), ['id' => $modelInstance->id, 'restricted' => true]);
+        $this->assertDatabaseHas('entity_permissions', [
+            'restrictable_id'   => $modelInstance->id,
+            'restrictable_type' => $modelInstance->getMorphClass(),
+            'role_id'           => $roleId,
+            'action'            => $permission,
+        ]);
     }
 
     public function test_bookshelf_restriction_form()
     {
-        $shelf = Bookshelf::first();
-        $this->asAdmin()->visit($shelf->getUrl('/permissions'))
-            ->see('Bookshelf Permissions')
-            ->check('restricted')
-            ->check('restrictions[2][view]')
-            ->press('Save Permissions')
-            ->seeInDatabase('bookshelves', ['id' => $shelf->id, 'restricted' => true])
-            ->seeInDatabase('entity_permissions', [
-                'restrictable_id'   => $shelf->id,
-                'restrictable_type' => Bookshelf::newModelInstance()->getMorphClass(),
-                'role_id'           => '2',
-                'action'            => 'view',
-            ]);
+        $this->entityRestrictionFormTest(Bookshelf::class, 'Bookshelf Permissions', 'view', '2');
     }
 
     public function test_book_restriction_form()
     {
-        $book = Book::first();
-        $this->asAdmin()->visit($book->getUrl() . '/permissions')
-            ->see('Book Permissions')
-            ->check('restricted')
-            ->check('restrictions[2][view]')
-            ->press('Save Permissions')
-            ->seeInDatabase('books', ['id' => $book->id, 'restricted' => true])
-            ->seeInDatabase('entity_permissions', [
-                'restrictable_id'   => $book->id,
-                'restrictable_type' => Book::newModelInstance()->getMorphClass(),
-                'role_id'           => '2',
-                'action'            => 'view',
-            ]);
+        $this->entityRestrictionFormTest(Book::class, 'Book Permissions', 'view', '2');
     }
 
     public function test_chapter_restriction_form()
     {
-        $chapter = Chapter::first();
-        $this->asAdmin()->visit($chapter->getUrl() . '/permissions')
-            ->see('Chapter Permissions')
-            ->check('restricted')
-            ->check('restrictions[2][update]')
-            ->press('Save Permissions')
-            ->seeInDatabase('chapters', ['id' => $chapter->id, 'restricted' => true])
-            ->seeInDatabase('entity_permissions', [
-                'restrictable_id'   => $chapter->id,
-                'restrictable_type' => Chapter::newModelInstance()->getMorphClass(),
-                'role_id'           => '2',
-                'action'            => 'update',
-            ]);
+        $this->entityRestrictionFormTest(Chapter::class, 'Chapter Permissions', 'update', '2');
     }
 
     public function test_page_restriction_form()
     {
-        $page = Page::first();
-        $this->asAdmin()->visit($page->getUrl() . '/permissions')
-            ->see('Page Permissions')
-            ->check('restricted')
-            ->check('restrictions[2][delete]')
-            ->press('Save Permissions')
-            ->seeInDatabase('pages', ['id' => $page->id, 'restricted' => true])
-            ->seeInDatabase('entity_permissions', [
-                'restrictable_id'   => $page->id,
-                'restrictable_type' => Page::newModelInstance()->getMorphClass(),
-                'role_id'           => '2',
-                'action'            => 'delete',
-            ]);
+        $this->entityRestrictionFormTest(Page::class, 'Page Permissions', 'delete', '2');
     }
 
     public function test_restricted_pages_not_visible_in_book_navigation_on_pages()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $page = $chapter->pages->first();
         $page2 = $chapter->pages[2];
 
         $this->setRestrictionsForTestRoles($page, []);
 
         $this->actingAs($this->user)
-            ->visit($page2->getUrl())
-            ->dontSeeInElement('.sidebar-page-list', $page->name);
+            ->get($page2->getUrl())
+            ->assertElementNotContains('.sidebar-page-list', $page->name);
     }
 
     public function test_restricted_pages_not_visible_in_book_navigation_on_chapters()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $page = $chapter->pages->first();
 
         $this->setRestrictionsForTestRoles($page, []);
 
         $this->actingAs($this->user)
-            ->visit($chapter->getUrl())
-            ->dontSeeInElement('.sidebar-page-list', $page->name);
+            ->get($chapter->getUrl())
+            ->assertElementNotContains('.sidebar-page-list', $page->name);
     }
 
     public function test_restricted_pages_not_visible_on_chapter_pages()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $page = $chapter->pages->first();
 
         $this->setRestrictionsForTestRoles($page, []);
 
         $this->actingAs($this->user)
-            ->visit($chapter->getUrl())
-            ->dontSee($page->name);
+            ->get($chapter->getUrl())
+            ->assertDontSee($page->name);
     }
 
     public function test_restricted_chapter_pages_not_visible_on_book_page()
     {
+        /** @var Chapter $chapter */
         $chapter = Chapter::query()->first();
         $this->actingAs($this->user)
-            ->visit($chapter->book->getUrl())
-            ->see($chapter->pages->first()->name);
+            ->get($chapter->book->getUrl())
+            ->assertSee($chapter->pages->first()->name);
 
         foreach ($chapter->pages as $page) {
             $this->setRestrictionsForTestRoles($page, []);
         }
 
         $this->actingAs($this->user)
-            ->visit($chapter->book->getUrl())
-            ->dontSee($chapter->pages->first()->name);
+            ->get($chapter->book->getUrl())
+            ->assertDontSee($chapter->pages->first()->name);
     }
 
     public function test_bookshelf_update_restriction_override()
     {
-        $shelf = Bookshelf::first();
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
 
         $this->actingAs($this->viewer)
-            ->visit($shelf->getUrl('/edit'))
-            ->dontSee('Edit Book');
+            ->get($shelf->getUrl('/edit'))
+            ->assertDontSee('Edit Book');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'delete']);
 
-        $this->forceVisit($shelf->getUrl('/edit'))
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($shelf->getUrl('/edit'))->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'update']);
 
-        $this->visit($shelf->getUrl('/edit'))
-            ->seePageIs($shelf->getUrl('/edit'));
+        $this->get($shelf->getUrl('/edit'))->assertOk();
     }
 
     public function test_bookshelf_delete_restriction_override()
     {
-        $shelf = Bookshelf::first();
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
 
         $this->actingAs($this->viewer)
-            ->visit($shelf->getUrl('/delete'))
-            ->dontSee('Delete Book');
+            ->get($shelf->getUrl('/delete'))
+            ->assertDontSee('Delete Book');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'update']);
 
-        $this->forceVisit($shelf->getUrl('/delete'))
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($shelf->getUrl('/delete'))->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'delete']);
 
-        $this->visit($shelf->getUrl('/delete'))
-            ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book');
+        $this->get($shelf->getUrl('/delete'))->assertOk()->assertSee('Delete Book');
     }
 
     public function test_book_create_restriction_override()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
 
         $bookUrl = $book->getUrl();
         $this->actingAs($this->viewer)
-            ->visit($bookUrl)
-            ->dontSeeInElement('.actions', 'New Page')
-            ->dontSeeInElement('.actions', 'New Chapter');
+            ->get($bookUrl)
+            ->assertElementNotContains('.actions', 'New Page')
+            ->assertElementNotContains('.actions', 'New Chapter');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'delete', 'update']);
 
-        $this->forceVisit($bookUrl . '/create-chapter')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookUrl . '/create-page')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->visit($bookUrl)->dontSeeInElement('.actions', 'New Page')
-            ->dontSeeInElement('.actions', 'New Chapter');
+        $this->get($bookUrl . '/create-chapter')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookUrl . '/create-page')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookUrl)->assertElementNotContains('.actions', 'New Page')
+            ->assertElementNotContains('.actions', 'New Chapter');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'create']);
 
-        $this->visit($bookUrl . '/create-chapter')
-            ->type('test chapter', 'name')
-            ->type('test description for chapter', 'description')
-            ->press('Save Chapter')
-            ->seePageIs($bookUrl . '/chapter/test-chapter');
-        $this->visit($bookUrl . '/create-page')
-            ->type('test page', 'name')
-            ->type('test content', 'html')
-            ->press('Save Page')
-            ->seePageIs($bookUrl . '/page/test-page');
-        $this->visit($bookUrl)->seeInElement('.actions', 'New Page')
-            ->seeInElement('.actions', 'New Chapter');
+        $resp = $this->post($book->getUrl('/create-chapter'), [
+            'name'        => 'test chapter',
+            'description' => 'test desc',
+        ]);
+        $resp->assertRedirect($book->getUrl('/chapter/test-chapter'));
+
+        $this->get($book->getUrl('/create-page'));
+        /** @var Page $page */
+        $page = Page::query()->where('draft', '=', true)->orderByDesc('id')->first();
+        $resp = $this->post($page->getUrl(), [
+            'name' => 'test page',
+            'html' => 'test desc',
+        ]);
+        $resp->assertRedirect($book->getUrl('/page/test-page'));
+
+        $this->get($bookUrl)
+            ->assertElementContains('.actions', 'New Page')
+            ->assertElementContains('.actions', 'New Chapter');
     }
 
     public function test_book_update_restriction_override()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $bookPage = $book->pages->first();
         $bookChapter = $book->chapters->first();
 
         $bookUrl = $book->getUrl();
-        $this->actingAs($this->viewer)
-            ->visit($bookUrl . '/edit')
-            ->dontSee('Edit Book');
+        $this->actingAs($this->viewer)->get($bookUrl . '/edit')
+            ->assertDontSee('Edit Book');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'delete']);
 
-        $this->forceVisit($bookUrl . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookPage->getUrl() . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookChapter->getUrl() . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($bookUrl . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookPage->getUrl() . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookChapter->getUrl() . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'update']);
 
-        $this->visit($bookUrl . '/edit')
-            ->seePageIs($bookUrl . '/edit');
-        $this->visit($bookPage->getUrl() . '/edit')
-            ->seePageIs($bookPage->getUrl() . '/edit');
-        $this->visit($bookChapter->getUrl() . '/edit')
-            ->see('Edit Chapter');
+        $this->get($bookUrl . '/edit')->assertOk();
+        $this->get($bookPage->getUrl() . '/edit')->assertOk();
+        $this->get($bookChapter->getUrl() . '/edit')->assertSee('Edit Chapter');
     }
 
     public function test_book_delete_restriction_override()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $bookPage = $book->pages->first();
         $bookChapter = $book->chapters->first();
 
         $bookUrl = $book->getUrl();
         $this->actingAs($this->viewer)
-            ->visit($bookUrl . '/delete')
-            ->dontSee('Delete Book');
+            ->get($bookUrl . '/delete')
+            ->assertDontSee('Delete Book');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'update']);
 
-        $this->forceVisit($bookUrl . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookPage->getUrl() . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookChapter->getUrl() . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($bookUrl . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookPage->getUrl() . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookChapter->getUrl() . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'delete']);
 
-        $this->visit($bookUrl . '/delete')
-            ->seePageIs($bookUrl . '/delete')->see('Delete Book');
-        $this->visit($bookPage->getUrl() . '/delete')
-            ->seePageIs($bookPage->getUrl() . '/delete')->see('Delete Page');
-        $this->visit($bookChapter->getUrl() . '/delete')
-            ->see('Delete Chapter');
+        $this->get($bookUrl . '/delete')->assertOk()->assertSee('Delete Book');
+        $this->get($bookPage->getUrl() . '/delete')->assertOk()->assertSee('Delete Page');
+        $this->get($bookChapter->getUrl() . '/delete')->assertSee('Delete Chapter');
     }
 
     public function test_page_visible_if_has_permissions_when_book_not_visible()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $bookChapter = $book->chapters->first();
         $bookPage = $bookChapter->pages->first();
 
@@ -655,34 +645,37 @@ class EntityPermissionsTest extends BrowserKitTest
         $this->setRestrictionsForTestRoles($bookPage, ['view']);
 
         $this->actingAs($this->viewer);
-        $this->get($bookPage->getUrl());
-        $this->assertResponseOk();
-        $this->see($bookPage->name);
-        $this->dontSee(substr($book->name, 0, 15));
-        $this->dontSee(substr($bookChapter->name, 0, 15));
+        $resp = $this->get($bookPage->getUrl());
+        $resp->assertOk();
+        $resp->assertSee($bookPage->name);
+        $resp->assertDontSee(substr($book->name, 0, 15));
+        $resp->assertDontSee(substr($bookChapter->name, 0, 15));
     }
 
     public function test_book_sort_view_permission()
     {
-        $firstBook = Book::first();
-        $secondBook = Book::find(2);
+        /** @var Book $firstBook */
+        $firstBook = Book::query()->first();
+        /** @var Book $secondBook */
+        $secondBook = Book::query()->find(2);
 
         $this->setRestrictionsForTestRoles($firstBook, ['view', 'update']);
         $this->setRestrictionsForTestRoles($secondBook, ['view']);
 
         // Test sort page visibility
-        $this->actingAs($this->user)->visit($secondBook->getUrl() . '/sort')
-                ->see('You do not have permission')
-                ->seePageIs('/');
+        $this->actingAs($this->user)->get($secondBook->getUrl('/sort'))->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         // Check sort page on first book
-        $this->actingAs($this->user)->visit($firstBook->getUrl() . '/sort');
+        $this->actingAs($this->user)->get($firstBook->getUrl('/sort'));
     }
 
     public function test_book_sort_permission()
     {
-        $firstBook = Book::first();
-        $secondBook = Book::find(2);
+        /** @var Book $firstBook */
+        $firstBook = Book::query()->first();
+        /** @var Book $secondBook */
+        $secondBook = Book::query()->find(2);
 
         $this->setRestrictionsForTestRoles($firstBook, ['view', 'update']);
         $this->setRestrictionsForTestRoles($secondBook, ['view']);
@@ -703,9 +696,8 @@ class EntityPermissionsTest extends BrowserKitTest
 
         // Move chapter from first book to a second book
         $this->actingAs($this->user)->put($firstBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)])
-                ->followRedirects()
-                ->see('You do not have permission')
-                ->seePageIs('/');
+            ->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $reqData = [
             [
@@ -719,30 +711,30 @@ class EntityPermissionsTest extends BrowserKitTest
 
         // Move chapter from second book to first book
         $this->actingAs($this->user)->put($firstBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)])
-                ->followRedirects()
-                ->see('You do not have permission')
-                ->seePageIs('/');
+                ->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
     }
 
     public function test_can_create_page_if_chapter_has_permissions_when_book_not_visible()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $this->setRestrictionsForTestRoles($book, []);
         $bookChapter = $book->chapters->first();
         $this->setRestrictionsForTestRoles($bookChapter, ['view']);
 
-        $this->actingAs($this->user)->visit($bookChapter->getUrl())
-            ->dontSee('New Page');
+        $this->actingAs($this->user)->get($bookChapter->getUrl())
+            ->assertDontSee('New Page');
 
         $this->setRestrictionsForTestRoles($bookChapter, ['view', 'create']);
 
-        $this->actingAs($this->user)->visit($bookChapter->getUrl())
-            ->click('New Page')
-            ->seeStatusCode(200)
-            ->type('test page', 'name')
-            ->type('test content', 'html')
-            ->press('Save Page')
-            ->seePageIs($book->getUrl('/page/test-page'))
-            ->seeStatusCode(200);
+        $this->get($bookChapter->getUrl('/create-page'));
+        /** @var Page $page */
+        $page = Page::query()->where('draft', '=', true)->orderByDesc('id')->first();
+        $resp = $this->post($page->getUrl(), [
+            'name' => 'test page',
+            'html' => 'test content',
+        ]);
+        $resp->assertRedirect($book->getUrl('/page/test-page'));
     }
 }
index b9b1805b6df213deac96139e63f7e6fdb58d3d08..5248ae1528ffbb509d62c0a2691b9f88aca06c85 100644 (file)
@@ -2,18 +2,20 @@
 
 namespace Tests\Permissions;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Actions\Comment;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
 use BookStack\Uploads\Image;
-use Laravel\BrowserKitTesting\HttpException;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
+use Tests\TestResponse;
 
-class RolesTest extends BrowserKitTest
+class RolesTest extends TestCase
 {
     protected $user;
 
@@ -25,17 +27,17 @@ class RolesTest extends BrowserKitTest
 
     public function test_admin_can_see_settings()
     {
-        $this->asAdmin()->visit('/settings')->see('Settings');
+        $this->asAdmin()->get('/settings')->assertSee('Settings');
     }
 
     public function test_cannot_delete_admin_role()
     {
         $adminRole = Role::getRole('admin');
         $deletePageUrl = '/settings/roles/delete/' . $adminRole->id;
-        $this->asAdmin()->visit($deletePageUrl)
-            ->press('Confirm')
-            ->seePageIs($deletePageUrl)
-            ->see('cannot be deleted');
+
+        $this->asAdmin()->get($deletePageUrl);
+        $this->delete($deletePageUrl)->assertRedirect($deletePageUrl);
+        $this->get($deletePageUrl)->assertSee('cannot be deleted');
     }
 
     public function test_role_cannot_be_deleted_if_default()
@@ -44,10 +46,9 @@ class RolesTest extends BrowserKitTest
         $this->setSettings(['registration-role' => $newRole->id]);
 
         $deletePageUrl = '/settings/roles/delete/' . $newRole->id;
-        $this->asAdmin()->visit($deletePageUrl)
-            ->press('Confirm')
-            ->seePageIs($deletePageUrl)
-            ->see('cannot be deleted');
+        $this->asAdmin()->get($deletePageUrl);
+        $this->delete($deletePageUrl)->assertRedirect($deletePageUrl);
+        $this->get($deletePageUrl)->assertSee('cannot be deleted');
     }
 
     public function test_role_create_update_delete_flow()
@@ -57,68 +58,104 @@ class RolesTest extends BrowserKitTest
         $testRoleUpdateName = 'An Super Updated role';
 
         // Creation
-        $this->asAdmin()->visit('/settings')
-            ->click('Roles')
-            ->seePageIs('/settings/roles')
-            ->click('Create New Role')
-            ->type('Test Role', 'display_name')
-            ->type('A little test description', 'description')
-            ->press('Save Role')
-            ->seeInDatabase('roles', ['display_name' => $testRoleName, 'description' => $testRoleDesc, 'mfa_enforced' => false])
-            ->seePageIs('/settings/roles');
+        $resp = $this->asAdmin()->get('/settings');
+        $resp->assertElementContains('a[href="' . url('/settings/roles') . '"]', 'Roles');
+
+        $resp = $this->get('/settings/roles');
+        $resp->assertElementContains('a[href="' . url('/settings/roles/new') . '"]', 'Create New Role');
+
+        $resp = $this->get('/settings/roles/new');
+        $resp->assertElementContains('form[action="' . url('/settings/roles/new') . '"]', 'Save Role');
+
+        $resp = $this->post('/settings/roles/new', [
+            'display_name' => $testRoleName,
+            'description'  => $testRoleDesc,
+        ]);
+        $resp->assertRedirect('/settings/roles');
+
+        $resp = $this->get('/settings/roles');
+        $resp->assertSee($testRoleName);
+        $resp->assertSee($testRoleDesc);
+        $this->assertDatabaseHas('roles', [
+            'display_name' => $testRoleName,
+            'description'  => $testRoleDesc,
+            'mfa_enforced' => false,
+        ]);
+
+        /** @var Role $role */
+        $role = Role::query()->where('display_name', '=', $testRoleName)->first();
+
         // Updating
-        $this->asAdmin()->visit('/settings/roles')
-            ->see($testRoleDesc)
-            ->click($testRoleName)
-            ->type($testRoleUpdateName, '#display_name')
-            ->check('#mfa_enforced')
-            ->press('Save Role')
-            ->seeInDatabase('roles', ['display_name' => $testRoleUpdateName, 'description' => $testRoleDesc, 'mfa_enforced' => true])
-            ->seePageIs('/settings/roles');
+        $resp = $this->get('/settings/roles/' . $role->id);
+        $resp->assertSee($testRoleName);
+        $resp->assertSee($testRoleDesc);
+        $resp->assertElementContains('form[action="' . url('/settings/roles/' . $role->id) . '"]', 'Save Role');
+
+        $resp = $this->put('/settings/roles/' . $role->id, [
+            'display_name' => $testRoleUpdateName,
+            'description'  => $testRoleDesc,
+            'mfa_enforced' => 'true',
+        ]);
+        $resp->assertRedirect('/settings/roles');
+        $this->assertDatabaseHas('roles', [
+            'display_name' => $testRoleUpdateName,
+            'description'  => $testRoleDesc,
+            'mfa_enforced' => true,
+        ]);
+
         // Deleting
-        $this->asAdmin()->visit('/settings/roles')
-            ->click($testRoleUpdateName)
-            ->click('Delete Role')
-            ->see($testRoleUpdateName)
-            ->press('Confirm')
-            ->seePageIs('/settings/roles')
-            ->dontSee($testRoleUpdateName);
+        $resp = $this->get('/settings/roles/' . $role->id);
+        $resp->assertElementContains('a[href="' . url("/settings/roles/delete/$role->id") . '"]', 'Delete Role');
+
+        $resp = $this->get("/settings/roles/delete/$role->id");
+        $resp->assertSee($testRoleUpdateName);
+        $resp->assertElementContains('form[action="' . url("/settings/roles/delete/$role->id") . '"]', 'Confirm');
+
+        $resp = $this->delete("/settings/roles/delete/$role->id");
+        $resp->assertRedirect('/settings/roles');
+        $this->get('/settings/roles')->assertSee('Role successfully deleted');
+        $this->assertActivityExists(ActivityType::ROLE_DELETE);
     }
 
-    public function test_admin_role_cannot_be_removed_if_last_admin()
+    public function test_admin_role_cannot_be_removed_if_user_last_admin()
     {
-        $adminRole = Role::where('system_name', '=', 'admin')->first();
+        /** @var Role $adminRole */
+        $adminRole = Role::query()->where('system_name', '=', 'admin')->first();
         $adminUser = $this->getAdmin();
         $adminRole->users()->where('id', '!=', $adminUser->id)->delete();
-        $this->assertEquals($adminRole->users()->count(), 1);
+        $this->assertEquals(1, $adminRole->users()->count());
 
         $viewerRole = $this->getViewer()->roles()->first();
 
         $editUrl = '/settings/users/' . $adminUser->id;
-        $this->actingAs($adminUser)->put($editUrl, [
+        $resp = $this->actingAs($adminUser)->put($editUrl, [
             'name'  => $adminUser->name,
             'email' => $adminUser->email,
             'roles' => [
                 'viewer' => strval($viewerRole->id),
             ],
-        ])->followRedirects();
+        ]);
+
+        $resp->assertRedirect($editUrl);
 
-        $this->seePageIs($editUrl);
-        $this->see('This user is the only user assigned to the administrator role');
+        $resp = $this->get($editUrl);
+        $resp->assertSee('This user is the only user assigned to the administrator role');
     }
 
     public function test_migrate_users_on_delete_works()
     {
+        /** @var Role $roleA */
         $roleA = Role::query()->create(['display_name' => 'Delete Test A']);
+        /** @var Role $roleB */
         $roleB = Role::query()->create(['display_name' => 'Delete Test B']);
         $this->user->attachRole($roleB);
 
         $this->assertCount(0, $roleA->users()->get());
         $this->assertCount(1, $roleB->users()->get());
 
-        $deletePage = $this->asAdmin()->get("/settings/roles/delete/{$roleB->id}");
-        $deletePage->seeElement('select[name=migrate_role_id]');
-        $this->asAdmin()->delete("/settings/roles/delete/{$roleB->id}", [
+        $deletePage = $this->asAdmin()->get("/settings/roles/delete/$roleB->id");
+        $deletePage->assertElementExists('select[name=migrate_role_id]');
+        $this->asAdmin()->delete("/settings/roles/delete/$roleB->id", [
             'migrate_role_id' => $roleA->id,
         ]);
 
@@ -128,21 +165,19 @@ class RolesTest extends BrowserKitTest
 
     public function test_manage_user_permission()
     {
-        $this->actingAs($this->user)->visit('/settings/users')
-            ->seePageIs('/');
+        $this->actingAs($this->user)->get('/settings/users')->assertRedirect('/');
         $this->giveUserPermissions($this->user, ['users-manage']);
-        $this->actingAs($this->user)->visit('/settings/users')
-            ->seePageIs('/settings/users');
+        $this->actingAs($this->user)->get('/settings/users')->assertOk();
     }
 
     public function test_manage_users_permission_shows_link_in_header_if_does_not_have_settings_manage_permision()
     {
         $usersLink = 'href="' . url('/settings/users') . '"';
-        $this->actingAs($this->user)->visit('/')->dontSee($usersLink);
+        $this->actingAs($this->user)->get('/')->assertDontSee($usersLink);
         $this->giveUserPermissions($this->user, ['users-manage']);
-        $this->actingAs($this->user)->visit('/')->see($usersLink);
+        $this->actingAs($this->user)->get('/')->assertSee($usersLink);
         $this->giveUserPermissions($this->user, ['settings-manage', 'users-manage']);
-        $this->actingAs($this->user)->visit('/')->dontSee($usersLink);
+        $this->actingAs($this->user)->get('/')->assertDontSee($usersLink);
     }
 
     public function test_user_cannot_change_email_unless_they_have_manage_users_permission()
@@ -151,14 +186,14 @@ class RolesTest extends BrowserKitTest
         $originalEmail = $this->user->email;
         $this->actingAs($this->user);
 
-        $this->visit($userProfileUrl)
-            ->assertResponseOk()
-            ->seeElement('input[name=email][disabled]');
+        $this->get($userProfileUrl)
+            ->assertOk()
+            ->assertElementExists('input[name=email][disabled]');
         $this->put($userProfileUrl, [
             'name'  => 'my_new_name',
             'email' => '[email protected]',
         ]);
-        $this->seeInDatabase('users', [
+        $this->assertDatabaseHas('users', [
             'id'    => $this->user->id,
             'email' => $originalEmail,
             'name'  => 'my_new_name',
@@ -166,16 +201,16 @@ class RolesTest extends BrowserKitTest
 
         $this->giveUserPermissions($this->user, ['users-manage']);
 
-        $this->visit($userProfileUrl)
-            ->assertResponseOk()
-            ->dontSeeElement('input[name=email][disabled]')
-            ->seeElement('input[name=email]');
+        $this->get($userProfileUrl)
+            ->assertOk()
+            ->assertElementNotExists('input[name=email][disabled]')
+            ->assertElementExists('input[name=email]');
         $this->put($userProfileUrl, [
             'name'  => 'my_new_name_2',
             'email' => '[email protected]',
         ]);
 
-        $this->seeInDatabase('users', [
+        $this->assertDatabaseHas('users', [
             'id'    => $this->user->id,
             'email' => '[email protected]',
             'name'  => 'my_new_name_2',
@@ -184,40 +219,47 @@ class RolesTest extends BrowserKitTest
 
     public function test_user_roles_manage_permission()
     {
-        $this->actingAs($this->user)->visit('/settings/roles')
-            ->seePageIs('/')->visit('/settings/roles/1')->seePageIs('/');
+        $this->actingAs($this->user)->get('/settings/roles')->assertRedirect('/');
+        $this->get('/settings/roles/1')->assertRedirect('/');
         $this->giveUserPermissions($this->user, ['user-roles-manage']);
-        $this->actingAs($this->user)->visit('/settings/roles')
-            ->seePageIs('/settings/roles')->click('Admin')
-            ->see('Edit Role');
+        $this->actingAs($this->user)->get('/settings/roles')->assertOk();
+        $this->get('/settings/roles/1')
+            ->assertOk()
+            ->assertSee('Admin');
     }
 
     public function test_settings_manage_permission()
     {
-        $this->actingAs($this->user)->visit('/settings')
-            ->seePageIs('/');
+        $this->actingAs($this->user)->get('/settings')->assertRedirect('/');
         $this->giveUserPermissions($this->user, ['settings-manage']);
-        $this->actingAs($this->user)->visit('/settings')
-            ->seePageIs('/settings')->press('Save Settings')->see('Settings Saved');
+        $this->get('/settings')->assertOk();
+
+        $resp = $this->post('/settings', []);
+        $resp->assertRedirect('/settings');
+        $resp = $this->get('/settings');
+        $resp->assertSee('Settings saved');
     }
 
     public function test_restrictions_manage_all_permission()
     {
-        $page = Page::take(1)->get()->first();
-        $this->actingAs($this->user)->visit($page->getUrl())
-            ->dontSee('Permissions')
-            ->visit($page->getUrl() . '/permissions')
-            ->seePageIs('/');
+        $page = Page::query()->get()->first();
+
+        $this->actingAs($this->user)->get($page->getUrl())->assertDontSee('Permissions');
+        $this->get($page->getUrl('/permissions'))->assertRedirect('/');
+
         $this->giveUserPermissions($this->user, ['restrictions-manage-all']);
-        $this->actingAs($this->user)->visit($page->getUrl())
-            ->see('Permissions')
-            ->click('Permissions')
-            ->see('Page Permissions')->seePageIs($page->getUrl() . '/permissions');
+
+        $this->actingAs($this->user)->get($page->getUrl())->assertSee('Permissions');
+
+        $this->get($page->getUrl('/permissions'))
+            ->assertOk()
+            ->assertSee('Page Permissions');
     }
 
     public function test_restrictions_manage_own_permission()
     {
-        $otherUsersPage = Page::first();
+        /** @var Page $otherUsersPage */
+        $otherUsersPage = Page::query()->first();
         $content = $this->createEntityChainBelongingToUser($this->user);
 
         // Set a different creator on the page we're checking to ensure
@@ -228,57 +270,45 @@ class RolesTest extends BrowserKitTest
         $page->save();
 
         // Check can't restrict other's content
-        $this->actingAs($this->user)->visit($otherUsersPage->getUrl())
-            ->dontSee('Permissions')
-            ->visit($otherUsersPage->getUrl() . '/permissions')
-            ->seePageIs('/');
+        $this->actingAs($this->user)->get($otherUsersPage->getUrl())->assertDontSee('Permissions');
+        $this->get($otherUsersPage->getUrl('/permissions'))->assertRedirect('/');
+
         // Check can't restrict own content
-        $this->actingAs($this->user)->visit($page->getUrl())
-            ->dontSee('Permissions')
-            ->visit($page->getUrl() . '/permissions')
-            ->seePageIs('/');
+        $this->actingAs($this->user)->get($page->getUrl())->assertDontSee('Permissions');
+        $this->get($page->getUrl('/permissions'))->assertRedirect('/');
 
         $this->giveUserPermissions($this->user, ['restrictions-manage-own']);
 
         // Check can't restrict other's content
-        $this->actingAs($this->user)->visit($otherUsersPage->getUrl())
-            ->dontSee('Permissions')
-            ->visit($otherUsersPage->getUrl() . '/permissions')
-            ->seePageIs('/');
+        $this->actingAs($this->user)->get($otherUsersPage->getUrl())->assertDontSee('Permissions');
+        $this->get($otherUsersPage->getUrl('/permissions'))->assertRedirect();
+
         // Check can restrict own content
-        $this->actingAs($this->user)->visit($page->getUrl())
-            ->see('Permissions')
-            ->click('Permissions')
-            ->seePageIs($page->getUrl() . '/permissions');
+        $this->actingAs($this->user)->get($page->getUrl())->assertSee('Permissions');
+        $this->get($page->getUrl('/permissions'))->assertOk();
     }
 
     /**
      * Check a standard entity access permission.
-     *
-     * @param string $permission
-     * @param array  $accessUrls Urls that are only accessible after having the permission
-     * @param array  $visibles   Check this text, In the buttons toolbar, is only visible with the permission
      */
-    private function checkAccessPermission($permission, $accessUrls = [], $visibles = [])
+    private function checkAccessPermission(string $permission, array $accessUrls = [], array $visibles = [])
     {
         foreach ($accessUrls as $url) {
-            $this->actingAs($this->user)->visit($url)
-                ->seePageIs('/');
+            $this->actingAs($this->user)->get($url)->assertRedirect('/');
         }
+
         foreach ($visibles as $url => $text) {
-            $this->actingAs($this->user)->visit($url)
-                ->dontSeeInElement('.action-buttons', $text);
+            $this->actingAs($this->user)->get($url)
+                ->assertElementNotContains('.action-buttons', $text);
         }
 
         $this->giveUserPermissions($this->user, [$permission]);
 
         foreach ($accessUrls as $url) {
-            $this->actingAs($this->user)->visit($url)
-                ->seePageIs($url);
+            $this->actingAs($this->user)->get($url)->assertOk();
         }
         foreach ($visibles as $url => $text) {
-            $this->actingAs($this->user)->visit($url)
-                ->see($text);
+            $this->actingAs($this->user)->get($url)->assertSee($text);
         }
     }
 
@@ -290,16 +320,16 @@ class RolesTest extends BrowserKitTest
             '/shelves' => 'New Shelf',
         ]);
 
-        $this->visit('/create-shelf')
-            ->type('test shelf', 'name')
-            ->type('shelf desc', 'description')
-            ->press('Save Shelf')
-            ->seePageIs('/shelves/test-shelf');
+        $this->post('/shelves', [
+            'name'        => 'test shelf',
+            'description' => 'shelf desc',
+        ])->assertRedirect('/shelves/test-shelf');
     }
 
     public function test_bookshelves_edit_own_permission()
     {
-        $otherShelf = Bookshelf::first();
+        /** @var Bookshelf $otherShelf */
+        $otherShelf = Bookshelf::query()->first();
         $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
         $ownShelf->forceFill(['owned_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
         $this->regenEntityPermissions($ownShelf);
@@ -310,15 +340,14 @@ class RolesTest extends BrowserKitTest
             $ownShelf->getUrl() => 'Edit',
         ]);
 
-        $this->visit($otherShelf->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Edit')
-            ->visit($otherShelf->getUrl('/edit'))
-            ->seePageIs('/');
+        $this->get($otherShelf->getUrl())->assertElementNotContains('.action-buttons', 'Edit');
+        $this->get($otherShelf->getUrl('/edit'))->assertRedirect('/');
     }
 
     public function test_bookshelves_edit_all_permission()
     {
-        $otherShelf = Bookshelf::first();
+        /** @var Bookshelf $otherShelf */
+        $otherShelf = Bookshelf::query()->first();
         $this->checkAccessPermission('bookshelf-update-all', [
             $otherShelf->getUrl('/edit'),
         ], [
@@ -329,7 +358,8 @@ class RolesTest extends BrowserKitTest
     public function test_bookshelves_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['bookshelf-update-all']);
-        $otherShelf = Bookshelf::first();
+        /** @var Bookshelf $otherShelf */
+        $otherShelf = Bookshelf::query()->first();
         $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
         $ownShelf->forceFill(['owned_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
         $this->regenEntityPermissions($ownShelf);
@@ -340,30 +370,27 @@ class RolesTest extends BrowserKitTest
             $ownShelf->getUrl() => 'Delete',
         ]);
 
-        $this->visit($otherShelf->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Delete')
-            ->visit($otherShelf->getUrl('/delete'))
-            ->seePageIs('/');
-        $this->visit($ownShelf->getUrl())->visit($ownShelf->getUrl('/delete'))
-            ->press('Confirm')
-            ->seePageIs('/shelves')
-            ->dontSee($ownShelf->name);
+        $this->get($otherShelf->getUrl())->assertElementNotContains('.action-buttons', 'Delete');
+        $this->get($otherShelf->getUrl('/delete'))->assertRedirect('/');
+
+        $this->get($ownShelf->getUrl());
+        $this->delete($ownShelf->getUrl())->assertRedirect('/shelves');
+        $this->get('/shelves')->assertDontSee($ownShelf->name);
     }
 
     public function test_bookshelves_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['bookshelf-update-all']);
-        $otherShelf = Bookshelf::first();
+        /** @var Bookshelf $otherShelf */
+        $otherShelf = Bookshelf::query()->first();
         $this->checkAccessPermission('bookshelf-delete-all', [
             $otherShelf->getUrl('/delete'),
         ], [
             $otherShelf->getUrl() => 'Delete',
         ]);
 
-        $this->visit($otherShelf->getUrl())->visit($otherShelf->getUrl('/delete'))
-            ->press('Confirm')
-            ->seePageIs('/shelves')
-            ->dontSee($otherShelf->name);
+        $this->delete($otherShelf->getUrl())->assertRedirect('/shelves');
+        $this->get('/shelves')->assertDontSee($otherShelf->name);
     }
 
     public function test_books_create_all_permissions()
@@ -374,16 +401,16 @@ class RolesTest extends BrowserKitTest
             '/books' => 'Create New Book',
         ]);
 
-        $this->visit('/create-book')
-            ->type('test book', 'name')
-            ->type('book desc', 'description')
-            ->press('Save Book')
-            ->seePageIs('/books/test-book');
+        $this->post('/books', [
+            'name'        => 'test book',
+            'description' => 'book desc',
+        ])->assertRedirect('/books/test-book');
     }
 
     public function test_books_edit_own_permission()
     {
-        $otherBook = Book::take(1)->get()->first();
+        /** @var Book $otherBook */
+        $otherBook = Book::query()->take(1)->get()->first();
         $ownBook = $this->createEntityChainBelongingToUser($this->user)['book'];
         $this->checkAccessPermission('book-update-own', [
             $ownBook->getUrl() . '/edit',
@@ -391,15 +418,14 @@ class RolesTest extends BrowserKitTest
             $ownBook->getUrl() => 'Edit',
         ]);
 
-        $this->visit($otherBook->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Edit')
-            ->visit($otherBook->getUrl() . '/edit')
-            ->seePageIs('/');
+        $this->get($otherBook->getUrl())->assertElementNotContains('.action-buttons', 'Edit');
+        $this->get($otherBook->getUrl('/edit'))->assertRedirect('/');
     }
 
     public function test_books_edit_all_permission()
     {
-        $otherBook = Book::take(1)->get()->first();
+        /** @var Book $otherBook */
+        $otherBook = Book::query()->take(1)->get()->first();
         $this->checkAccessPermission('book-update-all', [
             $otherBook->getUrl() . '/edit',
         ], [
@@ -410,7 +436,8 @@ class RolesTest extends BrowserKitTest
     public function test_books_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['book-update-all']);
-        $otherBook = Book::take(1)->get()->first();
+        /** @var Book $otherBook */
+        $otherBook = Book::query()->take(1)->get()->first();
         $ownBook = $this->createEntityChainBelongingToUser($this->user)['book'];
         $this->checkAccessPermission('book-delete-own', [
             $ownBook->getUrl() . '/delete',
@@ -418,35 +445,33 @@ class RolesTest extends BrowserKitTest
             $ownBook->getUrl() => 'Delete',
         ]);
 
-        $this->visit($otherBook->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Delete')
-            ->visit($otherBook->getUrl() . '/delete')
-            ->seePageIs('/');
-        $this->visit($ownBook->getUrl())->visit($ownBook->getUrl() . '/delete')
-            ->press('Confirm')
-            ->seePageIs('/books')
-            ->dontSee($ownBook->name);
+        $this->get($otherBook->getUrl())->assertElementNotContains('.action-buttons', 'Delete');
+        $this->get($otherBook->getUrl('/delete'))->assertRedirect('/');
+        $this->get($ownBook->getUrl());
+        $this->delete($ownBook->getUrl())->assertRedirect('/books');
+        $this->get('/books')->assertDontSee($ownBook->name);
     }
 
     public function test_books_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['book-update-all']);
-        $otherBook = Book::take(1)->get()->first();
+        /** @var Book $otherBook */
+        $otherBook = Book::query()->take(1)->get()->first();
         $this->checkAccessPermission('book-delete-all', [
             $otherBook->getUrl() . '/delete',
         ], [
             $otherBook->getUrl() => 'Delete',
         ]);
 
-        $this->visit($otherBook->getUrl())->visit($otherBook->getUrl() . '/delete')
-            ->press('Confirm')
-            ->seePageIs('/books')
-            ->dontSee($otherBook->name);
+        $this->get($otherBook->getUrl());
+        $this->delete($otherBook->getUrl())->assertRedirect('/books');
+        $this->get('/books')->assertDontSee($otherBook->name);
     }
 
     public function test_chapter_create_own_permissions()
     {
-        $book = Book::take(1)->get()->first();
+        /** @var Book $book */
+        $book = Book::query()->take(1)->get()->first();
         $ownBook = $this->createEntityChainBelongingToUser($this->user)['book'];
         $this->checkAccessPermission('chapter-create-own', [
             $ownBook->getUrl('/create-chapter'),
@@ -454,37 +479,35 @@ class RolesTest extends BrowserKitTest
             $ownBook->getUrl() => 'New Chapter',
         ]);
 
-        $this->visit($ownBook->getUrl('/create-chapter'))
-            ->type('test chapter', 'name')
-            ->type('chapter desc', 'description')
-            ->press('Save Chapter')
-            ->seePageIs($ownBook->getUrl('/chapter/test-chapter'));
+        $this->post($ownBook->getUrl('/create-chapter'), [
+            'name'        => 'test chapter',
+            'description' => 'chapter desc',
+        ])->assertRedirect($ownBook->getUrl('/chapter/test-chapter'));
 
-        $this->visit($book->getUrl())
-            ->dontSeeInElement('.action-buttons', 'New Chapter')
-            ->visit($book->getUrl('/create-chapter'))
-            ->seePageIs('/');
+        $this->get($book->getUrl())->assertElementNotContains('.action-buttons', 'New Chapter');
+        $this->get($book->getUrl('/create-chapter'))->assertRedirect('/');
     }
 
     public function test_chapter_create_all_permissions()
     {
-        $book = Book::take(1)->get()->first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $this->checkAccessPermission('chapter-create-all', [
             $book->getUrl('/create-chapter'),
         ], [
             $book->getUrl() => 'New Chapter',
         ]);
 
-        $this->visit($book->getUrl('/create-chapter'))
-            ->type('test chapter', 'name')
-            ->type('chapter desc', 'description')
-            ->press('Save Chapter')
-            ->seePageIs($book->getUrl('/chapter/test-chapter'));
+        $this->post($book->getUrl('/create-chapter'), [
+            'name'        => 'test chapter',
+            'description' => 'chapter desc',
+        ])->assertRedirect($book->getUrl('/chapter/test-chapter'));
     }
 
     public function test_chapter_edit_own_permission()
     {
-        $otherChapter = Chapter::take(1)->get()->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->first();
         $ownChapter = $this->createEntityChainBelongingToUser($this->user)['chapter'];
         $this->checkAccessPermission('chapter-update-own', [
             $ownChapter->getUrl() . '/edit',
@@ -492,15 +515,14 @@ class RolesTest extends BrowserKitTest
             $ownChapter->getUrl() => 'Edit',
         ]);
 
-        $this->visit($otherChapter->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Edit')
-            ->visit($otherChapter->getUrl() . '/edit')
-            ->seePageIs('/');
+        $this->get($otherChapter->getUrl())->assertElementNotContains('.action-buttons', 'Edit');
+        $this->get($otherChapter->getUrl('/edit'))->assertRedirect('/');
     }
 
     public function test_chapter_edit_all_permission()
     {
-        $otherChapter = Chapter::take(1)->get()->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->take(1)->get()->first();
         $this->checkAccessPermission('chapter-update-all', [
             $otherChapter->getUrl() . '/edit',
         ], [
@@ -511,7 +533,8 @@ class RolesTest extends BrowserKitTest
     public function test_chapter_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['chapter-update-all']);
-        $otherChapter = Chapter::take(1)->get()->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->first();
         $ownChapter = $this->createEntityChainBelongingToUser($this->user)['chapter'];
         $this->checkAccessPermission('chapter-delete-own', [
             $ownChapter->getUrl() . '/delete',
@@ -520,20 +543,18 @@ class RolesTest extends BrowserKitTest
         ]);
 
         $bookUrl = $ownChapter->book->getUrl();
-        $this->visit($otherChapter->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Delete')
-            ->visit($otherChapter->getUrl() . '/delete')
-            ->seePageIs('/');
-        $this->visit($ownChapter->getUrl())->visit($ownChapter->getUrl() . '/delete')
-            ->press('Confirm')
-            ->seePageIs($bookUrl)
-            ->dontSeeInElement('.book-content', $ownChapter->name);
+        $this->get($otherChapter->getUrl())->assertElementNotContains('.action-buttons', 'Delete');
+        $this->get($otherChapter->getUrl('/delete'))->assertRedirect('/');
+        $this->get($ownChapter->getUrl());
+        $this->delete($ownChapter->getUrl())->assertRedirect($bookUrl);
+        $this->get($bookUrl)->assertElementNotContains('.book-content', $ownChapter->name);
     }
 
     public function test_chapter_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['chapter-update-all']);
-        $otherChapter = Chapter::take(1)->get()->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->first();
         $this->checkAccessPermission('chapter-delete-all', [
             $otherChapter->getUrl() . '/delete',
         ], [
@@ -541,16 +562,17 @@ class RolesTest extends BrowserKitTest
         ]);
 
         $bookUrl = $otherChapter->book->getUrl();
-        $this->visit($otherChapter->getUrl())->visit($otherChapter->getUrl() . '/delete')
-            ->press('Confirm')
-            ->seePageIs($bookUrl)
-            ->dontSeeInElement('.book-content', $otherChapter->name);
+        $this->get($otherChapter->getUrl());
+        $this->delete($otherChapter->getUrl())->assertRedirect($bookUrl);
+        $this->get($bookUrl)->assertElementNotContains('.book-content', $otherChapter->name);
     }
 
     public function test_page_create_own_permissions()
     {
-        $book = Book::first();
-        $chapter = Chapter::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
 
         $entities = $this->createEntityChainBelongingToUser($this->user);
         $ownBook = $entities['book'];
@@ -561,8 +583,7 @@ class RolesTest extends BrowserKitTest
         $accessUrls = [$createUrl, $createUrlChapter];
 
         foreach ($accessUrls as $url) {
-            $this->actingAs($this->user)->visit($url)
-                ->seePageIs('/');
+            $this->actingAs($this->user)->get($url)->assertRedirect('/');
         }
 
         $this->checkAccessPermission('page-create-own', [], [
@@ -573,40 +594,39 @@ class RolesTest extends BrowserKitTest
         $this->giveUserPermissions($this->user, ['page-create-own']);
 
         foreach ($accessUrls as $index => $url) {
-            $this->actingAs($this->user)->visit($url);
-            $expectedUrl = Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
-            $this->seePageIs($expectedUrl);
+            $resp = $this->actingAs($this->user)->get($url);
+            $expectedUrl = Page::query()->where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
+            $resp->assertRedirect($expectedUrl);
         }
 
-        $this->visit($createUrl)
-            ->type('test page', 'name')
-            ->type('page desc', 'html')
-            ->press('Save Page')
-            ->seePageIs($ownBook->getUrl('/page/test-page'));
+        $this->get($createUrl);
+        /** @var Page $draft */
+        $draft = Page::query()->where('draft', '=', true)->orderBy('id', 'desc')->first();
+        $this->post($draft->getUrl(), [
+            'name' => 'test page',
+            'html' => 'page desc',
+        ])->assertRedirect($ownBook->getUrl('/page/test-page'));
+
+        $this->get($book->getUrl())->assertElementNotContains('.action-buttons', 'New Page');
+        $this->get($book->getUrl('/create-page'))->assertRedirect('/');
 
-        $this->visit($book->getUrl())
-            ->dontSeeInElement('.action-buttons', 'New Page')
-            ->visit($book->getUrl() . '/create-page')
-            ->seePageIs('/');
-        $this->visit($chapter->getUrl())
-            ->dontSeeInElement('.action-buttons', 'New Page')
-            ->visit($chapter->getUrl() . '/create-page')
-            ->seePageIs('/');
+        $this->get($chapter->getUrl())->assertElementNotContains('.action-buttons', 'New Page');
+        $this->get($chapter->getUrl('/create-page'))->assertRedirect('/');
     }
 
     public function test_page_create_all_permissions()
     {
-        $book = Book::take(1)->get()->first();
-        $chapter = Chapter::take(1)->get()->first();
-        $baseUrl = $book->getUrl() . '/page';
+        /** @var Book $book */
+        $book = Book::query()->first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $createUrl = $book->getUrl('/create-page');
 
         $createUrlChapter = $chapter->getUrl('/create-page');
         $accessUrls = [$createUrl, $createUrlChapter];
 
         foreach ($accessUrls as $url) {
-            $this->actingAs($this->user)->visit($url)
-                ->seePageIs('/');
+            $this->actingAs($this->user)->get($url)->assertRedirect('/');
         }
 
         $this->checkAccessPermission('page-create-all', [], [
@@ -617,27 +637,32 @@ class RolesTest extends BrowserKitTest
         $this->giveUserPermissions($this->user, ['page-create-all']);
 
         foreach ($accessUrls as $index => $url) {
-            $this->actingAs($this->user)->visit($url);
-            $expectedUrl = Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
-            $this->seePageIs($expectedUrl);
+            $resp = $this->actingAs($this->user)->get($url);
+            $expectedUrl = Page::query()->where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
+            $resp->assertRedirect($expectedUrl);
         }
 
-        $this->visit($createUrl)
-            ->type('test page', 'name')
-            ->type('page desc', 'html')
-            ->press('Save Page')
-            ->seePageIs($book->getUrl('/page/test-page'));
+        $this->get($createUrl);
+        /** @var Page $draft */
+        $draft = Page::query()->where('draft', '=', true)->orderByDesc('id')->first();
+        $this->post($draft->getUrl(), [
+            'name' => 'test page',
+            'html' => 'page desc',
+        ])->assertRedirect($book->getUrl('/page/test-page'));
 
-        $this->visit($chapter->getUrl('/create-page'))
-            ->type('new test page', 'name')
-            ->type('page desc', 'html')
-            ->press('Save Page')
-            ->seePageIs($book->getUrl('/page/new-test-page'));
+        $this->get($chapter->getUrl('/create-page'));
+        /** @var Page $draft */
+        $draft = Page::query()->where('draft', '=', true)->orderByDesc('id')->first();
+        $this->post($draft->getUrl(), [
+            'name' => 'new test page',
+            'html' => 'page desc',
+        ])->assertRedirect($book->getUrl('/page/new-test-page'));
     }
 
     public function test_page_edit_own_permission()
     {
-        $otherPage = Page::take(1)->get()->first();
+        /** @var Page $otherPage */
+        $otherPage = Page::query()->first();
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
         $this->checkAccessPermission('page-update-own', [
             $ownPage->getUrl() . '/edit',
@@ -645,17 +670,16 @@ class RolesTest extends BrowserKitTest
             $ownPage->getUrl() => 'Edit',
         ]);
 
-        $this->visit($otherPage->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Edit')
-            ->visit($otherPage->getUrl() . '/edit')
-            ->seePageIs('/');
+        $this->get($otherPage->getUrl())->assertElementNotContains('.action-buttons', 'Edit');
+        $this->get($otherPage->getUrl() . '/edit')->assertRedirect('/');
     }
 
     public function test_page_edit_all_permission()
     {
-        $otherPage = Page::take(1)->get()->first();
+        /** @var Page $otherPage */
+        $otherPage = Page::query()->first();
         $this->checkAccessPermission('page-update-all', [
-            $otherPage->getUrl() . '/edit',
+            $otherPage->getUrl('/edit'),
         ], [
             $otherPage->getUrl() => 'Edit',
         ]);
@@ -664,7 +688,8 @@ class RolesTest extends BrowserKitTest
     public function test_page_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['page-update-all']);
-        $otherPage = Page::take(1)->get()->first();
+        /** @var Page $otherPage */
+        $otherPage = Page::query()->first();
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
         $this->checkAccessPermission('page-delete-own', [
             $ownPage->getUrl() . '/delete',
@@ -673,122 +698,127 @@ class RolesTest extends BrowserKitTest
         ]);
 
         $parent = $ownPage->chapter ?? $ownPage->book;
-        $this->visit($otherPage->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Delete')
-            ->visit($otherPage->getUrl() . '/delete')
-            ->seePageIs('/');
-        $this->visit($ownPage->getUrl())->visit($ownPage->getUrl() . '/delete')
-            ->press('Confirm')
-            ->seePageIs($parent->getUrl())
-            ->dontSeeInElement('.book-content', $ownPage->name);
+        $this->get($otherPage->getUrl())->assertElementNotContains('.action-buttons', 'Delete');
+        $this->get($otherPage->getUrl('/delete'))->assertRedirect('/');
+        $this->get($ownPage->getUrl());
+        $this->delete($ownPage->getUrl())->assertRedirect($parent->getUrl());
+        $this->get($parent->getUrl())->assertElementNotContains('.book-content', $ownPage->name);
     }
 
     public function test_page_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['page-update-all']);
-        $otherPage = Page::take(1)->get()->first();
+        /** @var Page $otherPage */
+        $otherPage = Page::query()->first();
+
         $this->checkAccessPermission('page-delete-all', [
             $otherPage->getUrl() . '/delete',
         ], [
             $otherPage->getUrl() => 'Delete',
         ]);
 
+        /** @var Entity $parent */
         $parent = $otherPage->chapter ?? $otherPage->book;
-        $this->visit($otherPage->getUrl())->visit($otherPage->getUrl() . '/delete')
-            ->press('Confirm')
-            ->seePageIs($parent->getUrl())
-            ->dontSeeInElement('.book-content', $otherPage->name);
+        $this->get($otherPage->getUrl());
+
+        $this->delete($otherPage->getUrl())->assertRedirect($parent->getUrl());
+        $this->get($parent->getUrl())->assertDontSee($otherPage->name);
     }
 
     public function test_public_role_visible_in_user_edit_screen()
     {
-        $user = User::first();
+        /** @var User $user */
+        $user = User::query()->first();
         $adminRole = Role::getSystemRole('admin');
         $publicRole = Role::getSystemRole('public');
-        $this->asAdmin()->visit('/settings/users/' . $user->id)
-            ->seeElement('[name="roles[' . $adminRole->id . ']"]')
-            ->seeElement('[name="roles[' . $publicRole->id . ']"]');
+        $this->asAdmin()->get('/settings/users/' . $user->id)
+            ->assertElementExists('[name="roles[' . $adminRole->id . ']"]')
+            ->assertElementExists('[name="roles[' . $publicRole->id . ']"]');
     }
 
     public function test_public_role_visible_in_role_listing()
     {
-        $this->asAdmin()->visit('/settings/roles')
-            ->see('Admin')
-            ->see('Public');
+        $this->asAdmin()->get('/settings/roles')
+            ->assertSee('Admin')
+            ->assertSee('Public');
     }
 
     public function test_public_role_visible_in_default_role_setting()
     {
-        $this->asAdmin()->visit('/settings')
-            ->seeElement('[data-system-role-name="admin"]')
-            ->seeElement('[data-system-role-name="public"]');
+        $this->asAdmin()->get('/settings')
+            ->assertElementExists('[data-system-role-name="admin"]')
+            ->assertElementExists('[data-system-role-name="public"]');
     }
 
-    public function test_public_role_not_deleteable()
+    public function test_public_role_not_deletable()
     {
-        $this->asAdmin()->visit('/settings/roles')
-            ->click('Public')
-            ->see('Edit Role')
-            ->click('Delete Role')
-            ->press('Confirm')
-            ->see('Delete Role')
-            ->see('Cannot be deleted');
+        /** @var Role $publicRole */
+        $publicRole = Role::getSystemRole('public');
+        $resp = $this->asAdmin()->delete('/settings/roles/delete/' . $publicRole->id);
+        $resp->assertRedirect('/');
+
+        $this->get('/settings/roles/delete/' . $publicRole->id);
+        $resp = $this->delete('/settings/roles/delete/' . $publicRole->id);
+        $resp->assertRedirect('/settings/roles/delete/' . $publicRole->id);
+        $resp = $this->get('/settings/roles/delete/' . $publicRole->id);
+        $resp->assertSee('This role is a system role and cannot be deleted');
     }
 
     public function test_image_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['image-update-all']);
-        $page = Page::first();
-        $image = factory(Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $this->user->id, 'updated_by' => $this->user->id]);
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $image = factory(Image::class)->create([
+            'uploaded_to' => $page->id,
+            'created_by'  => $this->user->id,
+            'updated_by'  => $this->user->id,
+        ]);
 
-        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)
-            ->seeStatusCode(403);
+        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['image-delete-own']);
 
-        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)
-            ->seeStatusCode(200)
-            ->dontSeeInDatabase('images', ['id' => $image->id]);
+        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertOk();
+        $this->assertDatabaseMissing('images', ['id' => $image->id]);
     }
 
     public function test_image_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['image-update-all']);
         $admin = $this->getAdmin();
-        $page = Page::first();
+        /** @var Page $page */
+        $page = Page::query()->first();
         $image = factory(Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $admin->id, 'updated_by' => $admin->id]);
 
-        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)
-            ->seeStatusCode(403);
+        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['image-delete-own']);
 
-        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)
-            ->seeStatusCode(403);
+        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['image-delete-all']);
 
-        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)
-            ->seeStatusCode(200)
-            ->dontSeeInDatabase('images', ['id' => $image->id]);
+        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertOk();
+        $this->assertDatabaseMissing('images', ['id' => $image->id]);
     }
 
     public function test_role_permission_removal()
     {
         // To cover issue fixed in f99c8ff99aee9beb8c692f36d4b84dc6e651e50a.
-        $page = Page::first();
+        /** @var Page $page */
+        $page = Page::query()->first();
         $viewerRole = Role::getRole('viewer');
         $viewer = $this->getViewer();
-        $this->actingAs($viewer)->visit($page->getUrl())->assertResponseStatus(200);
+        $this->actingAs($viewer)->get($page->getUrl())->assertOk();
 
         $this->asAdmin()->put('/settings/roles/' . $viewerRole->id, [
             'display_name' => $viewerRole->display_name,
             'description'  => $viewerRole->description,
             'permission'   => [],
-        ])->assertResponseStatus(302);
+        ])->assertStatus(302);
 
-        $this->expectException(HttpException::class);
-        $this->actingAs($viewer)->visit($page->getUrl())->assertResponseStatus(404);
+        $this->actingAs($viewer)->get($page->getUrl())->assertStatus(404);
     }
 
     public function test_empty_state_actions_not_visible_without_permission()
@@ -796,130 +826,120 @@ class RolesTest extends BrowserKitTest
         $admin = $this->getAdmin();
         // Book links
         $book = factory(Book::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id]);
-        $this->updateEntityPermissions($book);
-        $this->actingAs($this->getViewer())->visit($book->getUrl())
-            ->dontSee('Create a new page')
-            ->dontSee('Add a chapter');
+        $this->regenEntityPermissions($book);
+        $this->actingAs($this->getViewer())->get($book->getUrl())
+            ->assertDontSee('Create a new page')
+            ->assertDontSee('Add a chapter');
 
         // Chapter links
         $chapter = factory(Chapter::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id, 'book_id' => $book->id]);
-        $this->updateEntityPermissions($chapter);
-        $this->actingAs($this->getViewer())->visit($chapter->getUrl())
-            ->dontSee('Create a new page')
-            ->dontSee('Sort the current book');
+        $this->regenEntityPermissions($chapter);
+        $this->actingAs($this->getViewer())->get($chapter->getUrl())
+            ->assertDontSee('Create a new page')
+            ->assertDontSee('Sort the current book');
     }
 
     public function test_comment_create_permission()
     {
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
 
-        $this->actingAs($this->user)->addComment($ownPage);
-
-        $this->assertResponseStatus(403);
+        $this->actingAs($this->user)
+            ->addComment($ownPage)
+            ->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['comment-create-all']);
 
-        $this->actingAs($this->user)->addComment($ownPage);
-        $this->assertResponseStatus(200);
+        $this->actingAs($this->user)
+            ->addComment($ownPage)
+            ->assertOk();
     }
 
     public function test_comment_update_own_permission()
     {
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
         $this->giveUserPermissions($this->user, ['comment-create-all']);
-        $commentId = $this->actingAs($this->user)->addComment($ownPage);
+        $this->actingAs($this->user)->addComment($ownPage);
+        /** @var Comment $comment */
+        $comment = $ownPage->comments()->latest()->first();
 
         // no comment-update-own
-        $this->actingAs($this->user)->updateComment($commentId);
-        $this->assertResponseStatus(403);
+        $this->actingAs($this->user)->updateComment($comment)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['comment-update-own']);
 
         // now has comment-update-own
-        $this->actingAs($this->user)->updateComment($commentId);
-        $this->assertResponseStatus(200);
+        $this->actingAs($this->user)->updateComment($comment)->assertOk();
     }
 
     public function test_comment_update_all_permission()
     {
+        /** @var Page $ownPage */
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
-        $commentId = $this->asAdmin()->addComment($ownPage);
+        $this->asAdmin()->addComment($ownPage);
+        /** @var Comment $comment */
+        $comment = $ownPage->comments()->latest()->first();
 
         // no comment-update-all
-        $this->actingAs($this->user)->updateComment($commentId);
-        $this->assertResponseStatus(403);
+        $this->actingAs($this->user)->updateComment($comment)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['comment-update-all']);
 
         // now has comment-update-all
-        $this->actingAs($this->user)->updateComment($commentId);
-        $this->assertResponseStatus(200);
+        $this->actingAs($this->user)->updateComment($comment)->assertOk();
     }
 
     public function test_comment_delete_own_permission()
     {
+        /** @var Page $ownPage */
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
         $this->giveUserPermissions($this->user, ['comment-create-all']);
-        $commentId = $this->actingAs($this->user)->addComment($ownPage);
+        $this->actingAs($this->user)->addComment($ownPage);
+
+        /** @var Comment $comment */
+        $comment = $ownPage->comments()->latest()->first();
 
         // no comment-delete-own
-        $this->actingAs($this->user)->deleteComment($commentId);
-        $this->assertResponseStatus(403);
+        $this->actingAs($this->user)->deleteComment($comment)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['comment-delete-own']);
 
         // now has comment-update-own
-        $this->actingAs($this->user)->deleteComment($commentId);
-        $this->assertResponseStatus(200);
+        $this->actingAs($this->user)->deleteComment($comment)->assertOk();
     }
 
     public function test_comment_delete_all_permission()
     {
+        /** @var Page $ownPage */
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
-        $commentId = $this->asAdmin()->addComment($ownPage);
+        $this->asAdmin()->addComment($ownPage);
+        /** @var Comment $comment */
+        $comment = $ownPage->comments()->latest()->first();
 
         // no comment-delete-all
-        $this->actingAs($this->user)->deleteComment($commentId);
-        $this->assertResponseStatus(403);
+        $this->actingAs($this->user)->deleteComment($comment)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['comment-delete-all']);
 
         // now has comment-delete-all
-        $this->actingAs($this->user)->deleteComment($commentId);
-        $this->assertResponseStatus(200);
+        $this->actingAs($this->user)->deleteComment($comment)->assertOk();
     }
 
-    private function addComment($page)
+    private function addComment(Page $page): TestResponse
     {
         $comment = factory(Comment::class)->make();
-        $url = "/comment/$page->id";
-        $request = [
-            'text' => $comment->text,
-            'html' => $comment->html,
-        ];
-
-        $this->postJson($url, $request);
-        $comment = $page->comments()->first();
 
-        return $comment === null ? null : $comment->id;
+        return $this->postJson("/comment/$page->id", $comment->only('text', 'html'));
     }
 
-    private function updateComment($commentId)
+    private function updateComment(Comment $comment): TestResponse
     {
-        $comment = factory(Comment::class)->make();
-        $url = "/comment/$commentId";
-        $request = [
-            'text' => $comment->text,
-            'html' => $comment->html,
-        ];
+        $commentData = factory(Comment::class)->make();
 
-        return $this->putJson($url, $request);
+        return $this->putJson("/comment/{$comment->id}", $commentData->only('text', 'html'));
     }
 
-    private function deleteComment($commentId)
+    private function deleteComment(Comment $comment): TestResponse
     {
-        $url = '/comment/' . $commentId;
-
-        return $this->json('DELETE', $url);
+        return $this->json('DELETE', '/comment/' . $comment->id);
     }
 }
index ae0c0ff95c6d454f649f0fe535b374a775a4d459..499c0c9f9710ab0bbd4c6f7401743c325dc2c6be 100644 (file)
@@ -2,7 +2,6 @@
 
 namespace Tests;
 
-use Auth;
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\Role;
@@ -10,6 +9,7 @@ use BookStack\Auth\User;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
+use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\View;
 
 class PublicActionTest extends TestCase
index 1c54452124fbb088631edb961931739377244270..f3e30c0d07d442f894347f7599e97e3e1c9218fc 100644 (file)
@@ -8,8 +8,8 @@ use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Deletion;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
-use DB;
 use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\DB;
 
 class RecycleBinTest extends TestCase
 {
index 888dac8106af4b8088ecf2f28fb8d82bd96d12f6..10551fc55a8d6dd90d7bf0f66ad430a8e9aee50c 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace Tests;
 
-use Illuminate\Support\Str;
+use BookStack\Util\CspService;
 
 class SecurityHeaderTest extends TestCase
 {
@@ -44,26 +44,99 @@ class SecurityHeaderTest extends TestCase
     public function test_iframe_csp_self_only_by_default()
     {
         $resp = $this->get('/');
-        $cspHeaders = collect($resp->headers->get('Content-Security-Policy'));
-        $frameHeaders = $cspHeaders->filter(function ($val) {
-            return Str::startsWith($val, 'frame-ancestors');
-        });
+        $frameHeader = $this->getCspHeader($resp, 'frame-ancestors');
 
-        $this->assertTrue($frameHeaders->count() === 1);
-        $this->assertEquals('frame-ancestors \'self\'', $frameHeaders->first());
+        $this->assertEquals('frame-ancestors \'self\'', $frameHeader);
     }
 
     public function test_iframe_csp_includes_extra_hosts_if_configured()
     {
         $this->runWithEnv('ALLOWED_IFRAME_HOSTS', 'https://a.example.com https://b.example.com', function () {
             $resp = $this->get('/');
-            $cspHeaders = collect($resp->headers->get('Content-Security-Policy'));
-            $frameHeaders = $cspHeaders->filter(function ($val) {
-                return Str::startsWith($val, 'frame-ancestors');
-            });
+            $frameHeader = $this->getCspHeader($resp, 'frame-ancestors');
 
-            $this->assertTrue($frameHeaders->count() === 1);
-            $this->assertEquals('frame-ancestors \'self\' https://a.example.com https://b.example.com', $frameHeaders->first());
+            $this->assertNotEmpty($frameHeader);
+            $this->assertEquals('frame-ancestors \'self\' https://a.example.com https://b.example.com', $frameHeader);
         });
     }
+
+    public function test_script_csp_set_on_responses()
+    {
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'script-src');
+        $this->assertStringContainsString('\'strict-dynamic\'', $scriptHeader);
+        $this->assertStringContainsString('\'nonce-', $scriptHeader);
+    }
+
+    public function test_script_csp_nonce_matches_nonce_used_in_custom_head()
+    {
+        $this->setSettings(['app-custom-head' => '<script>console.log("cat");</script>']);
+        $resp = $this->get('/login');
+        $scriptHeader = $this->getCspHeader($resp, 'script-src');
+
+        $nonce = app()->make(CspService::class)->getNonce();
+        $this->assertStringContainsString('nonce-' . $nonce, $scriptHeader);
+        $resp->assertSee('<script nonce="' . $nonce . '">console.log("cat");</script>');
+    }
+
+    public function test_script_csp_nonce_changes_per_request()
+    {
+        $resp = $this->get('/');
+        $firstHeader = $this->getCspHeader($resp, 'script-src');
+
+        $this->refreshApplication();
+
+        $resp = $this->get('/');
+        $secondHeader = $this->getCspHeader($resp, 'script-src');
+
+        $this->assertNotEquals($firstHeader, $secondHeader);
+    }
+
+    public function test_allow_content_scripts_settings_controls_csp_script_headers()
+    {
+        config()->set('app.allow_content_scripts', true);
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'script-src');
+        $this->assertEmpty($scriptHeader);
+
+        config()->set('app.allow_content_scripts', false);
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'script-src');
+        $this->assertNotEmpty($scriptHeader);
+    }
+
+    public function test_object_src_csp_header_set()
+    {
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'object-src');
+        $this->assertEquals('object-src \'self\'', $scriptHeader);
+    }
+
+    public function test_base_uri_csp_header_set()
+    {
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'base-uri');
+        $this->assertEquals('base-uri \'self\'', $scriptHeader);
+    }
+
+    public function test_cache_control_headers_are_strict_on_responses_when_logged_in()
+    {
+        $this->asEditor();
+        $resp = $this->get('/');
+        $resp->assertHeader('Cache-Control', 'max-age=0, no-store, private');
+        $resp->assertHeader('Pragma', 'no-cache');
+        $resp->assertHeader('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
+    }
+
+    /**
+     * Get the value of the first CSP header of the given type.
+     */
+    protected function getCspHeader(TestResponse $resp, string $type): string
+    {
+        $cspHeaders = collect($resp->headers->all('Content-Security-Policy'));
+
+        return $cspHeaders->filter(function ($val) use ($type) {
+            return strpos($val, $type) === 0;
+        })->first() ?? '';
+    }
 }
diff --git a/tests/Settings/CustomHeadContentTest.php b/tests/Settings/CustomHeadContentTest.php
new file mode 100644 (file)
index 0000000..94ef471
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+namespace Tests\Settings;
+
+use BookStack\Util\CspService;
+use Tests\TestCase;
+
+class CustomHeadContentTest extends TestCase
+{
+    public function test_configured_content_shows_on_pages()
+    {
+        $this->setSettings(['app-custom-head' => '<script>console.log("cat");</script>']);
+        $resp = $this->get('/login');
+        $resp->assertSee('console.log("cat")');
+    }
+
+    public function test_configured_content_does_not_show_on_settings_page()
+    {
+        $this->setSettings(['app-custom-head' => '<script>console.log("cat");</script>']);
+        $resp = $this->asAdmin()->get('/settings');
+        $resp->assertDontSee('console.log("cat")');
+    }
+
+    public function test_divs_in_js_preserved_in_configured_content()
+    {
+        $this->setSettings(['app-custom-head' => '<script><div id="hello">cat</div></script>']);
+        $resp = $this->get('/login');
+        $resp->assertSee('<div id="hello">cat</div>');
+    }
+
+    public function test_nonce_application_handles_edge_cases()
+    {
+        $mockCSP = $this->mock(CspService::class);
+        $mockCSP->shouldReceive('getNonce')->andReturn('abc123');
+
+        $content = trim('
+<script>console.log("cat");</script>
+<script type="text/html"><\script>const a = `<div></div>`<\/\script></script>
+<script >const a = `<div></div>`;</script>
+<script type="<script text>test">const c = `<div></div>`;</script>
+<script
+    type="text/html"
+>
+const a = `<\script><\/script>`;
+const b = `<script`;
+</script>
+<SCRIPT>const b = `↗️£`;</SCRIPT>
+        ');
+
+        $expectedOutput = trim('
+<script nonce="abc123">console.log("cat");</script>
+<script type="text/html" nonce="abc123"><\script>const a = `<div></div>`<\/\script></script>
+<script nonce="abc123">const a = `<div></div>`;</script>
+<script type="&lt;script text&gt;test" nonce="abc123">const c = `<div></div>`;</script>
+<script type="text/html" nonce="abc123">
+const a = `<\script><\/script>`;
+const b = `<script`;
+</script>
+<script nonce="abc123">const b = `↗️£`;</script>
+        ');
+
+        $this->setSettings(['app-custom-head' => $content]);
+        $resp = $this->get('/login');
+        $resp->assertSee($expectedOutput);
+    }
+}
similarity index 98%
rename from tests/FooterLinksTest.php
rename to tests/Settings/FooterLinksTest.php
index cb2959411cf49be89ef99a104bf781799e6a3cb7..55c3e107d5ff4b071e98cf2cd50f7475a148d018 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+namespace Tests\Settings;
+
 use Tests\TestCase;
 
 class FooterLinksTest extends TestCase
index df6c613df4eb2162b2346cb87ebd225bf7b968db..04952d22345caa18761466b026ce3b701395599c 100644 (file)
@@ -18,6 +18,10 @@ use BookStack\Entities\Repos\ChapterRepo;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Settings\SettingService;
 use BookStack\Uploads\HttpFetcher;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
 use Illuminate\Foundation\Testing\Assert as PHPUnit;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Support\Env;
@@ -25,6 +29,7 @@ use Illuminate\Support\Facades\Log;
 use Mockery;
 use Monolog\Handler\TestHandler;
 use Monolog\Logger;
+use Psr\Http\Client\ClientInterface;
 
 trait SharedTestHelpers
 {
@@ -89,7 +94,7 @@ trait SharedTestHelpers
     /**
      * Get a user that's not a system user such as the guest user.
      */
-    public function getNormalUser()
+    public function getNormalUser(): User
     {
         return User::query()->where('system_name', '=', null)->get()->last();
     }
@@ -211,6 +216,27 @@ trait SharedTestHelpers
         return $permissionRepo->saveNewRole($roleData);
     }
 
+    /**
+     * Create a group of entities that belong to a specific user.
+     *
+     * @return array{book: Book, chapter: Chapter, page: Page}
+     */
+    protected function createEntityChainBelongingToUser(User $creatorUser, ?User $updaterUser = null): array
+    {
+        if (empty($updaterUser)) {
+            $updaterUser = $creatorUser;
+        }
+
+        $userAttrs = ['created_by' => $creatorUser->id, 'owned_by' => $creatorUser->id, 'updated_by' => $updaterUser->id];
+        $book = factory(Book::class)->create($userAttrs);
+        $chapter = factory(Chapter::class)->create(array_merge(['book_id' => $book->id], $userAttrs));
+        $page = factory(Page::class)->create(array_merge(['book_id' => $book->id, 'chapter_id' => $chapter->id], $userAttrs));
+        $restrictionService = $this->app[PermissionService::class];
+        $restrictionService->buildJointPermissionsForEntity($book);
+
+        return compact('book', 'chapter', 'page');
+    }
+
     /**
      * Mock the HttpFetcher service and return the given data on fetch.
      */
@@ -223,6 +249,24 @@ trait SharedTestHelpers
             ->andReturn($returnData);
     }
 
+    /**
+     * Mock the http client used in BookStack.
+     * Returns a reference to the container which holds all history of http transactions.
+     *
+     * @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware
+     */
+    protected function &mockHttpClient(array $responses = []): array
+    {
+        $container = [];
+        $history = Middleware::history($container);
+        $mock = new MockHandler($responses);
+        $handlerStack = new HandlerStack($mock);
+        $handlerStack->push($history);
+        $this->app[ClientInterface::class] = new Client(['handler' => $handlerStack]);
+
+        return $container;
+    }
+
     /**
      * Run a set test with the given env variable.
      * Remembers the original and resets the value after test.
@@ -302,6 +346,15 @@ trait SharedTestHelpers
             );
     }
 
+    /**
+     * Assert that the session has a particular error notification message set.
+     */
+    protected function assertSessionError(string $message)
+    {
+        $error = session()->get('error');
+        PHPUnit::assertTrue($error === $message, "Failed asserting the session contains an error. \nFound: {$error}\nExpecting: {$message}");
+    }
+
     /**
      * Set a test handler as the logging interface for the application.
      * Allows capture of logs for checking against during tests.
index 080515173d67cdd6cdf3605dc9e66c7ad1de42a4..98e0dfbacf4c4cfb6321a55d9aa44fe9e491c6ef 100644 (file)
@@ -62,7 +62,7 @@ abstract class TestCase extends BaseTestCase
      * Assert that an activity entry exists of the given key.
      * Checks the activity belongs to the given entity if provided.
      */
-    protected function assertActivityExists(string $type, Entity $entity = null)
+    protected function assertActivityExists(string $type, ?Entity $entity = null, string $detail = '')
     {
         $detailsToCheck = ['type' => $type];
 
@@ -71,6 +71,10 @@ abstract class TestCase extends BaseTestCase
             $detailsToCheck['entity_id'] = $entity->id;
         }
 
+        if ($detail) {
+            $detailsToCheck['detail'] = $detail;
+        }
+
         $this->assertDatabaseHas('activities', $detailsToCheck);
     }
 }
index bab85be7a5e4ff9f8c0dd8bff3abbc785cb54cbc..2cab765ae4345c6958d2a2e54988dffb8cccab4b 100644 (file)
@@ -7,9 +7,9 @@ use BookStack\Entities\Models\Page;
 use BookStack\Entities\Tools\PageContent;
 use BookStack\Facades\Theme;
 use BookStack\Theming\ThemeEvents;
-use File;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
+use Illuminate\Support\Facades\File;
 use League\CommonMark\ConfigurableEnvironmentInterface;
 
 class ThemeTest extends TestCase
index f45d201363294274588d2e2ad318b8f970714a61..207fb7f59e3865aa607a6a36dc66696f1c63f835 100644 (file)
@@ -76,6 +76,12 @@ class ConfigTest extends TestCase
         );
     }
 
+    public function test_dompdf_remote_fetching_controlled_by_allow_untrusted_server_fetching_false()
+    {
+        $this->checkEnvConfigResult('ALLOW_UNTRUSTED_SERVER_FETCHING', 'false', 'dompdf.defines.enable_remote', false);
+        $this->checkEnvConfigResult('ALLOW_UNTRUSTED_SERVER_FETCHING', 'true', 'dompdf.defines.enable_remote', true);
+    }
+
     /**
      * Set an environment variable of the given name and value
      * then check the given config key to see if it matches the given result.
diff --git a/tests/Unit/OidcIdTokenTest.php b/tests/Unit/OidcIdTokenTest.php
new file mode 100644 (file)
index 0000000..ad91eec
--- /dev/null
@@ -0,0 +1,166 @@
+<?php
+
+namespace Tests\Unit;
+
+use BookStack\Auth\Access\Oidc\OidcIdToken;
+use BookStack\Auth\Access\Oidc\OidcInvalidTokenException;
+use Tests\Helpers\OidcJwtHelper;
+use Tests\TestCase;
+
+class OidcIdTokenTest extends TestCase
+{
+    public function test_valid_token_passes_validation()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [
+            OidcJwtHelper::publicJwkKeyArray(),
+        ]);
+
+        $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123'));
+    }
+
+    public function test_get_claim_returns_value_if_existing()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []);
+        $this->assertEquals('[email protected]', $token->getClaim('email'));
+    }
+
+    public function test_get_claim_returns_null_if_not_existing()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []);
+        $this->assertEquals(null, $token->getClaim('emails'));
+    }
+
+    public function test_get_all_claims_returns_all_payload_claims()
+    {
+        $defaultPayload = OidcJwtHelper::defaultPayload();
+        $token = new OidcIdToken(OidcJwtHelper::idToken($defaultPayload), OidcJwtHelper::defaultIssuer(), []);
+        $this->assertEquals($defaultPayload, $token->getAllClaims());
+    }
+
+    public function test_token_structure_error_cases()
+    {
+        $idToken = OidcJwtHelper::idToken();
+        $idTokenExploded = explode('.', $idToken);
+
+        $messagesAndTokenValues = [
+            ['Could not parse out a valid header within the provided token', ''],
+            ['Could not parse out a valid header within the provided token', 'cat'],
+            ['Could not parse out a valid payload within the provided token', $idTokenExploded[0]],
+            ['Could not parse out a valid payload within the provided token', $idTokenExploded[0] . '.' . 'dog'],
+            ['Could not parse out a valid signature within the provided token', $idTokenExploded[0] . '.' . $idTokenExploded[1]],
+            ['Could not parse out a valid signature within the provided token', $idTokenExploded[0] . '.' . $idTokenExploded[1] . '.' . '@$%'],
+        ];
+
+        foreach ($messagesAndTokenValues as [$message, $tokenValue]) {
+            $token = new OidcIdToken($tokenValue, OidcJwtHelper::defaultIssuer(), []);
+            $err = null;
+
+            try {
+                $token->validate('abc');
+            } catch (\Exception $exception) {
+                $err = $exception;
+            }
+
+            $this->assertInstanceOf(OidcInvalidTokenException::class, $err, $message);
+            $this->assertEquals($message, $err->getMessage());
+        }
+    }
+
+    public function test_error_thrown_if_token_signature_not_validated_from_no_keys()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []);
+        $this->expectException(OidcInvalidTokenException::class);
+        $this->expectExceptionMessage('Token signature could not be validated using the provided keys');
+        $token->validate('abc');
+    }
+
+    public function test_error_thrown_if_token_signature_not_validated_from_non_matching_key()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [
+            array_merge(OidcJwtHelper::publicJwkKeyArray(), [
+                'n' => 'iqK-1QkICMf_cusNLpeNnN-bhT0-9WLBvzgwKLALRbrevhdi5ttrLHIQshaSL0DklzfyG2HWRmAnJ9Q7sweEjuRiiqRcSUZbYu8cIv2hLWYu7K_NH67D2WUjl0EnoHEuiVLsZhQe1CmdyLdx087j5nWkd64K49kXRSdxFQUlj8W3NeK3CjMEUdRQ3H4RZzJ4b7uuMiFA29S2ZhMNG20NPbkUVsFL-jiwTd10KSsPT8yBYipI9O7mWsUWt_8KZs1y_vpM_k3SyYihnWpssdzDm1uOZ8U3mzFr1xsLAO718GNUSXk6npSDzLl59HEqa6zs4O9awO2qnSHvcmyELNk31w',
+            ]),
+        ]);
+        $this->expectException(OidcInvalidTokenException::class);
+        $this->expectExceptionMessage('Token signature could not be validated using the provided keys');
+        $token->validate('abc');
+    }
+
+    public function test_error_thrown_if_invalid_key_provided()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), ['url://example.com']);
+        $this->expectException(OidcInvalidTokenException::class);
+        $this->expectExceptionMessage('Unexpected type of key value provided');
+        $token->validate('abc');
+    }
+
+    public function test_error_thrown_if_token_algorithm_is_not_rs256()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken([], ['alg' => 'HS256']), OidcJwtHelper::defaultIssuer(), []);
+        $this->expectException(OidcInvalidTokenException::class);
+        $this->expectExceptionMessage('Only RS256 signature validation is supported. Token reports using HS256');
+        $token->validate('abc');
+    }
+
+    public function test_token_claim_error_cases()
+    {
+        /** @var array<array{0: string: 1: array}> $claimOverridesByErrorMessage */
+        $claimOverridesByErrorMessage = [
+            // 1. iss claim present
+            ['Missing or non-matching token issuer value', ['iss' => null]],
+            // 1. iss claim matches provided issuer
+            ['Missing or non-matching token issuer value', ['iss' => 'https://auth.example.co.uk']],
+            // 2. aud claim present
+            ['Missing token audience value', ['aud' => null]],
+            // 2. aud claim validates all values against those expected (Only expect single)
+            ['Token audience value has 2 values, Expected 1', ['aud' => ['abc', 'def']]],
+            // 2. aud claim matches client id
+            ['Token audience value did not match the expected client_id', ['aud' => 'xxyyzz.aaa.bbccdd.456']],
+            // 4. azp claim matches client id if present
+            ['Token authorized party exists but does not match the expected client_id', ['azp' => 'xxyyzz.aaa.bbccdd.456']],
+            // 5. exp claim present
+            ['Missing token expiration time value', ['exp' => null]],
+            // 5. exp claim not expired
+            ['Token has expired', ['exp' => time() - 360]],
+            // 6. iat claim present
+            ['Missing token issued at time value', ['iat' => null]],
+            // 6. iat claim too far in the future
+            ['Token issue at time is not recent or is invalid', ['iat' => time() + 600]],
+            // 6. iat claim too far in the past
+            ['Token issue at time is not recent or is invalid', ['iat' => time() - 172800]],
+
+            // Custom: sub is present
+            ['Missing token subject value', ['sub' => null]],
+        ];
+
+        foreach ($claimOverridesByErrorMessage as [$message, $overrides]) {
+            $token = new OidcIdToken(OidcJwtHelper::idToken($overrides), OidcJwtHelper::defaultIssuer(), [
+                OidcJwtHelper::publicJwkKeyArray(),
+            ]);
+
+            $err = null;
+
+            try {
+                $token->validate('xxyyzz.aaa.bbccdd.123');
+            } catch (\Exception $exception) {
+                $err = $exception;
+            }
+
+            $this->assertInstanceOf(OidcInvalidTokenException::class, $err, $message);
+            $this->assertEquals($message, $err->getMessage());
+        }
+    }
+
+    public function test_keys_can_be_a_local_file_reference_to_pem_key()
+    {
+        $file = tmpfile();
+        $testFilePath = 'file://' . stream_get_meta_data($file)['uri'];
+        file_put_contents($testFilePath, OidcJwtHelper::publicPemKey());
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [
+            $testFilePath,
+        ]);
+
+        $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123'));
+        unlink($testFilePath);
+    }
+}
index 2248bc2c5d15a3833ac129fb9bb6ae007e30b191..60fd370b676d0d592be5bfad9610ff04c0e55dcf 100644 (file)
@@ -76,9 +76,9 @@ class AttachmentTest extends TestCase
         $upload->assertStatus(200);
 
         $attachment = Attachment::query()->orderBy('id', 'desc')->first();
-        $expectedResp['path'] = $attachment->path;
-
         $upload->assertJson($expectedResp);
+
+        $expectedResp['path'] = $attachment->path;
         $this->assertDatabaseHas('attachments', $expectedResp);
 
         $this->deleteUploads();
index 4fd7bacc7dc465fd648e6c25d64efd8284547d7b..ed2fb5f04808c5ebddfce6cc8165c0b09a8ded5e 100644 (file)
 namespace Tests\User;
 
 use BookStack\Actions\ActivityType;
+use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Page;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
 use Tests\TestCase;
 
 class UserManagementTest extends TestCase
 {
+    public function test_user_creation()
+    {
+        /** @var User $user */
+        $user = factory(User::class)->make();
+        $adminRole = Role::getRole('admin');
+
+        $resp = $this->asAdmin()->get('/settings/users');
+        $resp->assertElementContains('a[href="' . url('/settings/users/create') . '"]', 'Add New User');
+
+        $this->get('/settings/users/create')
+            ->assertElementContains('form[action="' . url('/settings/users/create') . '"]', 'Save');
+
+        $resp = $this->post('/settings/users/create', [
+            'name'                          => $user->name,
+            'email'                         => $user->email,
+            'password'                      => $user->password,
+            'password-confirm'              => $user->password,
+            'roles[' . $adminRole->id . ']' => 'true',
+        ]);
+        $resp->assertRedirect('/settings/users');
+
+        $resp = $this->get('/settings/users');
+        $resp->assertSee($user->name);
+
+        $this->assertDatabaseHas('users', $user->only('name', 'email'));
+
+        $user->refresh();
+        $this->assertStringStartsWith(Str::slug($user->name), $user->slug);
+    }
+
+    public function test_user_updating()
+    {
+        $user = $this->getNormalUser();
+        $password = $user->password;
+
+        $resp = $this->asAdmin()->get('/settings/users/' . $user->id);
+        $resp->assertSee($user->email);
+
+        $this->put($user->getEditUrl(), [
+            'name' => 'Barry Scott',
+        ])->assertRedirect('/settings/users');
+
+        $this->assertDatabaseHas('users', ['id' => $user->id, 'name' => 'Barry Scott', 'password' => $password]);
+        $this->assertDatabaseMissing('users', ['name' => $user->name]);
+
+        $user->refresh();
+        $this->assertStringStartsWith(Str::slug($user->name), $user->slug);
+    }
+
+    public function test_user_password_update()
+    {
+        $user = $this->getNormalUser();
+        $userProfilePage = '/settings/users/' . $user->id;
+
+        $this->asAdmin()->get($userProfilePage);
+        $this->put($userProfilePage, [
+            'password' => 'newpassword',
+        ])->assertRedirect($userProfilePage);
+
+        $this->get($userProfilePage)->assertSee('Password confirmation required');
+
+        $this->put($userProfilePage, [
+            'password'         => 'newpassword',
+            'password-confirm' => 'newpassword',
+        ])->assertRedirect('/settings/users');
+
+        $userPassword = User::query()->find($user->id)->password;
+        $this->assertTrue(Hash::check('newpassword', $userPassword));
+    }
+
+    public function test_user_cannot_be_deleted_if_last_admin()
+    {
+        $adminRole = Role::getRole('admin');
+
+        // Delete all but one admin user if there are more than one
+        $adminUsers = $adminRole->users;
+        if (count($adminUsers) > 1) {
+            /** @var User $user */
+            foreach ($adminUsers->splice(1) as $user) {
+                $user->delete();
+            }
+        }
+
+        // Ensure we currently only have 1 admin user
+        $this->assertEquals(1, $adminRole->users()->count());
+        /** @var User $user */
+        $user = $adminRole->users->first();
+
+        $resp = $this->asAdmin()->delete('/settings/users/' . $user->id);
+        $resp->assertRedirect('/settings/users/' . $user->id);
+
+        $resp = $this->get('/settings/users/' . $user->id);
+        $resp->assertSee('You cannot delete the only admin');
+
+        $this->assertDatabaseHas('users', ['id' => $user->id]);
+    }
+
     public function test_delete()
     {
         $editor = $this->getEditor();
@@ -42,4 +142,26 @@ class UserManagementTest extends TestCase
             'owned_by' => $newOwner->id,
         ]);
     }
+
+    public function test_guest_profile_shows_limited_form()
+    {
+        $guest = User::getDefault();
+        $resp = $this->asAdmin()->get('/settings/users/' . $guest->id);
+        $resp->assertSee('Guest');
+        $resp->assertElementNotExists('#password');
+    }
+
+    public function test_guest_profile_cannot_be_deleted()
+    {
+        $guestUser = User::getDefault();
+        $resp = $this->asAdmin()->get('/settings/users/' . $guestUser->id . '/delete');
+        $resp->assertSee('Delete User');
+        $resp->assertSee('Guest');
+        $resp->assertElementContains('form[action$="/settings/users/' . $guestUser->id . '"] button', 'Confirm');
+
+        $resp = $this->delete('/settings/users/' . $guestUser->id);
+        $resp->assertRedirect('/settings/users/' . $guestUser->id);
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('cannot delete the guest user');
+    }
 }
index 1d5d3e7297ef29b2c58a6d117a4a461324fc45db..b39c2c47c84bee8145b0340baade7a183d5bdac9 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Tests\User;
 
+use BookStack\Entities\Models\Bookshelf;
 use Tests\TestCase;
 
 class UserPreferencesTest extends TestCase
@@ -106,4 +107,44 @@ class UserPreferencesTest extends TestCase
         $home = $this->get('/login');
         $home->assertElementExists('.dark-mode');
     }
+
+    public function test_books_view_type_preferences_when_list()
+    {
+        $editor = $this->getEditor();
+        setting()->putUser($editor, 'books_view_type', 'list');
+
+        $this->actingAs($editor)->get('/books')
+            ->assertElementNotExists('.featured-image-container')
+            ->assertElementExists('.content-wrap .entity-list-item');
+    }
+
+    public function test_books_view_type_preferences_when_grid()
+    {
+        $editor = $this->getEditor();
+        setting()->putUser($editor, 'books_view_type', 'grid');
+
+        $this->actingAs($editor)->get('/books')
+            ->assertElementExists('.featured-image-container');
+    }
+
+    public function test_shelf_view_type_change()
+    {
+        $editor = $this->getEditor();
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
+        setting()->putUser($editor, 'bookshelf_view_type', 'list');
+
+        $this->actingAs($editor)->get($shelf->getUrl())
+            ->assertElementNotExists('.featured-image-container')
+            ->assertElementExists('.content-wrap .entity-list-item')
+            ->assertSee('Grid View');
+
+        $req = $this->patch("/settings/users/{$editor->id}/switch-shelf-view", ['view_type' => 'grid']);
+        $req->assertRedirect($shelf->getUrl());
+
+        $this->actingAs($editor)->get($shelf->getUrl())
+            ->assertElementExists('.featured-image-container')
+            ->assertElementNotExists('.content-wrap .entity-list-item')
+            ->assertSee('List View');
+    }
 }
index 859a036e0e4a9a06653aa2da6bfff2aac7fe18fc..3942efa8e3d095a1d1084594d4c0d9959e91fde9 100644 (file)
@@ -5,11 +5,13 @@ namespace Tests\User;
 use Activity;
 use BookStack\Actions\ActivityType;
 use BookStack\Auth\User;
-use BookStack\Entities\Models\Bookshelf;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
 
-class UserProfileTest extends BrowserKitTest
+class UserProfileTest extends TestCase
 {
+    /**
+     * @var User
+     */
     protected $user;
 
     public function setUp(): void
@@ -21,74 +23,73 @@ class UserProfileTest extends BrowserKitTest
     public function test_profile_page_shows_name()
     {
         $this->asAdmin()
-            ->visit('/user/' . $this->user->slug)
-            ->see($this->user->name);
+            ->get('/user/' . $this->user->slug)
+            ->assertSee($this->user->name);
     }
 
     public function test_profile_page_shows_recent_entities()
     {
         $content = $this->createEntityChainBelongingToUser($this->user, $this->user);
 
-        $this->asAdmin()
-            ->visit('/user/' . $this->user->slug)
-            // Check the recently created page is shown
-            ->see($content['page']->name)
-            // Check the recently created chapter is shown
-            ->see($content['chapter']->name)
-            // Check the recently created book is shown
-            ->see($content['book']->name);
+        $resp = $this->asAdmin()->get('/user/' . $this->user->slug);
+        // Check the recently created page is shown
+        $resp->assertSee($content['page']->name);
+        // Check the recently created chapter is shown
+        $resp->assertSee($content['chapter']->name);
+        // Check the recently created book is shown
+        $resp->assertSee($content['book']->name);
     }
 
     public function test_profile_page_shows_created_content_counts()
     {
-        $newUser = $this->getNewBlankUser();
+        $newUser = factory(User::class)->create();
 
-        $this->asAdmin()->visit('/user/' . $newUser->slug)
-            ->see($newUser->name)
-            ->seeInElement('#content-counts', '0 Books')
-            ->seeInElement('#content-counts', '0 Chapters')
-            ->seeInElement('#content-counts', '0 Pages');
+        $this->asAdmin()->get('/user/' . $newUser->slug)
+            ->assertSee($newUser->name)
+            ->assertElementContains('#content-counts', '0 Books')
+            ->assertElementContains('#content-counts', '0 Chapters')
+            ->assertElementContains('#content-counts', '0 Pages');
 
         $this->createEntityChainBelongingToUser($newUser, $newUser);
 
-        $this->asAdmin()->visit('/user/' . $newUser->slug)
-            ->see($newUser->name)
-            ->seeInElement('#content-counts', '1 Book')
-            ->seeInElement('#content-counts', '1 Chapter')
-            ->seeInElement('#content-counts', '1 Page');
+        $this->asAdmin()->get('/user/' . $newUser->slug)
+            ->assertSee($newUser->name)
+            ->assertElementContains('#content-counts', '1 Book')
+            ->assertElementContains('#content-counts', '1 Chapter')
+            ->assertElementContains('#content-counts', '1 Page');
     }
 
     public function test_profile_page_shows_recent_activity()
     {
-        $newUser = $this->getNewBlankUser();
+        $newUser = factory(User::class)->create();
         $this->actingAs($newUser);
         $entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
         Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
         Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
 
-        $this->asAdmin()->visit('/user/' . $newUser->slug)
-            ->seeInElement('#recent-user-activity', 'updated book')
-            ->seeInElement('#recent-user-activity', 'created page')
-            ->seeInElement('#recent-user-activity', $entities['page']->name);
+        $this->asAdmin()->get('/user/' . $newUser->slug)
+            ->assertElementContains('#recent-user-activity', 'updated book')
+            ->assertElementContains('#recent-user-activity', 'created page')
+            ->assertElementContains('#recent-user-activity', $entities['page']->name);
     }
 
-    public function test_clicking_user_name_in_activity_leads_to_profile_page()
+    public function test_user_activity_has_link_leading_to_profile()
     {
-        $newUser = $this->getNewBlankUser();
+        $newUser = factory(User::class)->create();
         $this->actingAs($newUser);
         $entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
         Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
         Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
 
-        $this->asAdmin()->visit('/')->clickInElement('#recent-activity', $newUser->name)
-            ->seePageIs('/user/' . $newUser->slug)
-            ->see($newUser->name);
+        $linkSelector = '#recent-activity a[href$="/user/' . $newUser->slug . '"]';
+        $this->asAdmin()->get('/')
+            ->assertElementContains($linkSelector, $newUser->name);
     }
 
     public function test_profile_has_search_links_in_created_entity_lists()
     {
         $user = $this->getEditor();
-        $resp = $this->actingAs($this->getAdmin())->visit('/user/' . $user->slug);
+        $resp = $this->actingAs($this->getAdmin())->get('/user/' . $user->slug);
 
         $expectedLinks = [
             '/search?term=%7Bcreated_by%3A' . $user->slug . '%7D+%7Btype%3Apage%7D',
@@ -98,66 +99,7 @@ class UserProfileTest extends BrowserKitTest
         ];
 
         foreach ($expectedLinks as $link) {
-            $resp->seeInElement('[href$="' . $link . '"]', 'View All');
+            $resp->assertElementContains('[href$="' . $link . '"]', 'View All');
         }
     }
-
-    public function test_guest_profile_shows_limited_form()
-    {
-        $this->asAdmin()
-            ->visit('/settings/users')
-            ->click('Guest')
-            ->dontSeeElement('#password');
-    }
-
-    public function test_guest_profile_cannot_be_deleted()
-    {
-        $guestUser = User::getDefault();
-        $this->asAdmin()->visit('/settings/users/' . $guestUser->id . '/delete')
-            ->see('Delete User')->see('Guest')
-            ->press('Confirm')
-            ->seePageIs('/settings/users/' . $guestUser->id)
-            ->see('cannot delete the guest user');
-    }
-
-    public function test_books_view_is_list()
-    {
-        $editor = $this->getEditor();
-        setting()->putUser($editor, 'books_view_type', 'list');
-
-        $this->actingAs($editor)
-            ->visit('/books')
-            ->pageNotHasElement('.featured-image-container')
-            ->pageHasElement('.content-wrap .entity-list-item');
-    }
-
-    public function test_books_view_is_grid()
-    {
-        $editor = $this->getEditor();
-        setting()->putUser($editor, 'books_view_type', 'grid');
-
-        $this->actingAs($editor)
-            ->visit('/books')
-            ->pageHasElement('.featured-image-container');
-    }
-
-    public function test_shelf_view_type_change()
-    {
-        $editor = $this->getEditor();
-        $shelf = Bookshelf::query()->first();
-        setting()->putUser($editor, 'bookshelf_view_type', 'list');
-
-        $this->actingAs($editor)->visit($shelf->getUrl())
-            ->pageNotHasElement('.featured-image-container')
-            ->pageHasElement('.content-wrap .entity-list-item')
-            ->see('Grid View');
-
-        $req = $this->patch("/settings/users/{$editor->id}/switch-shelf-view", ['view_type' => 'grid']);
-        $req->assertRedirectedTo($shelf->getUrl());
-
-        $this->actingAs($editor)->visit($shelf->getUrl())
-            ->pageHasElement('.featured-image-container')
-            ->pageNotHasElement('.content-wrap .entity-list-item')
-            ->see('List View');
-    }
 }
diff --git a/version b/version
index 0d86fac788e718d59afcf33dbc4b497fa1cbe152..4a22367ef577fc75cdf9c5788579851707d70425 100644 (file)
--- a/version
+++ b/version
@@ -1 +1 @@
-v21.06-dev
+v21.10-dev