MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
-MAIL_ENCRYPTION=null
\ No newline at end of file
+MAIL_ENCRYPTION=null
# 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
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
# 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"
+++ /dev/null
----
-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
--- /dev/null
+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
+++ /dev/null
----
-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.
--- /dev/null
+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
+++ /dev/null
----
-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.
--- /dev/null
+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
+++ /dev/null
----
-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
--- /dev/null
+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.__*
--- /dev/null
+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
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
*/
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,
]);
}
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
{
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
-use DB;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
class TagRepo
{
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.
/**
* 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 {
* 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.
* Class LdapService
* Handles any app-specific LDAP tasks.
*/
-class LdapService extends ExternalAuthService
+class LdapService
{
protected $ldap;
+ protected $groupSyncService;
protected $ldapConnection;
protected $userAvatars;
protected $config;
/**
* 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;
}
}
$userGroups = $this->groupFilter($user);
- $userGroups = $this->getGroupsRecursive($userGroups, []);
- return $userGroups;
+ return $this->getGroupsRecursive($userGroups, []);
}
/**
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']);
}
/**
// 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);
}
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;
/**
* 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
);
}
return (new Writer(
new ImageRenderer(
- new RendererStyle(192, 0, null, null, $color),
+ new RendererStyle(192, 4, null, null, $color),
new SvgImageBackEnd()
)
))->writeString($url);
--- /dev/null
+<?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'];
+ }
+}
--- /dev/null
+<?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');
+ }
+ }
+}
--- /dev/null
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+class OidcInvalidKeyException extends \Exception
+{
+}
--- /dev/null
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use Exception;
+
+class OidcInvalidTokenException extends Exception
+{
+}
--- /dev/null
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+class OidcIssuerDiscoveryException extends \Exception
+{
+}
--- /dev/null
+<?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');
+ }
+}
--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?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;
+ }
+}
--- /dev/null
+<?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');
+ }
+}
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Exception;
+use Illuminate\Support\Str;
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.
*
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;
* 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;
}
/**
* @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();
/**
* 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
{
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.
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');
// 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('/');
}
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;
*/
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.
*
*/
public function deleteByUser(User $user)
{
- return $this->db->table($this->tokenTable)
+ return DB::table($this->tokenTable)
->where('user_id', '=', $user->id)
->delete();
}
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(),
*/
protected function tokenExists(string $token): bool
{
- return $this->db->table($this->tokenTable)
+ return DB::table($this->tokenTable)
->where('token', '=', $token)->exists();
}
*/
protected function getEntryByToken(string $token)
{
- return $this->db->table($this->tokenTable)
+ return DB::table($this->tokenTable)
->where('token', '=', $token)
->first();
}
/**
* 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')
{
$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) {
$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())
/**
* 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
{
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
-use Log;
+use Illuminate\Support\Facades\Log;
class UserRepo
{
// 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.
return [
// Method of authentication to use
- // Options: standard, ldap, saml2
+ // Options: standard, ldap, saml2, oidc
'method' => env('AUTH_METHOD', 'standard'),
// Authentication Defaults
// 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',
'provider' => 'external',
],
'saml2' => [
- 'driver' => 'saml2-session',
+ 'driver' => 'async-external-session',
+ 'provider' => 'external',
+ ],
+ 'oidc' => [
+ 'driver' => 'async-external-session',
'provider' => 'external',
],
'api' => [
'email' => 'emails.password',
'table' => 'password_resets',
'expire' => 60,
+ 'throttle' => 60,
],
],
'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,
* 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.
*
* 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.
* 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 ====.
* 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.
* 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.
* @link http://www.ros.co.nz/pdf
* @link http://www.php.net/image
*/
- 'DOMPDF_PDF_BACKEND' => 'CPDF',
+ 'pdf_backend' => 'CPDF',
/**
* PDFlib license key.
* 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.
*
* @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.
*
* @var string
*/
- 'DOMPDF_DEFAULT_FONT' => 'dejavu sans',
+ 'default_font' => 'dejavu sans',
/**
* Image DPI setting.
*
* @var int
*/
- 'DOMPDF_DPI' => 96,
+ 'dpi' => 96,
/**
* Enable inline PHP.
*
* @var bool
*/
- 'DOMPDF_ENABLE_PHP' => false,
+ 'enable_php' => false,
/**
* Enable inline Javascript.
*
* @var bool
*/
- 'DOMPDF_ENABLE_JAVASCRIPT' => false,
+ 'enable_javascript' => false,
/**
* Enable remote file access.
*
* @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.
*
* @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,
],
'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' => [
--- /dev/null
+<?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),
+];
namespace BookStack\Console\Commands;
use BookStack\Entities\Tools\SearchIndex;
-use DB;
use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
class RegenerateSearch extends Command
{
/**
* 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
{
*/
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';
use BookStack\Auth\User;
use BookStack\Model;
use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Class PageRevision.
* @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
{
/**
* 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);
}
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);
*/
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 = '';
*/
public function setNewMarkdown(string $markdown)
{
+ $markdown = $this->extractBase64ImagesFromMarkdown($markdown);
$this->page->markdown = $markdown;
$html = $this->markdownToHtml($markdown);
$this->page->html = $this->formatHtml($html);
/**
* 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;
$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\')]');
$extension = strtolower(preg_split('/[\/;]/', $dataDefinition)[1] ?? 'png');
// Validate extension
- if (!in_array($extension, $allowedExtensions)) {
+ if (!$imageRepo->imageExtensionSupported($extension)) {
$imageNode->setAttribute('src', '');
continue;
}
$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', '');
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.
*/
}
// 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);
/**
* Check if there's active editing being performed on this page.
- *
- * @return bool
*/
public function hasActiveEditing(): bool
{
}
/**
- * 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
{
})->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);
}
--- /dev/null
+<?php
+
+namespace BookStack\Exceptions;
+
+class OpenIdConnectException extends NotifyException
+{
+}
], 401);
}
- if (session()->get('sent-email-confirmation') === true) {
+ if (session()->pull('sent-email-confirmation') === true) {
return redirect('/register/confirm');
}
--- /dev/null
+<?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',
+ ];
+ }
+}
--- /dev/null
+<?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);
+ }
+}
]), 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'),
$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);
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;
}
// 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,
$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);
+ }
}
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,
]);
}
--- /dev/null
+<?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();
+ }
+}
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
{
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
{
}
/**
- * 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');
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]));
}
}
$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,
]);
}
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);
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');
}
*/
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,
],
\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,
],
];
--- /dev/null
+<?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;
+ }
+}
--- /dev/null
+<?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);
+ }
+}
+++ /dev/null
-<?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;
- }
-}
--- /dev/null
+<?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;
+ }
+}
namespace BookStack\Providers;
-use Blade;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Entities\BreadcrumbsViewComposer;
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
{
*/
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));
});
$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,
+ ]);
+ });
}
}
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
);
});
- 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'],
namespace BookStack\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
-use Route;
+use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
--- /dev/null
+<?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', '');
+ }
+}
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.
{
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'
+ );
+ }
}
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
* 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;
}
/**
*/
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,
}
/**
- * 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);
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);
}
/**
* 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());
$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());
protected $restrictionService;
protected $page;
+ protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
+
/**
* ImageRepo constructor.
*/
$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.
*/
namespace BookStack\Uploads;
use BookStack\Exceptions\ImageUploadException;
-use DB;
use ErrorException;
use Exception;
use Illuminate\Contracts\Cache\Repository as Cache;
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
/**
* 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;
}
/**
$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;
}
}
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());
}
$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);
*/
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));
}
/**
*/
public function destroy(Image $image)
{
- $this->destroyImagesFromPath($image->path);
+ $this->destroyImagesFromPath($image->path, $image->type);
$image->delete();
}
* 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);
}
/**
- * Check whether or not a folder is empty.
+ * Check whether a folder is empty.
*/
protected function isFolderEmpty(FileSystemInstance $storage, string $path): bool
{
}
/**
- * 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.
return null;
}
+ $storagePath = $this->adjustPathForStorageDisk($storagePath);
$storage = $this->getStorage();
$imageData = null;
if ($storage->exists($storagePath)) {
--- /dev/null
+<?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));
+ }
+}
namespace BookStack\Util;
+use DOMAttr;
use DOMDocument;
use DOMNodeList;
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
{
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;
}
/**
- * 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
{
$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);
+ }
+ }
}
--- /dev/null
+<?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);
+ }
+ }
+}
"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",
"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": [
"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",
+ }
+ ],
+ "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",
- "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",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
{
"name": "Michael Dowling",
"homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Tobias Schultze",
+ "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",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
{
"name": "Michael Dowling",
"homepage": "https://github.com/mtdowling"
},
+ {
+ "name": "George Mponos",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "homepage": "https://github.com/sagikazarmark"
+ },
{
"name": "Tobias Schultze",
"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",
+ "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",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "Patrick Monnerat",
+ "role": "Developer"
+ },
+ {
+ "name": "Andreas Fischer",
+ "role": "Developer"
+ },
+ {
+ "name": "Hans-Jürgen Petrich",
+ "role": "Developer"
+ },
+ {
+ "name": "Graham Campbell",
+ "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",
- "homepage": "https://gjcampbell.co.uk/"
},
{
"name": "Vance Lucas",
- "homepage": "https://vancelucas.com/"
}
],
"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",
- }
- ],
- "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",
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Str;
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);
--- /dev/null
+<?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');
+ });
+ }
+}
--- /dev/null
+{
+ "name": "My uploaded attachment",
+ "uploaded_to": 8,
+ "link": "https://link.example.com"
+}
\ No newline at end of file
--- /dev/null
+{
+ "name": "My updated attachment",
+ "uploaded_to": 4,
+ "link": "https://link.example.com/updated"
+}
\ No newline at end of file
--- /dev/null
+{
+ "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
--- /dev/null
+{
+ "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
--- /dev/null
+{
+ "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
--- /dev/null
+{
+ "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
<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"/>
* [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
## 🚀 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:
* [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
--- /dev/null
+<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
frequency: 30000,
last: 0,
};
+ this.shownWarningsCache = new Set();
if (this.pageId !== 0 && this.draftsEnabled) {
window.setTimeout(() => {
}
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 {
'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' => 'سيؤدي هذا إلى تطبيق إعدادات الأذونات الحالية لهذا الرف على جميع الكتب المتضمنة فيه. قبل التفعيل، تأكد من حفظ أي تغييرات في أذونات هذا الرف.',
'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 بدأ بتعديل هذه الصفحة',
'audit_table_user' => 'المستخدم',
'audit_table_event' => 'الحدث',
'audit_table_related' => 'العنصر أو التفاصيل ذات الصلة',
+ 'audit_table_ip' => 'IP Address',
'audit_table_date' => 'تاريخ النشاط',
'audit_date_from' => 'نطاق التاريخ من',
'audit_date_to' => 'نطاق التاريخ إلى',
'role_manage_page_templates' => 'إدارة قوالب الصفحة',
'role_access_api' => 'الوصول إلى واجهة برمجة تطبيقات النظام API',
'role_manage_settings' => 'إدارة إعدادات التطبيق',
+ 'role_export_content' => 'Export content',
'role_asset' => 'أذونات الأصول',
'roles_system_warning' => 'اعلم أن الوصول إلى أي من الأذونات الثلاثة المذكورة أعلاه يمكن أن يسمح للمستخدم بتغيير امتيازاته الخاصة أو امتيازات الآخرين في النظام. قم بتعيين الأدوار مع هذه الأذونات فقط للمستخدمين الموثوق بهم.',
'role_asset_desc' => 'تتحكم هذه الأذونات في الوصول الافتراضي إلى الأصول داخل النظام. ستتجاوز الأذونات الخاصة بالكتب والفصول والصفحات هذه الأذونات.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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' => 'Това ще приложи настоящите настройки за достъп на този рафт с книги за всички книги, съдържащи се в него. Преди да активирате, уверете се, че всички промени в настройките за достъп на този рафт са запазени.',
'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 в момента редактира тази страница',
'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' => 'Време до',
'role_manage_page_templates' => 'Управление на шаблони на страници',
'role_access_api' => 'Достъп до API на системата',
'role_manage_settings' => 'Управление на настройките на приложението',
+ 'role_export_content' => 'Export content',
'role_asset' => 'Настройки за достъп до активи',
'roles_system_warning' => 'Важно: Добавянето на потребител в някое от горните три роли може да му позволи да промени собствените си права или правата на другите в системата. Възлагайте тези роли само на доверени потребители.',
'role_asset_desc' => 'Тези настройки за достъп контролират достъпа по подразбиране до активите в системата. Настройките за достъп до книги, глави и страници ще отменят тези настройки.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
'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',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
'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',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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',
'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',
'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',
'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.',
'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',
'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',
'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ů',
'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í.',
'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',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
],
'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.',
'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',
'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',
'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',
'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',
'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',
'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',
'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.',
'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',
'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',
'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',
'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.',
'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',
'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',
'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.',
'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',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
],
'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.',
'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',
*/
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
'username' => 'Benutzername',
'email' => 'E-Mail',
'password' => 'Passwort',
- 'password_confirm' => 'Passwort bestätigen',
+ 'password_confirm' => 'Passwort bestätigen',
'password_hint' => 'Mindestlänge: 7 Zeichen',
'forgot_password' => 'Passwort vergessen?',
'remember_me' => 'Angemeldet bleiben',
'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
'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',
'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',
'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.',
'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.',
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
'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.',
'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',
'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:',
];
'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?',
'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',
// 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',
'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.',
'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',
'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',
'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.',
'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',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
],
'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.',
'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',
'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',
'export_html' => 'HTML-Datei',
'export_pdf' => 'PDF-Datei',
'export_text' => 'Textdatei',
- 'export_md' => 'Markdown-Datei',
+ 'export_md' => 'Markdown-Dateir',
// Permissions and restrictions
'permissions' => 'Berechtigungen',
'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.',
'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.',
'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.',
'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',
'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',
'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.',
'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',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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',
'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.',
'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',
'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',
'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:',
'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',
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',
'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.',
'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',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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',
'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
'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.',
'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',
'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',
'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.',
'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',
'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',
'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.',
'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',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
],
'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.',
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' => 'صفحه با موفقیت حذف شد',
'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.',
'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.',
'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',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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',
'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',
];
'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 !',
'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
'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',
'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',
'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',
// 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',
'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',
'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.',
'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',
'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',
'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',
'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',
'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',
'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
'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.',
'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',
'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',
'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
'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',
'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
'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
// 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.',
// 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',
'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
*/
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é !',
];
'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',
'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',
// 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',
// 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',
'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',
'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',
'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',
'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',
'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.',
'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',
'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',
'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.
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norvegien',
'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.',
],
'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.',
],
'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.',
],
],
'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.',
'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' => 'פעולה זו תעתיק את כל הרשאות המדף לכל הספרים המשוייכים למדף זה. לפני הביצוע, יש לוודא שכל הרשאות המדף אכן נשמרו.',
'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 החל לערוך דף זה',
'audit_table_user' => 'משתמש',
'audit_table_event' => 'אירוע',
'audit_table_related' => 'פריט או פרט קשור',
+ 'audit_table_ip' => 'IP Address',
'audit_table_date' => 'זמן הפעילות',
'audit_date_from' => 'טווח תאריכים החל מ...',
'audit_date_to' => 'טווח תאריכים עד ל...',
'role_manage_page_templates' => 'נהל תבניות דפים',
'role_access_api' => 'גש ל-API המערכת',
'role_manage_settings' => 'ניהול הגדרות יישום',
+ 'role_export_content' => 'Export content',
'role_asset' => 'הרשאות משאבים',
'roles_system_warning' => 'שימו לב לכך שגישה לכל אחת משלושת ההרשאות הנ"ל יכולה לאפשר למשתמש לשנות את הפריווילגיות שלהם או של אחרים במערכת. הגדירו תפקידים להרשאות אלה למשתמשים בהם אתם בוטחים בלבד.',
'role_asset_desc' => 'הרשאות אלו שולטות בגישת ברירת המחדל למשאבים בתוך המערכת. הרשאות של ספרים, פרקים ודפים יגברו על הרשאות אלו.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
'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',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
'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',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
'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',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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',
'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.',
'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
'reset' => 'Azzera',
'remove' => 'Rimuovi',
'add' => 'Aggiungi',
- 'configure' => 'Configure',
+ 'configure' => 'Configura',
'fullscreen' => 'Schermo intero',
'favourite' => 'Aggiungi ai Preferiti',
'unfavourite' => 'Rimuovi dai preferiti',
'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.',
'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',
'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',
'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',
'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.',
'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',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
],
'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.',
'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.',
'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がページの編集を開始しました',
'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',
'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' => '各アセットに対するデフォルトの権限を設定します。ここで設定した権限が優先されます。',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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' => '서가의 모든 책자에 이 권한을 적용합니다. 서가의 권한을 저장했는지 확인하세요.',
'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이 이 문서를 수정하고 있습니다.',
'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' => '날짜 범위 끝',
'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' => '책자, 챕터, 문서별 권한은 이 설정에 우선합니다.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
'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į',
'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',
'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ų.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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',
'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',
'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',
'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.',
'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ā',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
],
'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.',
'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å',
'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
'reset' => 'Nullstill',
'remove' => 'Fjern',
'add' => 'Legg til',
- 'configure' => 'Configure',
+ 'configure' => 'Konfigurer',
'fullscreen' => 'Fullskjerm',
'favourite' => 'Favorisér',
'unfavourite' => 'Avfavorisér',
'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.',
'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',
'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',
'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',
'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.',
'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',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
],
'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.',
'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.',
'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',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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ł',
'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.',
'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ę',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
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',
'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',
'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.',
'reset' => 'Redefinir',
'remove' => 'Remover',
'add' => 'Adicionar',
- 'configure' => 'Configure',
+ 'configure' => 'Configurar',
'fullscreen' => 'Ecrã completo',
'favourite' => 'Favorito',
'unfavourite' => 'Retirar Favorito',
'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.',
'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',
'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é',
'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',
'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.',
'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',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
],
'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.',
'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.',
'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',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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' => 'прокомментировал',
'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
'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' => 'Это применит текущие настройки доступов этой книжной полки ко всем книгам, содержащимся внутри. Перед активацией убедитесь, что все изменения в доступах этой книжной полки сохранены.',
'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 начал редактирование этой страницы',
'audit_table_user' => 'Пользователь',
'audit_table_event' => 'Событие',
'audit_table_related' => 'Связанный элемент',
+ 'audit_table_ip' => 'IP-адрес',
'audit_table_date' => 'Дата действия',
'audit_date_from' => 'Диапазон даты от',
'audit_date_to' => 'Диапазон даты до',
'role_manage_page_templates' => 'Управление шаблонами страниц',
'role_access_api' => 'Доступ к системному API',
'role_manage_settings' => 'Управление настройками приложения',
+ 'role_export_content' => 'Export content',
'role_asset' => 'Права доступа к материалам',
'roles_system_warning' => 'Имейте в виду, что доступ к любому из указанных выше трех разрешений может позволить пользователю изменить свои собственные привилегии или привилегии других пользователей системы. Назначать роли с этими правами можно только доверенным пользователям.',
'role_asset_desc' => 'Эти разрешения контролируют доступ по умолчанию к параметрам внутри системы. Разрешения на книги, главы и страницы перезапишут эти разрешения.',
'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' => 'Создать токен',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
],
'string' => ':attribute должен быть строкой.',
'timezone' => ':attribute должен быть корректным часовым поясом.',
- 'totp' => 'The provided code is not valid or has expired.',
+ 'totp' => 'Указанный код недействителен или истек.',
'unique' => ':attribute уже есть.',
'url' => 'Формат :attribute некорректен.',
'uploaded' => 'Не удалось загрузить файл. Сервер не может принимать файлы такого размера.',
// 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á',
'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)',
'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
'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',
'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',
'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',
'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',
// 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:',
// 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',
];
'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é',
'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',
'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',
'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',
'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',
'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',
'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',
'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',
'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',
'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é.',
'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',
// 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',
'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',
'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',
'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',
'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',
// 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',
'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',
'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',
'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.',
'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',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
'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',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
'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',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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.',
'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ı',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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' => 'прокоментував',
'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' => 'Це застосовує поточні налаштування дозволів цієї книжкової полиці до всіх книг, що містяться всередині. Перш ніж активувати, переконайтесь що будь-які зміни дозволів цієї книжкової полиці були збережені.',
'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 розпочав редагування цієї сторінки',
'audit_table_user' => 'Користувач',
'audit_table_event' => 'Подія',
'audit_table_related' => 'Пов’язаний елемент',
+ 'audit_table_ip' => 'IP Address',
'audit_table_date' => 'Дата активності',
'audit_date_from' => 'Діапазон дат від',
'audit_date_to' => 'Діапазон дат до',
'role_manage_page_templates' => 'Управління шаблонами сторінок',
'role_access_api' => 'Доступ до системного API',
'role_manage_settings' => 'Керування налаштуваннями програми',
+ 'role_export_content' => 'Export content',
'role_asset' => 'Дозволи',
'roles_system_warning' => 'Майте на увазі, що доступ до будь-якого з вищезазначених трьох дозволів може дозволити користувачеві змінювати власні привілеї або привілеї інших в системі. Ролі з цими дозволами призначайте лише довіреним користувачам.',
'role_asset_desc' => 'Ці дозволи контролюють стандартні доступи всередині системи. Права на книги, розділи та сторінки перевизначать ці дозволи.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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ề',
'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.',
'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
'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',
'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',
'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',
'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',
'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.',
'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',
'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' => '#',
'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',
'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',
'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.',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
'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' => '评论',
'remember_me' => '记住我',
'ldap_email_hint' => '请输入用于此帐户的电子邮件。',
'create_account' => '创建账户',
- 'already_have_account' => '您已经有账号?',
+ 'already_have_account' => '已经有账号了?',
'dont_have_account' => '您还没有账号吗?',
'social_login' => 'SNS登录',
'social_registration' => '使用社交网站账号注册',
'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
'reset' => '重置',
'remove' => '删除',
'add' => '添加',
- 'configure' => 'Configure',
+ 'configure' => '配置',
'fullscreen' => '全屏',
'favourite' => '收藏',
'unfavourite' => '取消收藏',
'shelves_permissions' => '书架权限',
'shelves_permissions_updated' => '书架权限已更新',
'shelves_permissions_active' => '书架权限激活',
+ 'shelves_permissions_cascade_warning' => '书架上的权限不会自动应用到书架里的书。这是因为书可以在多个书架上存在。使用下面的选项可以将权限复制到书架里的书上。',
'shelves_copy_permissions_to_books' => '将权限复制到图书',
'shelves_copy_permissions' => '复制权限',
'shelves_copy_permissions_explain' => '这会将此书架的当前权限设置应用于其中包含的所有图书。 在激活之前,请确保已保存对此书架权限的任何更改。',
'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”已经开始编辑此页面',
'audit_table_user' => '用户',
'audit_table_event' => '事件',
'audit_table_related' => '相关项目或详细信息',
+ 'audit_table_ip' => 'IP地址',
'audit_table_date' => '活动日期',
'audit_date_from' => '日期范围从',
'audit_date_to' => '日期范围至',
'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' => '管理用户',
'role_manage_page_templates' => '管理页面模板',
'role_access_api' => '访问系统 API',
'role_manage_settings' => '管理App设置',
+ 'role_export_content' => '导出内容',
'role_asset' => '资源许可',
'roles_system_warning' => '请注意,具有上述三个权限中的任何一个都可以允许用户更改自己的特权或系统中其他人的特权。 只将具有这些权限的角色分配给受信任的用户。',
'role_asset_desc' => '对系统内资源的默认访问许可将由这些权限控制。单独设置在书籍,章节和页面上的权限将覆盖这里的权限设定。',
'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 令牌',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => '挪威语 (Bokmål)',
'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之间。',
],
'string' => ':attribute 必须是字符串。',
'timezone' => ':attribute 必须是有效的区域。',
- 'totp' => 'The provided code is not valid or has expired.',
+ 'totp' => '您输入的认证码无效或已过期。',
'unique' => ':attribute 已经被使用。',
'url' => ':attribute 格式无效。',
'uploaded' => '无法上传文件。 服务器可能不接受此大小的文件。',
'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' => '這會將此書架目前的權限設定套用到所有包含的書本上。在啟用前,請確認您已儲存任何對此書架權限的變更。',
'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 已經開始編輯此頁面',
'audit_table_user' => '使用者',
'audit_table_event' => '活動',
'audit_table_related' => '相關的項目或詳細資訊',
+ 'audit_table_ip' => 'IP Address',
'audit_table_date' => '活動日期',
'audit_date_from' => '日期範圍,從',
'audit_date_to' => '日期範圍,到',
'role_manage_page_templates' => '管理頁面範本',
'role_access_api' => '存取系統 API',
'role_manage_settings' => '管理應用程式設定',
+ 'role_export_content' => 'Export content',
'role_asset' => '資源權限',
'roles_system_warning' => '請注意,有上述三項權限中的任一項的使用者都可以更改自己或系統中其他人的權限。有這些權限的角色只應分配給受信任的使用者。',
'role_asset_desc' => '對系統內資源的預設權限將由這裡的權限控制。若有單獨設定在書本、章節和頁面上的權限,將會覆寫這裡的權限設定。',
'it' => 'Italian',
'ja' => '日本語',
'ko' => '한국어',
+ 'lt' => 'Lietuvių Kalba',
'lv' => 'Latviešu Valoda',
'nl' => 'Nederlands',
'nb' => 'Norsk (Bokmål)',
.flex {
min-height: 0;
flex: 1;
+ max-width: 100%;
&.fit-content {
flex-basis: auto;
flex-grow: 0;
</p>
<table class="table">
<tr>
- <th>Parameter</th>
+ <th width="110">Parameter</th>
<th>Details</th>
<th width="30%">Examples</th>
</tr>
--- /dev/null
+<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>
+@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
+@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
--- /dev/null
+<!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 »</a>
+ </li>
+ <li>
+ <a href="https://github.com/BookStackApp/BookStack/releases" target="_blank">Ensure your instance is up-to-date »</a>
+ </li>
+ <li>
+ <a href="https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue+{{ urlencode($error) }}" target="_blank">Search for the issue on GitHub »</a>
+ </li>
+ <li>
+ <a href="https://discord.gg/ztkBqR2" target="_blank">Ask for help via Discord »</a>
+ </li>
+ <li>
+ <a href="https://duckduckgo.com/?q={{urlencode("BookStack {$error}")}}" target="_blank">Search the error message »</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
<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') }}">
</div>
@yield('bottom')
- <script src="{{ versioned_asset('dist/app.js') }}"></script>
+ <script src="{{ versioned_asset('dist/app.js') }}" nonce="{{ $cspNonce }}"></script>
@yield('scripts')
</body>
<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>
@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')
<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>
<div class="px-m">{{ $activity->detail }}</div>
@endif
</td>
+ <td>{{ $activity->ip }}</td>
<td>{{ $activity->created_at }}</td>
</tr>
@endforeach
'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
@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'])
</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>
* 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');
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
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');
+++ /dev/null
-<?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);
- }
-}
namespace Tests\Api;
-use BookStack\Auth\User;
use Tests\TestCase;
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);
]],
]);
}
-
- 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);
- }
}
--- /dev/null
+<?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);
+ }
+}
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.
*/
$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,
+ ]);
+ }
}
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()
{
- ->seePageIs('/');
}
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()
$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()
$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()
$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();
});
// 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'));
- $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');
- $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()
$mfaSession->markVerifiedForUser($user);
$this->assertTrue($mfaSession->isVerifiedForUser($user));
- $this->asAdmin()->visit('/logout');
+ $this->asAdmin()->get('/logout');
$this->assertFalse($mfaSession->isVerifiedForUser($user));
}
{
Notification::fake();
- $this->visit('/login')->click('Forgot Password?')
- ->seePageIs('/password/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', [
]);
+ $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.');
+ $this->assertDatabaseHas('password_resets', [
+ ]);
+
+ /** @var User $user */
Notification::assertSentTo($user, ResetPassword::class);
$n = Notification::sent($user, ResetPassword::class);
- $this->visit('/password/reset/' . $n->first()->token)
- ->see('Reset Password')
- ->submitForm('Reset Password', [
- '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', [
+ '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')
- ->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', [
- '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', [
+ ]);
+ $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', [
+ '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'));
- ->seePageUrlIs($page->getUrl());
+ ->assertRedirect($page->getUrl());
}
public function test_login_intended_redirect_does_not_redirect_to_external_pages()
$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()
$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()
$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()
$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'));
}
}
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;
$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,
$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');
+ }
}
--- /dev/null
+<?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([
+ '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', [
+ 'external_auth_id' => 'benny1010101',
+ 'email_confirmed' => false,
+ ]);
+
+ $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([
+ 'sub' => 'benny505',
+ ]);
+
+ $resp->assertStatus(200);
+ $resp->assertJson([
+ '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([
+ '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([
+ '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),
+ ]));
+ }
+}
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', [
- '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', [
+ '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()
$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()
'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()
$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()
'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()
'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()
'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);
- });
+ $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());
}
public function test_group_sync_functions_when_email_confirmation_required()
$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('/');
'name' => 'Barry Scott',
]);
- $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
- $acsPost = $this->post('/saml2/acs');
- $acsPost->assertRedirect('/login');
- $this->assertFalse($this->isAuthenticated());
- $this->assertDatabaseHas('users', [
- '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', [
+ '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()
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 = [];
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;
]);
$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()
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
+++ /dev/null
-<?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;
- }
-}
--- /dev/null
+<?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');
+ }
+}
$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');
+ }
}
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);
$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();
$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);
+ }
}
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);
--- /dev/null
+<?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);
+ }
+}
+++ /dev/null
-<?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());
- }
-}
$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 = [
$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
+ }
}
+++ /dev/null
-<?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);
- }
-}
}
}
- 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();
$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:');
$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();
$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:');
}
}
{
$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();
{
$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();
{
$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();
$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();
$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();
$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 ',
+ ]);
+
+ $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 ',
+ ]);
+
+ $page->refresh();
+ $this->assertStringContainsString('<img src=""', $page->html);
+ }
}
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;
/**
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,
]);
}
--- /dev/null
+<?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,
+ ]);
+ }
+}
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();
]);
}
- 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);
}
}
$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();
$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);
+ }
}
--- /dev/null
+<?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',
+ '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',
+ ];
+ }
+}
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Page;
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();
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
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();
$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']);
// 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 = [
[
// 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'));
}
}
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;
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()
$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()
$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,
]);
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()
$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',
]);
- $this->seeInDatabase('users', [
+ $this->assertDatabaseHas('users', [
'id' => $this->user->id,
'email' => $originalEmail,
'name' => 'my_new_name',
$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',
]);
- $this->seeInDatabase('users', [
+ $this->assertDatabaseHas('users', [
'id' => $this->user->id,
'name' => 'my_new_name_2',
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
$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);
}
}
'/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);
$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'),
], [
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);
$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()
'/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',
$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',
], [
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',
$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'),
$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',
$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',
], [
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',
]);
$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',
], [
]);
$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'];
$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', [], [
$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', [], [
$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',
$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',
]);
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',
]);
$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()
$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);
}
}
namespace Tests;
-use Auth;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Auth\Role;
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
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
{
namespace Tests;
-use Illuminate\Support\Str;
+use BookStack\Util\CspService;
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() ?? '';
+ }
}
--- /dev/null
+<?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="<script text>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);
+ }
+}
<?php
+namespace Tests\Settings;
+
use Tests\TestCase;
class FooterLinksTest extends TestCase
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;
use Mockery;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
+use Psr\Http\Client\ClientInterface;
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();
}
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.
*/
->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.
);
}
+ /**
+ * 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.
* 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];
$detailsToCheck['entity_id'] = $entity->id;
}
+ if ($detail) {
+ $detailsToCheck['detail'] = $detail;
+ }
+
$this->assertDatabaseHas('activities', $detailsToCheck);
}
}
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
);
}
+ 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.
--- /dev/null
+<?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(), []);
+ }
+
+ 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);
+ }
+}
$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();
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();
'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');
+ }
}
namespace Tests\User;
+use BookStack\Entities\Models\Bookshelf;
use Tests\TestCase;
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');
+ }
}
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
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',
];
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');
- }
}