diff --git a/.concrete/index.maintenance.php b/.concrete/index.maintenance.php new file mode 100644 index 0000000..5c4677c --- /dev/null +++ b/.concrete/index.maintenance.php @@ -0,0 +1,18 @@ + +Site Maintenance + + +
+

We’ll be back soon!

+
+

We’re performing some maintenance at the moment, please check back soon

+
+
diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..42aa49d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore unneeded stuff "export-ignore". +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.phpstorm.meta.php export-ignore +/.psalm export-ignore +/.github export-ignore +/ISSUE_TEMPLATE.md export-ignore +/psalm.xml.dist export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/docs export-ignore diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml new file mode 100644 index 0000000..6b1d1fe --- /dev/null +++ b/.github/workflows/phpcs.yml @@ -0,0 +1,34 @@ +name: PHPCS + +on: [push, pull_request] + +jobs: + phpcs: + name: PHPCS + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@releases/v2 + with: + php-version: 8.0 + extensions: mbstring + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist + + - name: Run PHPCS + run: ./vendor/bin/phpcs --ignore=fixtures src tests diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..eda225a --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,40 @@ +name: PHPUnit + +on: [push, pull_request] + +jobs: + phpunit: + name: PHPUnit + runs-on: ubuntu-latest + strategy: + matrix: + php: ['7.1', '8.0'] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP with specific version of PECL extension + uses: shivammathur/setup-php@releases/v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist + + - name: Configure matchers + uses: mheap/phpunit-matcher-action@v1 + + - name: Run PHPUnit + run: ./vendor/bin/phpunit diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml new file mode 100644 index 0000000..c3b9875 --- /dev/null +++ b/.github/workflows/psalm.yml @@ -0,0 +1,22 @@ +name: Psalm + +on: [push, pull_request] + +jobs: + psalm: + name: Psalm + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Psalm + uses: docker://vimeo/psalm-github-actions + with: + security_analysis: true + report_file: results.sarif + + - name: Upload Security Analysis results to GitHub + uses: github/codeql-action/upload-sarif@v1 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 7e9f2d9..8dfbb5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .DS_Store .idea -vendor \ No newline at end of file +vendor +build +composer.lock +.phpunit.result.cache diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php new file mode 100755 index 0000000..4028a53 --- /dev/null +++ b/.phpstorm.meta.php @@ -0,0 +1,12 @@ + '@', +])); +override(Application::make(0), map([ + '' => '@', +])); diff --git a/.psalm/plugins/PsrContainerChecker.php b/.psalm/plugins/PsrContainerChecker.php new file mode 100644 index 0000000..6556771 --- /dev/null +++ b/.psalm/plugins/PsrContainerChecker.php @@ -0,0 +1,68 @@ +classImplements($className, ContainerInterface::class) + ) { + return; + } + + $arg = $expr->args[0] ?? null; + if ($arg === null || ! $arg->value instanceof ClassConstFetch) { + return; + } + + $class = $arg->value->class; + if (! $class->hasAttribute('resolvedName')) { + return; + } + + $return_type_candidate = new Union([ + new TNamedObject( + (string) $class->getAttribute('resolvedName') + ), + ]); + } +} diff --git a/.psalm/stubs/Composer/Semver/Comparator.php.stub b/.psalm/stubs/Composer/Semver/Comparator.php.stub new file mode 100644 index 0000000..c818edb --- /dev/null +++ b/.psalm/stubs/Composer/Semver/Comparator.php.stub @@ -0,0 +1,107 @@ + $version2. + * + * @param string $version1 + * @param string $version2 + * + * @return bool + * + * @psalm-pure + */ + public static function greaterThan($version1, $version2) + { + } + + /** + * Evaluates the expression: $version1 >= $version2. + * + * @param string $version1 + * @param string $version2 + * + * @return bool + * + * @psalm-pure + */ + public static function greaterThanOrEqualTo($version1, $version2) + { + } + + /** + * Evaluates the expression: $version1 < $version2. + * + * @param string $version1 + * @param string $version2 + * + * @return bool + * + * @psalm-pure + */ + public static function lessThan($version1, $version2) + { + } + + /** + * Evaluates the expression: $version1 <= $version2. + * + * @param string $version1 + * @param string $version2 + * + * @return bool + * + * @psalm-pure + */ + public static function lessThanOrEqualTo($version1, $version2) + { + } + + /** + * Evaluates the expression: $version1 == $version2. + * + * @param string $version1 + * @param string $version2 + * + * @return bool + * + * @psalm-pure + */ + public static function equalTo($version1, $version2) + { + } + + /** + * Evaluates the expression: $version1 != $version2. + * + * @param string $version1 + * @param string $version2 + * + * @return bool + * + * @psalm-pure + */ + public static function notEqualTo($version1, $version2) + { + } + + /** + * Evaluates the expression: $version1 $operator $version2. + * + * @param string $version1 + * @param string $operator + * @param string $version2 + * + * @return bool + * + * @psalm-pure + */ + public static function compare($version1, $operator, $version2) + { + } +} diff --git a/.psalm/stubs/Concrete/Core/Application.php.stub b/.psalm/stubs/Concrete/Core/Application.php.stub new file mode 100644 index 0000000..fe42b63 --- /dev/null +++ b/.psalm/stubs/Concrete/Core/Application.php.stub @@ -0,0 +1,6 @@ + ncreteconcreteconcreteconcreteconcre + concreteconcreteconcreteconcreteconcrete + | concreteconcr--econc teconcreteconcrete + | concreteconc econ tecon.reteconcrete + | concreteconc. econ teco :eteconcrete + | concre- .oncr con tec. reteconcrete + | concret :ncr con. tec 'reteconcrete + | concret: ncr: conc .te. creteconcrete + | concrete. :cret conc ete ncreteconcrete + | concreteco re:. .:oncreteconcrete + | concretecon . . . .ncre..concrete + | concreteco .concreteconc. .oncrete + | concretec econcreteco. econcrete + | concretec: :concrete. :teconcrete + | concreteco: .ncr. :reteconcrete + | concreteconcr:. .oncreteconcrete + | concreteconcreteconcreteconcreteconcrete + \ oncreteconcreteconcreteconcreteconcre + ---------------------------------- diff --git a/art/concrete-symfony.txt b/art/concrete-symfony.txt new file mode 100644 index 0000000..3841c97 --- /dev/null +++ b/art/concrete-symfony.txt @@ -0,0 +1,20 @@ + + ncreteconcreteconcreteconcreteconcre + concreteconcreteconcreteconcreteconcrete + | concreteconcr--econc teconcreteconcrete + | concreteconc econ tecon.reteconcrete + | concreteconc. econ teco :eteconcrete + | concre- .oncr con tec. reteconcrete + | concret :ncr con. tec 'reteconcrete + | concret: ncr: conc .te. creteconcrete + | concrete. :cret conc ete ncreteconcrete + | concreteco re:. .:oncreteconcrete + | concretecon . . . .ncre..concrete + | concreteco .concreteconc. .oncrete + | concretec econcreteco. econcrete + | concretec: :concrete. :teconcrete + | concreteco: .ncr. :reteconcrete + | concreteconcr:. .oncreteconcrete + | concreteconcreteconcreteconcreteconcrete + \ oncreteconcreteconcreteconcreteconcre + ---------------------------------- diff --git a/art/concrete.txt b/art/concrete.txt new file mode 100644 index 0000000..89d9e3b --- /dev/null +++ b/art/concrete.txt @@ -0,0 +1,20 @@ + + ncreteconcreteconcreteconcreteconcre + concreteconcreteconcreteconcreteconcrete + | concreteconcr--econc teconcreteconcrete + | concreteconc econ tecon.reteconcrete + | concreteconc. econ teco :eteconcrete + | concre- .oncr con tec. reteconcrete + | concret :ncr con. tec 'reteconcrete + | concret: ncr: conc .te. creteconcrete + | concrete. :cret conc ete ncreteconcrete + | concreteco re:. .:oncreteconcrete + | concretecon . . . .ncre..concrete + | concreteco .concreteconc. .oncrete + | concretec econcreteco. econcrete + | concretec: :concrete. :teconcrete + | concreteco: .ncr. :reteconcrete + | concreteconcr:. .oncreteconcrete + | concreteconcreteconcreteconcreteconcrete + \ oncreteconcreteconcreteconcreteconcre + ---------------------------------- diff --git a/bin/concrete.php b/bin/concrete.php index 6a9fab1..1233271 100755 --- a/bin/concrete.php +++ b/bin/concrete.php @@ -1,16 +1,63 @@ register(); + +// Set up DI container +$container = new Container(); +$container->delegate(new ReflectionContainer()); +$container->addServiceProvider(ConcreteServiceProvider::class); +$container->addServiceProvider(InstallationServiceProvider::class); + +// Set up an inflector for the container itself +$container->inflector(\League\Container\ContainerAwareInterface::class) + ->invokeMethod('setContainer', [$container]); + +// Setup console application +$application = new Application($container); + +// Set up an inflector for the console +$container->inflector(Command\ConsoleAwareInterface::class) + ->invokeMethod('setConsole', [$application]); + +// Run +try { + // Add commands + $application->run(); +} catch(Exception\Installation\InstallationNotFound $e) { + $output = $application->getOutputStyle(); + + if ($output->isVeryVerbose()) { + throw $e; + } + $output->error($e->getMessage()); + die($e->getCode()); +} catch (\Throwable $e) { + $output = $application->getOutputStyle(); -$application = new Application(); -$application->addCommands( - [ - new \Concrete\Console\Command\InfoCommand(), - new \Concrete\Console\Command\Backup\BackupCommand(), - new \Concrete\Console\Command\Database\DumpCommand(), + if ($output->isVeryVerbose()) { + throw $e; + } - ] -); -$application->run(); + $output->error($e->getMessage()); + die($e->getCode()); +} diff --git a/composer.json b/composer.json index b1a93a8..b74f2db 100644 --- a/composer.json +++ b/composer.json @@ -4,12 +4,22 @@ "minimum-stability": "dev", "prefer-stable": true, "require": { - "symfony/console": "^4.1", - "symfony/process": "^5.2" + "php": "^7.1|^8.0", + "ext-json": "*", + "ext-pdo": "*", + "symfony/process": "^3|^4|^5", + "league/container": "^3.3", + "mnapoli/silly": "^1.7", + "composer/semver": "^3.2", + "league/climate": "^3.5", + "league/flysystem": "^1.0" }, "bin": [ "bin/concrete" ], + "conflict": { + "concrete5/core": "*" + }, "autoload": { "psr-4": { "Concrete\\Console\\": "src/" @@ -17,5 +27,20 @@ "files": [ "helpers.php" ] + }, + "autoload-dev": { + "psr-4": { + "Concrete\\Console\\": "tests/" + }, + "files": [ + "helpers.php" + ] + }, + "require-dev": { + "ext-simplexml": "*", + "nunomaduro/collision": "^3|^4|^5", + "phpunit/phpunit": "^7|^8|^9", + "mockery/mockery": "^1.3", + "squizlabs/php_codesniffer": "^3.5" } } diff --git a/composer.lock b/composer.lock deleted file mode 100644 index 5c05478..0000000 --- a/composer.lock +++ /dev/null @@ -1,517 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "19a720cc3fa5b97a87e97637d8abdf9a", - "packages": [ - { - "name": "psr/container", - "version": "1.1.1", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf", - "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf", - "shasum": "" - }, - "require": { - "php": ">=7.2.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "time": "2021-03-05T17:36:06+00:00" - }, - { - "name": "symfony/console", - "version": "v4.4.20", - "source": { - "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "c98349bda966c70d6c08b4cd8658377c94166492" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/c98349bda966c70d6c08b4cd8658377c94166492", - "reference": "c98349bda966c70d6c08b4cd8658377c94166492", - "shasum": "" - }, - "require": { - "php": ">=7.1.3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php73": "^1.8", - "symfony/polyfill-php80": "^1.15", - "symfony/service-contracts": "^1.1|^2" - }, - "conflict": { - "symfony/dependency-injection": "<3.4", - "symfony/event-dispatcher": "<4.3|>=5", - "symfony/lock": "<4.4", - "symfony/process": "<3.3" - }, - "provide": { - "psr/log-implementation": "1.0" - }, - "require-dev": { - "psr/log": "~1.0", - "symfony/config": "^3.4|^4.0|^5.0", - "symfony/dependency-injection": "^3.4|^4.0|^5.0", - "symfony/event-dispatcher": "^4.3", - "symfony/lock": "^4.4|^5.0", - "symfony/process": "^3.4|^4.0|^5.0", - "symfony/var-dumper": "^4.3|^5.0" - }, - "suggest": { - "psr/log": "For using the console logger", - "symfony/event-dispatcher": "", - "symfony/lock": "", - "symfony/process": "" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Console\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Eases the creation of beautiful and testable command line interfaces", - "homepage": "https://symfony.com", - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-02-22T18:44:15+00:00" - }, - { - "name": "symfony/polyfill-mbstring", - "version": "v1.22.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "5232de97ee3b75b0360528dae24e73db49566ab1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/5232de97ee3b75b0360528dae24e73db49566ab1", - "reference": "5232de97ee3b75b0360528dae24e73db49566ab1", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "suggest": { - "ext-mbstring": "For best performance" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.22-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - }, - "files": [ - "bootstrap.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-01-22T09:19:47+00:00" - }, - { - "name": "symfony/polyfill-php73", - "version": "v1.22.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "a678b42e92f86eca04b7fa4c0f6f19d097fb69e2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/a678b42e92f86eca04b7fa4c0f6f19d097fb69e2", - "reference": "a678b42e92f86eca04b7fa4c0f6f19d097fb69e2", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.22-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" - }, - "files": [ - "bootstrap.php" - ], - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-01-07T16:49:33+00:00" - }, - { - "name": "symfony/polyfill-php80", - "version": "v1.22.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dc3063ba22c2a1fd2f45ed856374d79114998f91", - "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.22-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, - "files": [ - "bootstrap.php" - ], - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-01-07T16:49:33+00:00" - }, - { - "name": "symfony/process", - "version": "v5.2.4", - "source": { - "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "313a38f09c77fbcdc1d223e57d368cea76a2fd2f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/313a38f09c77fbcdc1d223e57d368cea76a2fd2f", - "reference": "313a38f09c77fbcdc1d223e57d368cea76a2fd2f", - "shasum": "" - }, - "require": { - "php": ">=7.2.5", - "symfony/polyfill-php80": "^1.15" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Process\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Executes commands in sub-processes", - "homepage": "https://symfony.com", - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-01-27T10:15:41+00:00" - }, - { - "name": "symfony/service-contracts", - "version": "v2.2.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d15da7ba4957ffb8f1747218be9e1a121fd298a1", - "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1", - "shasum": "" - }, - "require": { - "php": ">=7.2.5", - "psr/container": "^1.0" - }, - "suggest": { - "symfony/service-implementation": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.2-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Service\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2020-09-07T11:33:47+00:00" - } - ], - "packages-dev": [], - "aliases": [], - "minimum-stability": "dev", - "stability-flags": [], - "prefer-stable": true, - "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "1.1.0" -} diff --git a/helpers.php b/helpers.php index e69de29..f2ba7cc 100755 --- a/helpers.php +++ b/helpers.php @@ -0,0 +1,121 @@ +setOptions($options); + } + + return $process; +} + +function stdoutToOutput(ConsoleOutputInterface $output): Closure +{ + return function($message, $channel) use ($output): void { + switch ($channel) { + case 'STDOUT': + $output->writeln($message); + break; + case 'STDERR': + $output->getErrorOutput()->writeln($message, OutputInterface::OUTPUT_RAW); + break; + default: + dd('Unknown message type: ' . $channel); + } + }; +} + +if (!function_exists('dot_get')) { + /** + * @template T + * @param array $array + * @param string $key + * @psalm-param T $default + * @return T|mixed + */ + function dot_get(array $array, string $key, $default = null) + { + if (is_null($key)) { + return $array; + } + + if (array_key_exists($key, $array)) { + return $array[$key]; + } + + if (strpos($key, '.') === false) { + return isset($array[$key]) ? $array[$key] : $default; + } + + foreach (explode('.', $key) as $segment) { + if (is_array($array) && array_key_exists($segment, $array)) { + $array = $array[$segment]; + } else { + return $default; + } + } + + return $array; + } +} + +if (!function_exists('snake_case')) { + function snake_case(string $original, string $delimiter = '_'): string + { + $result = $original; + if (! ctype_lower($result)) { + $result = preg_replace('/\s+/u', '', ucwords($result)); + + $result = mb_strtolower(preg_replace('/(.)(?=[A-Z])/u', '$1'.$delimiter, $result), 'UTF-8'); + } + + return $result; + } +} + +if (!function_exists('class_basename')) { + function class_basename($classOrInstance): string { + if (!is_string($classOrInstance)) { + $classOrInstance = get_class($classOrInstance); + } + + $segments = explode('\\', $classOrInstance); + return array_pop($segments); + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..c2f9bc3 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,14 @@ + + + The coding standard of concrete/console package + + + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..199119e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + tests + + + + + src/ + + + + + + + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..1c884a0 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Application.php b/src/Application.php index 39567f8..0bf0452 100644 --- a/src/Application.php +++ b/src/Application.php @@ -2,48 +2,113 @@ namespace Concrete\Console; -use Concrete\Console\Bootstrap\Booter; -use Concrete\Console\Command\InstallationAwareCommandInterface; -use Concrete\Console\Installation\Factory; -use Concrete\Console\Installation\Validator; -use Symfony\Component\Console\Application as SymfonyApplication; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\ArgvInput; +use Concrete\Console\Command\CommandProvider; +use Concrete\Console\Command\Output; +use Concrete\Console\Command\OutputStyle; +use Concrete\Console\Command\OutputStyleAwareInterface; +use League\Container\Container; +use Silly\Application as SillyApplication; +use Silly\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; -class Application extends SymfonyApplication +/** + * @method Container getContainer() + */ +class Application extends SillyApplication { - public function __construct() + /** + * @var false + */ + protected $registered = false; + + /** + * @var InputInterface + */ + protected $input; + + /** + * @var OutputInterface + */ + protected $output; + + /** + * @var OutputStyle + */ + protected $style; + + public function __construct(Container $container) { - parent::__construct('Concrete Console', '0.8'); + parent::__construct('Concrete Console', '0.1'); + $this->useContainer($container, true, true); } - public function doRunCommand(Command $command, InputInterface $input, OutputInterface $output) + public function doRun(InputInterface $input, OutputInterface $output) { - if ($command instanceof InstallationAwareCommandInterface) { - $command->mergeApplicationDefinition(); - $input->bind($command->getDefinition()); - $path = $command->getInstallation($input) ?? null; - - $installationFactory = new Factory(); - if ($path) { - // Turn the path into something real in case it's relative - $path = $installationFactory->normalizePath($path); - $installation = $installationFactory->createFromPath($path); - } else { - $installation = $installationFactory->detectInstallation(); - } - - $validator = new Validator(); - if ($validator->isValid($installation)) { - $booter = new Booter(); - $booter->boot($installation); - } + $this->input = $input; + $this->output = $output; + $this->style = new OutputStyle($input, $output); + + try { + // Loads in default stuff + $input->bind($this->getDefinition()); + } catch (\Throwable $e) { + // Errors must be ignored, full binding/validation happens later when the command is known. } - return parent::doRunCommand($command, $input, $output); // TODO: Change the autogenerated stub + + // Add input, output, and OutputStyle to container + $this->getContainer()->add(InputInterface::class, $input); + $this->getContainer()->add(OutputInterface::class, $output); + $this->getContainer()->add(ConsoleOutputInterface::class, $output); + $this->getContainer()->add(OutputStyle::class, $this->style); + $this->getContainer()->inflector(OutputStyleAwareInterface::class) + ->invokeMethod('setOutputStyle', [OutputStyle::class]); + + // Add commands + CommandProvider::register($this->getContainer(), $this); + + return parent::doRun($input, $output); + } + + protected function getDefaultInputDefinition() + { + $definition = parent::getDefaultInputDefinition(); + + $definition->addOptions([ + new InputOption('--instance', '-I', InputOption::VALUE_REQUIRED, 'Specify the concrete5 directory', '.'), + ]); + + return $definition; } -} \ No newline at end of file + public function getHelp() + { + $artWidth = 42; + + // Get the width of the title, excluding the version on purpose + $titleWidth = strlen($this->getName()); + + // Make help a little fancy + return implode('', [ + file_get_contents(__DIR__ . '/../art/concrete-symfony.txt'), + str_repeat(' ', (int) ceil(($artWidth - $titleWidth) / 2)), + "{$this->getName()} {$this->getVersion()}", + ]); + } + + public function getInput(): InputInterface + { + return $this->input; + } + public function getOutput(): OutputInterface + { + return $this->output; + } + + public function getOutputStyle(): OutputStyle + { + return $this->style; + } +} diff --git a/src/Bootstrap/Booter.php b/src/Bootstrap/Booter.php deleted file mode 100644 index 8193434..0000000 --- a/src/Bootstrap/Booter.php +++ /dev/null @@ -1,24 +0,0 @@ -getPath() . '/application/config'); - define('DIR_BASE', $installation->getPath()); - - // Include configuration - require $installation->getPath() . '/concrete/bootstrap/configure.php'; - - // Include autoloaders - require $installation->getPath() . '/concrete/bootstrap/autoload.php'; - - // Begin startup - $app = require $installation->getPath() . '/concrete/bootstrap/start.php'; - } -} \ No newline at end of file diff --git a/src/Command/AbstractInstallationCommand.php b/src/Command/AbstractInstallationCommand.php deleted file mode 100644 index be55fa6..0000000 --- a/src/Command/AbstractInstallationCommand.php +++ /dev/null @@ -1,26 +0,0 @@ -addInstallationOption(); - } - - protected function addInstallationOption() - { - $this->addOption('installation', 'i', InputOption::VALUE_OPTIONAL, 'Concrete installation to run command against.'); - } - - public function getInstallation(InputInterface $input): ?string - { - return $input->hasOption('installation') ? $input->getOption('installation') : null; - } -} \ No newline at end of file diff --git a/src/Command/Backup/BackupCommand.php b/src/Command/Backup/BackupCommand.php index 339ceae..9dce282 100644 --- a/src/Command/Backup/BackupCommand.php +++ b/src/Command/Backup/BackupCommand.php @@ -1,161 +1,301 @@ setName('backup:backup') - ->setDescription('Generates a Concrete installation backup.') - ->addArgument('file', InputArgument::OPTIONAL, 'Filename for the backup.') - ->addOption('skip-core', null, InputOption::VALUE_NONE, 'Does not include the Concrete core in the archive.'); + $this->skipCore = (bool)$input->getOption('skip-core'); + + // Make a new directory to back up in + $backupDirectory = Platform::tempDirectory(true); + /** @var string $directory */ + $directory = $input->getOption('dir'); + + if (!file_exists($directory)) { + mkdir($directory, 0777, true); + } + + $app = $this->getApplication(); + /** @var Site $site */ + $site = $app->make('site')->getSite(); + $manifest = (new Manifest()) + ->setHostName(gethostname()) + ->setVersion($this->getInstallation()->getVersion()->getVersion()) + ->setInstallationPath($this->getInstallation()->getPath()) + ->setSiteName($site->getSiteName()) + ->setUrl((string)$site->getSiteCanonicalURL()); + + $e = null; + try { + $manifest = $this->exportDatabase($manifest, $backupDirectory); + $manifest = $this->exportFiles($manifest, $backupDirectory); + $manifest = $this->exportApplication($manifest, $backupDirectory); + $manifest = $this->exportPackages($manifest, $backupDirectory); + $manifest = $this->exportCore($manifest, $backupDirectory); + $manifest = $this->exportIndexEntrypoint($manifest, $backupDirectory); + $this->writeManifest($manifest, $backupDirectory); + + // Finalize the backup + $this->compressDirectory($backupDirectory, $directory, $filename); + } catch (Throwable $e) { + // Rethrow any errors + throw $e; + } finally { + $this->output->writeln('Removing temporary directory...'); + + $rmProcess = new Process( + [ + 'rm', + '-r', + $backupDirectory + ] + ); + $rmProcess->setTimeout(null); + $rmProcess->run(); + } + + $this->output->success('Backup complete!'); + return 0; } - protected function exportDatabase(array &$manifest, string $directory, InputInterface $input, OutputInterface $output) + /** + * @return void + */ + public static function register(Container $container, Application $console): void + { + $console + ->command( + 'backup:backup [filename] [--skip-core] [--temp] [--dir=]', + self::class, + ['backup'] + ) + ->descriptions( + 'Generate a Concrete installation backup', + [ + 'filename' => 'Filename to use', + '--skip-core' => 'Does not include the Concrete core in the archive.', + '--temp' => 'Store relative to the concrete temp folder', + '--dir' => 'The directory to store the backup in' + ] + )->defaults( + [ + 'dir' => Platform::configDirectory() . '/backups' + ] + ); + } + + protected function exportDatabase(Manifest $manifest, string $directory): Manifest { $dbOutput = $directory . DIRECTORY_SEPARATOR . 'db'; mkdir($dbOutput); $dbOutput = $dbOutput . '/db.sql'; - $command = $this->getApplication()->find('database:dump'); - $arguments = ['file' => $dbOutput]; - $backupInput = new ArrayInput($arguments); - $command->run($backupInput, $output); + $this->getConsole()->runCommand("database:dump '{$dbOutput}'"); - $manifest['contents']['database'] = true;; + return $manifest->setDatabase('db/db.sql'); } - protected function exportIndexEntrypoint(array &$manifest, string $directory, InputInterface $input, OutputInterface $output) + protected function exportIndexEntrypoint(Manifest $manifest, string $directory): Manifest { - copy(DIR_BASE . DIRECTORY_SEPARATOR . DISPATCHER_FILENAME, $directory . DIRECTORY_SEPARATOR . DISPATCHER_FILENAME); - $manifest['contents']['index'] = true;; + copy( + DIR_BASE . DIRECTORY_SEPARATOR . DISPATCHER_FILENAME, + $directory . DIRECTORY_SEPARATOR . DISPATCHER_FILENAME + ); + + return $manifest->setIncludeIndex(true); } - protected function exportFiles(array &$manifest, string $directory, InputInterface $input, OutputInterface $output) + protected function exportFiles(Manifest $manifest, string $directory): Manifest { $storageOutput = $directory . DIRECTORY_SEPARATOR . 'storage'; mkdir($storageOutput); // loop through all storage locations - $app = Facade::getFacadeApplication(); + $app = $this->getApplication(); $fileStorageFactory = $app->make(StorageLocationFactory::class); $storageLocations = $fileStorageFactory->fetchList(); - foreach($storageLocations as $storageLocation) { + /** @var StorageLocation $storageLocation */ + foreach ($storageLocations as $storageLocation) { $configuration = $storageLocation->getConfigurationObject(); if ($configuration instanceof LocalConfiguration) { - $output->writeln( - sprintf("Adding files from storage location: '%s' (%s)", + $this->output->writeln( + sprintf( + "Adding files from storage location: '%s' (%s)", $storageLocation->getName(), $configuration->getRootPath() ) ); - $storageLocationOutput = $storageOutput . DIRECTORY_SEPARATOR . $storageLocation->getID(); + $storageLocationOutput = $storageOutput . DIRECTORY_SEPARATOR . $storageLocation->getID() . '/'; mkdir($storageLocationOutput); // Copy files from the storage location. - $rsyncProcess = new Process([ - 'rsync', - '-av', - '-progress', - $configuration->getRootPath(), - $storageLocationOutput, - '--exclude=tmp/', - '--exclude=cache/', - '--exclude=incoming/' - ]); + $rsyncProcess = new Process( + [ + 'rsync', + '-avL', + '--progress', + rtrim($configuration->getRootPath(), '/') . '/', + $storageLocationOutput, + '--exclude=tmp/', + '--exclude=cache/', + '--exclude=incoming/' + ] + ); $rsyncProcess->setTimeout(null); $rsyncProcess->run(); if (!$rsyncProcess->isSuccessful()) { - throw \Exception($rsyncProcess->getErrorOutput()); + throw new Exception($rsyncProcess->getErrorOutput()); } - $manifest['contents']['files'] = true; - + $manifest = $manifest->addStorageLocation( + $storageLocation->getID(), + $storageLocation->getName(), + $storageLocation->isDefault(), + true + ); } else { - $output->writeln( - sprintf("** Alert! File storage location '%s' is not an instance of local configuration. It will not be included in this backup.", - $storageLocation->getName() + $this->output->writeln( + sprintf( + "** Alert! File storage location '%s' is not an instance of local configuration." . + "It will not be included in this backup.", + $storageLocation->getName() ) ); } } + return $manifest; } - protected function writeManifest(array $manifest, string $directory) + protected function writeManifest(Manifest $manifest, string $directory): Manifest { file_put_contents($directory . DIRECTORY_SEPARATOR . 'manifest.json', json_encode($manifest)); + return $manifest; } - protected function exportApplication(array &$manifest, string $directory, InputInterface $input, OutputInterface $output) + protected function exportApplication(Manifest $manifest, string $directory): Manifest { - $output->writeln('Exporting application/ directory...'); - $rsyncProcess = new Process([ - 'rsync', - '-av', - '-progress', - DIR_APPLICATION, - $directory, - '--exclude=config/doctrine/', - '--exclude=files/', - ]); + $this->output->writeln('Exporting application/ directory...'); + $rsyncProcess = new Process( + [ + 'rsync', + '-avL', + '-progress', + DIR_APPLICATION, + $directory, + '--exclude=config/doctrine/', + '--exclude=config/*.database.php', + '--exclude=config/database.php', + '--exclude=files/', + ] + ); $rsyncProcess->setTimeout(null); $rsyncProcess->run(); if (!$rsyncProcess->isSuccessful()) { - throw new \Exception($rsyncProcess->getErrorOutput()); + throw new Exception($rsyncProcess->getErrorOutput()); } - $manifest['contents']['application'] = true; - $manifest['contents']['applicationContents'] = []; - foreach(['attributes', 'authentication', 'blocks', 'bootstrap', 'config', 'controllers', 'elements', - 'jobs', 'languages', 'mail', 'page_templates', 'single_pages', 'src', 'themes', 'tools', 'views'] as $item) { - $manifest['contents']['applicationContents'][] = $item; - } + return $manifest->addApplicationItems( + [ + 'attributes', + 'authentication', + 'blocks', + 'bootstrap', + 'config', + 'controllers', + 'elements', + 'jobs', + 'languages', + 'mail', + 'page_templates', + 'single_pages', + 'src', + 'themes', + 'tools', + 'views' + ] + ); } - protected function exportPackages(array &$manifest, string $directory, InputInterface $input, OutputInterface $output) + protected function exportPackages(Manifest $manifest, string $directory): Manifest { - $output->writeln('Exporting packages/ directory...'); - $rsyncProcess = new Process([ - 'rsync', - '-av', - '-progress', - DIR_PACKAGES, - $directory - ]); + $this->output->writeln('Exporting packages/ directory...'); + /** @var PackageService $packages */ + $packages = $this->getApplication()->make(PackageService::class); + $installed = $packages->getInstalledHandles(); + + foreach ($packages->getAvailablePackages() as $package) { + $handle = $package->getPackageHandle(); + $manifest = $manifest->addPackage( + $handle, + in_array($handle, $installed), + file_exists($package->getPackagePath()) + ); + } + + // Make sure we have installed missing packages as well + foreach ($installed as $packageHandle) { + if ($manifest->getPackage($packageHandle)) { + continue; + } + + $manifest = $manifest->addPackage($packageHandle, true, false); + } + + $rsyncProcess = new Process( + [ + 'rsync', + '-avL', + '-progress', + DIR_PACKAGES, + $directory + ] + ); $rsyncProcess->setTimeout(null); $rsyncProcess->run(); if (!$rsyncProcess->isSuccessful()) { - throw new \Exception($rsyncProcess->getErrorOutput()); + throw new Exception($rsyncProcess->getErrorOutput()); } - $manifest['contents']['packages'] = true; + return $manifest; } - protected function exportCore(array &$manifest, string $directory, InputInterface $input, OutputInterface $output) + protected function exportCore(Manifest $manifest, string $directory): Manifest { - if (!$input->getOption('skip-core')) { - $output->writeln('Exporting concrete/ directory...'); + if (!$this->skipCore) { + $this->output->writeln('Exporting concrete/ directory...'); $rsyncProcess = new Process( [ 'rsync', - '-av', + '-avL', '-progress', DIR_BASE_CORE, $directory @@ -165,83 +305,32 @@ protected function exportCore(array &$manifest, string $directory, InputInterfac $rsyncProcess->run(); if (!$rsyncProcess->isSuccessful()) { - throw new \Exception($rsyncProcess->getErrorOutput()); + throw new Exception($rsyncProcess->getErrorOutput()); } - $manifest['contents']['core'] = true; - } else { - $manifest['contents']['core'] = false; + return $manifest->setIncludeCore(true); } + + return $manifest->setIncludeCore(false); } - protected function compressDirectory(string $directory, InputInterface $input, OutputInterface $output) + protected function compressDirectory(string $directory, string $outputDirectory, string $outputFile = null): void { - $file = $input->getArgument('file'); - if (!$file) { - $app = Facade::getFacadeApplication(); + if (!$outputFile) { + $app = $this->getApplication(); $siteName = $app->make('site')->getSite()->getSiteName(); - $date = new \DateTime(); - $file = sprintf('backup_%s_%s', snake_case($siteName), $date->format('Y-m-d-H-i-s')); + $date = new DateTime(); + /** @psalm-suppress UndefinedFunction */ + $outputFile = sprintf('backup_%s_%s', snake_case($siteName), $date->format('Y-m-d-H-i-s')); } - rename($directory, $file); + $this->output->writeln(sprintf('Compressing directory: %s', $outputFile)); - $output->writeln(sprintf('Compressing directory: %s', $file)); - - $tarProcess = new Process([ - 'tar', - '-zcf', - $file . '.tar.gz', - $file - ]); - $tarProcess->setTimeout(null); - $tarProcess->run(); - - if (!$tarProcess->isSuccessful()) { - throw new \Exception($tarProcess->getErrorOutput()); - } + $compressed = new PharData($outputDirectory . '/' . $outputFile . '.tar.gz', null, null, Phar::GZ); + $compressed->buildFromDirectory($directory); - $output->writeln('Removing temporary directory...'); - - $rmProcess = new Process([ - 'rm', - '-r', - $file - ]); - $rmProcess->setTimeout(null); - $rmProcess->run(); - - return 0; - } - - public function execute(InputInterface $input, OutputInterface $output) - { - $backupDirectory = getcwd() . DIRECTORY_SEPARATOR . sprintf('tmp_%s', uniqid()); - mkdir($backupDirectory); - - $app = Facade::getFacadeApplication(); - $site = $app->make('site')->getSite(); - $manifest = [ - 'site' => $site->getSiteName(), - 'url' => $site->getSiteCanonicalURL(), - 'installationPath' => DIR_BASE, - 'version' => '1.0', - 'contents' => [] - ]; - - $this->exportDatabase($manifest, $backupDirectory, $input, $output); - $this->exportFiles($manifest, $backupDirectory, $input, $output); - $this->exportApplication($manifest, $backupDirectory, $input, $output); - $this->exportPackages($manifest, $backupDirectory, $input, $output); - $this->exportCore($manifest, $backupDirectory, $input, $output); - $this->exportIndexEntrypoint($manifest, $backupDirectory, $input, $output); - $this->writeManifest($manifest, $backupDirectory); - $this->compressDirectory($backupDirectory, $input, $output); - - $output->writeln('Backup complete!'); - - return 0; + $this->output->writeln(['', 'Successfully created backup file at:']); + $this->output->writeln($compressed->getPath(), self::VERBOSITY_QUIET); + $this->output->writeln(''); } - - -} \ No newline at end of file +} diff --git a/src/Command/Backup/InspectCommand.php b/src/Command/Backup/InspectCommand.php new file mode 100644 index 0000000..067767c --- /dev/null +++ b/src/Command/Backup/InspectCommand.php @@ -0,0 +1,144 @@ +output->error('Backup file not found.'); + return 111; + } + + $manifest = $factory->forBackup($backupFile); + + if (!$manifest) { + $this->output->error('Unable to load manifest from backup file.'); + return 112; + } + + if ($path = $input->getOption('ls')) { + /** @var string $path */ + return $this->list($path, $backupFile); + } + + if ($input->getOption('manifest-only')) { + $this->output->writeln(json_encode($manifest, JSON_PRETTY_PRINT)); + return 0; + } + $testAdjective = function (string $affirmative, string $negative): callable { + return function (bool $test) use ($affirmative, $negative): string { + return $test ? '' . $affirmative . '' : '' . $negative . ''; + }; + }; + + $included = $testAdjective('Included', 'Excluded'); + $installed = $testAdjective('Installed', 'Not Installed'); + + $this->output->table(['basics'], [ + ['Version', $manifest->getVersion()], + ['Core', $included($manifest->includesCore())], + ['Index', $included($manifest->includesIndex())], + ]); + + $packages = []; + foreach ($manifest->getPackages() as $package) { + $packages[] = [ + dot_get($package, 'handle'), + $installed(!!dot_get($package, 'installed')), + $included(!!dot_get($package, 'included')) + ]; + } + $this->output->table(['Packages'], $packages); + + $locations = []; + foreach ($manifest->getStorageLocations() as $location) { + $locations[] = [ + dot_get($location, 'name'), + dot_get($location, 'id'), + number_format($this->fileCount($backupFile, $location['id'])), + $included(!!dot_get($location, 'included')), + $testAdjective('Default', '')(!!dot_get($location, 'default')), + ]; + } + + $keys = ['Storage Locations', 'ID', 'File count', 'Is Included', 'Is Default']; + $locations = array_map(function ($columns) use ($keys) { + return array_combine($keys, $columns); + }, $locations); + + $this->output->table(['Storage Locations', 'ID', 'File Count'], $locations); + return 0; + } + + public static function register(Container $container, Application $console): void + { + $console->command('backup:inspect backupfile [-r|--manifest-only] [--ls=]', self::class) + ->descriptions('Inspects a Concrete installation backup', [ + 'backupfile' => 'The path to the backup file to inspect', + '--manifest-only' => 'Output the raw manifest json, combine with jq command.', + '--ls' => 'List out files at a given path' + ]); + } + + protected function fileCount(string $backupFile, int $storageLocationId): int + { + $data = new PharData($backupFile . '/storage/' . $storageLocationId); + + /** @psalm-suppress TooManyArguments Psalm has the wrong definition for PharData::count */ + return $data->count(COUNT_RECURSIVE); + } + + private function list(string $path, string $backupFile): int + { + $backup = new PharData($backupFile); + $path = ltrim($path, '/'); + $headers = [ + 'Size', 'Path' + ]; + $table = []; + $isFile = false; + + if ($path) { + if (!$backup->offsetExists($path)) { + $this->output->error('Cannot access "' . $path . '": No such file or directory'); + return 1; + } + + /** @var PharFileInfo $backupDir */ + $backupDir = $backup->offsetGet($path); + + if (!$backupDir->isDir()) { + $table[] = [$backupDir->getSize(), $backupDir->getFilename()]; + $isFile = true; + } else { + $backup = new PharData($backupDir->getPathname()); + } + } + + if (!$isFile) { + /** @var PharFileInfo $child */ + foreach ($backup as $child) { + $colors = $child->isDir() ? '' : ''; + $close = $child->isDir() ? '/' : ''; + $table[] = [ + $child->isDir() ? '' : $child->getSize(), + $colors . ltrim(implode('/', [$path, $child->getFilename()]), '/') . $close + ]; + } + } + + $this->output->table($headers, $table); + return 0; + } +} diff --git a/src/Command/Backup/RestoreCommand.php b/src/Command/Backup/RestoreCommand.php new file mode 100644 index 0000000..ca396e6 --- /dev/null +++ b/src/Command/Backup/RestoreCommand.php @@ -0,0 +1,98 @@ +getInstallation(); + $manifest = $factory->forBackup($backupFile); + + if (!$this->output->confirm('Are you sure you want to restore this backup?')) { + return 1; + } + + if (!$installation) { + throw new InstallationNotFound('No installation found.'); + } + + $restore + ->enableMaintenancePage() + ->restoreApplication(!!$input->getOption('skip-application')) + ->restoreCore(!!$input->getOption('skip-core') || !$manifest->includesCore()) + ->restoreConfig(!!$input->getOption('skip-config')) + ->restoreIndex(!!$input->getOption('skip-index') || !$manifest->includesIndex()) + ->restorePackages(!!$input->getOption('skip-packages') || !$manifest->getPackages()) + ->restoreDatabase(!!$input->getOption('skip-database') || !$manifest->getDatabase()) + ->restoreFiles(!!$input->getOption('skip-files')) + ->finalize(); + + // Handle various skips + if ($input->getOption('skip-application')) { + $restore->restoreApplication(true); + } + if ($input->getOption('skip-core')) { + $restore->restoreCore(true); + } + + $job = Restoration::forBackup( + new \PharData($backupFile), + $manifest, + $installation, + Platform::tempDirectory(true), + !!$input->getOption('dryrun') + ); + + try { + $restore->resolve()->restore($job); + } finally { + $this->output->newLine(); + $this->output->writeln('Cleaning up...'); + $finalize = new Finalize(); + $finalize->clean($job); + } + } + + public static function register(Container $container, Application $console): void + { + $console + ->command( + 'backup:restore backupFile [-D|--dryrun] + [--skip-db] [--skip-core] [--skip-packages] [--skip-config] [--skip-files] [--skip-application] + [--skip-index] [--skip-database]', + self::class, + ['restore'] + ) + ->descriptions( + 'Restore a concrete5 site from a backup.', + [ + 'backupFile' => 'The file to restore from', + '--dryrun' => 'Don\'t actually run restoration', + '--skip-core' => 'Skip the concrete5 core', + '--skip-packages' => 'Skip packages', + '--skip-config' => 'Skip config', + '--skip-files' => 'Skip any storage locations', + '--skip-application' => 'Skip restoring the full application directory', + '--skip-index' => 'Skip the index.php file', + '--skip-database' => 'Skip restoring the database', + ] + ); + } +} diff --git a/src/Command/Command.php b/src/Command/Command.php new file mode 100644 index 0000000..6f348b9 --- /dev/null +++ b/src/Command/Command.php @@ -0,0 +1,61 @@ +output = $outputStyle; + } + + public function setConsole(Application $application): void + { + $this->console = $application; + } + + protected function getConsole(): Application + { + return $this->console; + } + + public function getApplication(): \Concrete\Core\Application\Application + { + $connection = $this->getConnection(); + if (!$connection || !$connection instanceof ApplicationEnabledConnectionInterface) { + throw new VersionMismatch('This command can only run on Application Enabled concrete installs.'); + } + + return $connection->getApplication(); + } +} diff --git a/src/Command/CommandGroupInterface.php b/src/Command/CommandGroupInterface.php new file mode 100644 index 0000000..099c8f5 --- /dev/null +++ b/src/Command/CommandGroupInterface.php @@ -0,0 +1,12 @@ +setName('database:dump') - ->setDescription('Dumps the Concrete database to a file.') - ->addArgument('file', InputArgument::OPTIONAL, 'Filename for the dump file.') - ->addOption( - 'gz', - null, - InputOption::VALUE_NONE, - 'Gzip the export' - ); - } - - public function execute(InputInterface $input, OutputInterface $output) + public function __invoke(string $file, Input $input) { - $file = $input->getArgument('file'); - $app = Facade::getFacadeApplication(); + $app = $this->getApplication(); $config = $app->make('config')->get('database'); $connection = $config['default-connection']; - foreach($config['connections'] as $identifier => $connectionRow) { + foreach ($config['connections'] as $identifier => $connectionRow) { if ($identifier == $connection) { if (!$file) { $date = new \DateTime(); @@ -44,7 +30,7 @@ public function execute(InputInterface $input, OutputInterface $output) $mysqldump = sprintf( "mysqldump --host='%s' --port='%s' --user='%s' --password='%s' '%s' > '%s'", $connectionRow['server'], - 3306, + isset($connectionRow['port']) ? $connectionRow['port'] : 3306, $connectionRow['username'], $connectionRow['password'], $connectionRow['database'], @@ -52,32 +38,39 @@ public function execute(InputInterface $input, OutputInterface $output) ); $outputFile = $file; - $output->writeln(sprintf('Exporting database: %s', $connectionRow['database'])); + $this->output->writeln(sprintf('Exporting database: %s', $connectionRow['database'])); - $process = Process::fromShellCommandline($mysqldump); + $process = process($mysqldump); $process->setTimeout(null); $process->run(); if ($process->isSuccessful()) { if ($input->getOption('gz')) { $outputFile = $file . '.gz'; - $output->writeln('Compressing file with gzip...'); - $process = Process::fromShellCommandline(sprintf("gzip '%s'", $file)); + $this->output->writeln('Compressing file with gzip...'); + $process = process(['gzip', $file]); $process->setTimeout(null); $process->run(); } - $output->writeln(sprintf('Database backed up to file: %s', $outputFile)); + $this->output->writeln(sprintf('Database backed up to file: %s', $outputFile)); } else { - $output->writeln('' . $process->getErrorOutput() . ''); + $this->output->writeln('' . $process->getErrorOutput() . ''); } return 0; } } - $output->writeln('Unable to locate default Concrete database connection.'); + $this->output->writeln('Unable to locate default Concrete database connection.'); return 1; } - -} \ No newline at end of file + public static function register(Container $container, Application $console): void + { + $console->command('database:dump [file] [-z|--gz]', self::class) + ->descriptions('Dumps the Concrete database to a file', [ + 'file' => 'Filename for the dump file', + '--gz' => 'Flag to gzip', + ]); + } +} diff --git a/src/Command/InfoCommand.php b/src/Command/InfoCommand.php deleted file mode 100644 index 98ebcfb..0000000 --- a/src/Command/InfoCommand.php +++ /dev/null @@ -1,29 +0,0 @@ -setName('info') - ->setDescription('Get info about about the Concrete installation.'); - } - - public function execute(InputInterface $input, OutputInterface $output) - { - $info = new Info(); - $output->writeln('# Location'); - $output->writeln(sprintf('Path to installation: %s', DIR_BASE)); - $output->writeln('# concrete5 Version'); - $output->writeln('Installed - ' . ($info->isInstalled() ? 'Yes' : 'No')); - $output->writeln($info->getCoreVersions()); - } - - -} \ No newline at end of file diff --git a/src/Command/InstallationAwareCommandInterface.php b/src/Command/InstallationAwareCommandInterface.php deleted file mode 100644 index 650275a..0000000 --- a/src/Command/InstallationAwareCommandInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -newLine(); + $this->write($prefix . rtrim($step, '. ') . $suffix); + } + + public function outputDone(string $doneText = 'Done!', string $tag = ''): void + { + $this->write($tag . $doneText . ''); + } + + public function outputDryrun(): void + { + $this->outputDone('Done! (Dry run)'); + } + + public function outputFinal(): void + { + $this->outputStep('', '', ' -'); + } +} diff --git a/src/Command/OutputStyleAwareInterface.php b/src/Command/OutputStyleAwareInterface.php new file mode 100644 index 0000000..a5044d8 --- /dev/null +++ b/src/Command/OutputStyleAwareInterface.php @@ -0,0 +1,9 @@ +traitOutputStyle = $outputStyle; + } + + protected function getOutputStyle(): OutputStyle + { + return $this->traitOutputStyle ?: new OutputStyle(new ArgvInput([]), new NullOutput()); + } +} diff --git a/src/Command/Site/InfoCommand.php b/src/Command/Site/InfoCommand.php new file mode 100644 index 0000000..6ca0d97 --- /dev/null +++ b/src/Command/Site/InfoCommand.php @@ -0,0 +1,35 @@ +getApplication(); + + $info = $app->make(Info::class); + $this->output->writeln('# Location'); + $this->output->writeln(sprintf('Path to installation: %s', DIR_BASE)); + $this->output->writeln('# concrete5 Version'); + $this->output->writeln('Installed - ' . ($info->isInstalled() ? 'Yes' : 'No')); + $this->output->writeln($info->getCoreVersions()); + } + + /** + * @param Container $container + * @param Application $console + * @return void + */ + public static function register(Container $container, Application $console): void + { + $console->command('site:info', self::class, ['info']) + ->descriptions('Get info about the current Concrete installation'); + } +} diff --git a/src/Command/Site/SyncCommand.php b/src/Command/Site/SyncCommand.php new file mode 100644 index 0000000..5989f7f --- /dev/null +++ b/src/Command/Site/SyncCommand.php @@ -0,0 +1,65 @@ +getInstallation()); + + if (!$file) { + throw new RuntimeException('Unable to locate concrete.json config file.'); + } + + $data = json_decode(file_get_contents($file), true); + + $instances = InstanceConfig::fromJson(dot_get($data, 'instances', [])); + $fromInstance = dot_get($instances, $from); + + if (!$fromInstance || !$fromInstance instanceof InstanceConfig) { + throw new RuntimeException('No instances found.'); + } + + $this->output->outputStep('Backing up remote site'); + $process = $fromInstance->executeConsole('backup -q'); + $process->mustRun(); + $this->output->outputDone(); + + $this->output->outputStep('Syncing remote file'); + $syncPath = Platform::tempDirectory(true) . '/backup.tar.gz'; + + $path = trim($process->getOutput()); + $process = $fromInstance->downloadFile($path, $syncPath); + $process->mustRun(); + $this->output->outputDone(); + + $this->output->outputStep('Restoring'); + $result = $this->console->runCommand("backup:restore '{$syncPath}' --no-interaction", $this->output); + $this->output->outputDone(); + + return $result; + } + + public static function register(Container $container, Application $console): void + { + $console->command('site:sync from [--config=]', self::class) + ->descriptions( + 'Sync a remote site into this site using backups.', + [ + 'from' => 'The location to sync from @remote:/path/to/backup.tar.gz or @remote', + '--config' => 'Specify the config file to use', + ] + ); + } +} diff --git a/src/Concrete/Adapter/AbstractApplicationEnabledAdapter.php b/src/Concrete/Adapter/AbstractApplicationEnabledAdapter.php new file mode 100644 index 0000000..d6a4990 --- /dev/null +++ b/src/Concrete/Adapter/AbstractApplicationEnabledAdapter.php @@ -0,0 +1,95 @@ +createConnection(); + $connection->connect($this->resolveApplication($path)); + + return $connection; + } + + /** + * Get the connection to connect with + * + * @return ApplicationEnabledConnectionInterface + */ + abstract protected function createConnection(): ApplicationEnabledConnectionInterface; + + /** + * Resolve the application object from a concrete5 site + * @param string $path + * @return Application + */ + private function resolveApplication($path): Application + { + chdir($path); + + // Setup + $this->defineConstants($path); + $this->registerAutoload($path); + + // Get the concrete5 application + $cms = $this->getApplicationInstance($path); + + // Boot the runtime + $this->bootApplication($cms); + + return $cms; + } + + /** + * @param $path + */ + protected function defineConstants(string $path): void + { + // Define some required constants + define('DIR_BASE', $path); + define('C5_ENVIRONMENT_ONLY', true); + + // Load in the rest of them + require $path . '/concrete/bootstrap/configure.php'; + } + + /** + * @param $path + */ + protected function registerAutoload(string $path): void + { + // Load in concrete5's autoloader + require $path . '/concrete/bootstrap/autoload.php'; + } + + /** + * @param $path + * @return Application + */ + protected function getApplicationInstance(string $path): Application + { + return require $path . '/concrete/bootstrap/start.php'; + } + + /** + * @param $cms + */ + protected function bootApplication(Application $cms): void + { + if (method_exists($cms, 'getRuntime')) { + $runtime = $cms->getRuntime(); + $runtime->boot(); + } + } +} diff --git a/src/Concrete/Adapter/AdapterFactory.php b/src/Concrete/Adapter/AdapterFactory.php new file mode 100644 index 0000000..80602cf --- /dev/null +++ b/src/Concrete/Adapter/AdapterFactory.php @@ -0,0 +1,35 @@ + 5.6.0', $version); + } + + $map = [ + '5.6.9999' => Version6Adapter::class, + '9.9999999' => ApplicationEnabledAdapter::class, + ]; + + foreach ($map as $test => $adapterClass) { + if (Comparator::lessThanOrEqualTo($version, $test)) { + return $this->getContainer()->get($adapterClass); + } + } + + throw VersionMismatch::expected('< 10.0.0', $version); + } +} diff --git a/src/Concrete/Adapter/AdapterInterface.php b/src/Concrete/Adapter/AdapterInterface.php new file mode 100644 index 0000000..c57e66a --- /dev/null +++ b/src/Concrete/Adapter/AdapterInterface.php @@ -0,0 +1,17 @@ +handleAttaching($path); + ob_end_clean(); + return $result; + } + + protected function handleAttaching(string $path): LegacyConnection + { + // Change the cwd to the site path + chdir($path); + + // Define a couple things concrete5 expects + define('DIR_BASE', $path); + define('C5_ENVIRONMENT_ONLY', true); + + // Set the error reporting low + error_reporting(E_ALL | ~E_NOTICE | ~E_WARNING | ~E_STRICT); + + // Add 3rdparty to include path + set_include_path(get_include_path() . PATH_SEPARATOR . $path . '/concrete/libraries/3rdparty'); + + // Include Adodb first, not sure why this was needed + @require_once $path . '/concrete/libraries/3rdparty/adodb/adodb.inc.php'; + + // Include Loader explicitly + @require_once $path . '/concrete/libraries/loader.php'; + + // Load in legacy dispatcher + @require_once $path . '/concrete/dispatcher.php'; + + // Adodb Stuff + $GLOBALS['ADODB_ASSOC_CASE'] = 2; + $GLOBALS['ADODB_ACTIVE_CACHESECS'] = 300; + $GLOBALS['ADODB_CACHE_DIR'] = defined('DIR_FILES_CACHE_DB') ? DIR_FILES_CACHE_DB : ''; + + return new LegacyConnection(); + } +} diff --git a/src/Concrete/Client.php b/src/Concrete/Client.php new file mode 100644 index 0000000..d84be07 --- /dev/null +++ b/src/Concrete/Client.php @@ -0,0 +1,41 @@ +adapter = $adapter; + } + + /** + * Attempt to disconnect + * (This isn't going to be fully supported for awhile) + * + * @param ConnectionInterface $connection + * @return bool + */ + public function disconnect(ConnectionInterface $connection): bool + { + return $connection->disconnect(); + } + + /** + * Get a connection to a concrete5 site + * @param string $path The path to the site to connect to + * @return ConnectionInterface + */ + public function connect($path): ConnectionInterface + { + return $this->adapter->attach($path); + } +} diff --git a/src/Concrete/ClientInterface.php b/src/Concrete/ClientInterface.php new file mode 100644 index 0000000..98af7cc --- /dev/null +++ b/src/Concrete/ClientInterface.php @@ -0,0 +1,25 @@ +getLeagueContainer(); + + $app->add(ClientInterface::class, function (): ClientInterface { + $installation = $this->getContainer()->get(Installation::class); + $adapter = $this->getContainer()->get(AdapterFactory::class) + ->forVersion($installation->getVersion()->getVersion()); + + return new Client($adapter); + }); + + $app->add(ConnectionInterface::class, function (): ConnectionInterface { + $client = $this->getContainer()->get(ClientInterface::class); + $installation = $this->getContainer()->get(Installation::class); + + return $client->connect($installation->getPath()); + }, true); + } + + public function boot() + { + $app = $this->getLeagueContainer(); + + // Add inflector for version7 connection + $app->inflector(ConnectionAwareInterface::class) + ->invokeMethod('setConnection', [ConnectionInterface::class]); + } +} diff --git a/src/Concrete/Connection/ApplicationEnabledConnection.php b/src/Concrete/Connection/ApplicationEnabledConnection.php new file mode 100644 index 0000000..9a66bc0 --- /dev/null +++ b/src/Concrete/Connection/ApplicationEnabledConnection.php @@ -0,0 +1,59 @@ +application = $application; + } + + /** + * Determine if this connection is connected + * + * @return bool + */ + public function isConnected(): bool + { + return $this->application !== null; + } + + /** + * Disconnect a connection + * + * @return bool Success or failure + */ + public function disconnect(): bool + { + if (!$this->isConnected()) { + return false; + } + + $this->application = null; + return true; + } + + /** + * @return Application + */ + public function getApplication(): Application + { + if (!$this->application) { + throw new \RuntimeException('Accessing the application before it has been populated.'); + } + + return $this->application; + } +} diff --git a/src/Concrete/Connection/ApplicationEnabledConnectionInterface.php b/src/Concrete/Connection/ApplicationEnabledConnectionInterface.php new file mode 100644 index 0000000..c87cab7 --- /dev/null +++ b/src/Concrete/Connection/ApplicationEnabledConnectionInterface.php @@ -0,0 +1,11 @@ +traitConnection = $connection; + } + + protected function getConnection(): ?ConnectionInterface + { + return $this->traitConnection; + } +} diff --git a/src/Concrete/Connection/ConnectionInterface.php b/src/Concrete/Connection/ConnectionInterface.php new file mode 100644 index 0000000..4a6d9a3 --- /dev/null +++ b/src/Concrete/Connection/ConnectionInterface.php @@ -0,0 +1,19 @@ +isConnected(); + } +} diff --git a/src/Concrete/InstanceConfig.php b/src/Concrete/InstanceConfig.php new file mode 100644 index 0000000..a926a86 --- /dev/null +++ b/src/Concrete/InstanceConfig.php @@ -0,0 +1,96 @@ +user, $this->host, (int) $this->port); + return $sshConfig ? $sshConfig($ssh) : $ssh; + } + + public function executeConsole(string $command, callable $sshConfig = null): Process + { + return $this->execute($this->consolePath . ' ' . $command, $sshConfig); + } + + public function executeAsyncConsole(string $command, callable $sshConfig = null): Process + { + return $this->executeAsync($this->consolePath . ' ' . $command, $sshConfig); + } + + public function downloadFile(string $remotePath, string $localPath, callable $sshConfig = null): Process + { + if ($this->host === 'localhost') { + return $this->ssh($sshConfig)->run("cp '{$remotePath}' '{$localPath}'"); + } + + return $this->ssh($sshConfig)->download($remotePath, $localPath); + } + + public function execute(string $command, callable $sshConfig = null): Process + { + if ($this->host === 'localhost') { + return $this->ssh($sshConfig)->run("cd {$this->path} && {$command}"); + } + + return $this->ssh($sshConfig)->execute('cd ' . $this->path . "\n" . $command); + } + + public function executeAsync(string $command, callable $sshConfig = null): Process + { + if ($this->host === 'localhost') { + return $this->ssh($sshConfig)->run("cd {$this->path} && {$command}", 'start'); + } + + return $this->ssh($sshConfig)->executeAsync('cd ' . $this->path . "\n" . $command); + } + + /** + * @param array $instances + * @return array + */ + public static function fromJson(array $instances): array + { + $result = []; + foreach ($instances as $handle => $instance) { + $config = new InstanceConfig(); + + $config->host = dot_get($instance, 'host', ''); + $config->user = dot_get($instance, 'user', ''); + $config->path = dot_get($instance, 'path', ''); + $config->consolePath = dot_get($instance, 'consolePath', 'concrete'); + $config->port = dot_get($instance, 'port', '22'); + $config->sshKey = dot_get($instance, 'sshKey', ''); + $config->sshKeypassword = dot_get($instance, 'sshKeyPassword', ''); + + $result[$handle] = $config; + } + + return $result; + } +} diff --git a/src/Concrete/Loader.php b/src/Concrete/Loader.php new file mode 100644 index 0000000..797deba --- /dev/null +++ b/src/Concrete/Loader.php @@ -0,0 +1,148 @@ +callLoader('library', $library, $packageHandle); + } + + /** + * @param string $model + * @param string|null $packageHandle + * @return mixed + */ + public function model(string $model, string $packageHandle = null) + { + return $this->callLoader('model', $model, $packageHandle); + } + + /** + * @param string $file + * @param string $packageHandle + * @param array|null $args + * @return mixed + */ + public function packageElement(string $file, string $packageHandle, array $args = null) + { + return $this->callLoader('model', $file, $packageHandle, $args); + } + + /** + * @param string $file + * @param array|null $args + * @return mixed + */ + public function element(string $file, array $args = null) + { + return $this->callLoader('model', $file, $args); + } + + /** + * @param string $file + * @param array|null $args + * @param string|null $packageHandle + * @return mixed + */ + public function tool(string $file, array $args = null, string $packageHandle = null) + { + return $this->callLoader('model', $file, $args, $packageHandle); + } + + /** + * @param string $block + * @return mixed + */ + public function block(string $block) + { + return $this->callLoader('model', $block); + } + + /** + * @return mixed + */ + public function database() + { + return $this->callLoader('database'); + } + + /** + * @return mixed + */ + public function db() + { + return $this->callLoader('db'); + } + + /** + * @param string $file + * @param string|null $pkgHandle + * @return mixed + */ + public function helper(string $file, string $pkgHandle = null) + { + // Shim in false for pkgHandle default value + if ($pkgHandle === null) { + $pkgHandle = false; + } + + return $this->callLoader('helper', $file, $pkgHandle); + } + + /** + * @param string $package + * @return mixed + */ + public function package(string $package) + { + return $this->callLoader('package', $package); + } + + /** + * @param string $package + * @return mixed + */ + public function startingPointPackage(string $package) + { + return $this->callLoader('startingPointPackage', $package); + } + + /** + * @param string $collectionType + * @return mixed + */ + public function pageTypeControllerPath(string $collectionType) + { + return $this->callLoader('pageTypeControllerPath', $collectionType); + } + + /** + * @param string $path + * @return mixed + */ + public function controller(string $path) + { + return $this->callLoader('controller', $path); + } +} diff --git a/src/Concrete/Restore/Restoration.php b/src/Concrete/Restore/Restoration.php new file mode 100644 index 0000000..0b697e2 --- /dev/null +++ b/src/Concrete/Restore/Restoration.php @@ -0,0 +1,145 @@ +backup = $backup; + $self->manifest = $manifest; + $self->install = $install; + $self->temp = $temp; + $self->isDryRun = $dryrun; + + return $self; + } + + public function hasExtracted(): bool + { + return $this->extracted !== null; + } + + public function extract(): string + { + if (!$this->extracted) { + do { + $extractDir = $this->tempDir(uniqid('extract'), false); + } while (file_exists($extractDir)); + + $this->getBackup()->extractTo($extractDir); + $this->extracted = $extractDir; + } + + return $this->extracted; + } + + public function tempDir(string $subpath = null, bool $create = true): string + { + $temp = rtrim($this->temp, '/'); + + if ($subpath) { + $temp .= '/' . trim($subpath, '/'); + if ($create && !file_exists($temp)) { + mkdir($temp, 0777, true); + } + } + + return $temp; + } + + public function getBackup(): PharData + { + return $this->backup; + } + + /** + * @return Installation + */ + public function getInstallation(): Installation + { + return $this->install; + } + + public function findConcretePath(string $subpath, string $path = null): array + { + if (!$path) { + $path = $this->getInstallation()->getPath(); + } + + $path = rtrim($path, '/'); + $subpath = ltrim($subpath); + + $results = []; + $check = [ + '', + 'public', + 'www', + 'web', + ]; + + foreach ($check as $expectedPath) { + $fullPath = implode( + '/', + array_filter( + [ + $path, + $expectedPath, + $subpath + ] + ) + ); + + if (file_exists($fullPath)) { + $results[] = $fullPath; + } + } + + return $results; + } + + /** + * @return Manifest + */ + public function getManifest(): Manifest + { + return $this->manifest; + } + + /** + * @return bool + */ + public function isDryRun(): bool + { + return $this->isDryRun; + } +} diff --git a/src/Concrete/Restore/RestorationManager.php b/src/Concrete/Restore/RestorationManager.php new file mode 100644 index 0000000..1c53f16 --- /dev/null +++ b/src/Concrete/Restore/RestorationManager.php @@ -0,0 +1,72 @@ + */ + protected $strategies = []; + + public function addStrategy(StrategyInterface $strategy, bool $skip = false): RestorationManager + { + $this->strategies[get_class($strategy)] = [$strategy, $skip]; + return $this; + } + + public function restoreGenerator(Restoration $job): \Generator + { + $failed = 0; + $succeeded = 0; + foreach ($this->strategies as $strategyArray) { + [$strategy, $skip] = $strategyArray; + $this->outputStyle->write( + ' Starting ' . snake_case(class_basename($strategy), ' ') . ' step...' + ); + + if (!$skip) { + if ($strategy->restore($job)) { + $this->outputStyle->writeln(' Success!'); + $succeeded++; + yield self::RESULT_SUCCESS; + } else { + $this->outputStyle->writeln(' Failed.'); + $failed++; + yield self::RESULT_FAILURE; + } + } else { + $this->outputStyle->writeln(' Skipped.'); + yield self::RESULT_SKIPPED; + } + } + + $this->outputStyle->newLine(); + $this->outputStyle->writeln('Finished restoration' . ($failed ? " with {$failed} failures." : '!')); + + return $failed === 0; + } + + public function restore(Restoration $job): bool + { + $generator = $this->restoreGenerator($job); + // Resolve the generator completely + iterator_to_array($generator); + + return $generator->getReturn(); + } + + public function setOutputStyle(OutputStyle $outputStyle): void + { + $this->outputStyle = $outputStyle; + } +} diff --git a/src/Concrete/Restore/RestorationManagerBuilder.php b/src/Concrete/Restore/RestorationManagerBuilder.php new file mode 100644 index 0000000..455725d --- /dev/null +++ b/src/Concrete/Restore/RestorationManagerBuilder.php @@ -0,0 +1,74 @@ +manager = $manager; + } + + public function restoreDatabase(bool $skip = false): RestorationManagerBuilder + { + return $this->addStrategy(Strategy\RestoreDatabase::class, $skip); + } + + public function restoreCore(bool $skip = false): RestorationManagerBuilder + { + return $this->addStrategy(Strategy\RestoreCore::class, $skip); + } + + public function restorePackages(bool $skip = false): RestorationManagerBuilder + { + return $this->addStrategy(Strategy\RestorePackages::class, $skip); + } + + public function restoreApplication(bool $skip = false): RestorationManagerBuilder + { + return $this->addStrategy(Strategy\RestoreApplication::class, $skip); + } + + public function restoreIndex(bool $skip = false): RestorationManagerBuilder + { + return $this->addStrategy(Strategy\RestoreIndex::class, $skip); + } + + public function restoreConfig(bool $skip = false): RestorationManagerBuilder + { + return $this->addStrategy(Strategy\RestoreConfig::class, $skip); + } + + public function restoreFiles(bool $skip = false): RestorationManagerBuilder + { + return $this->addStrategy(Strategy\RestoreStorageLocations::class, $skip); + } + + public function enableMaintenancePage(bool $skip = false): RestorationManagerBuilder + { + return $this->addStrategy(Strategy\EnableMaintenanceMode::class, $skip); + } + + public function finalize(bool $skip = false): RestorationManagerBuilder + { + return $this->addStrategy(Strategy\Finalize::class, $skip); + } + + public function resolve(): RestorationManager + { + return $this->manager; + } + + protected function addStrategy(string $strategy, bool $skip = false): RestorationManagerBuilder + { + $this->manager->addStrategy($this->container->get($strategy), $skip); + return $this; + } +} diff --git a/src/Concrete/Restore/Strategy/AbstractDirectoryExtractStrategy.php b/src/Concrete/Restore/Strategy/AbstractDirectoryExtractStrategy.php new file mode 100644 index 0000000..16dd62e --- /dev/null +++ b/src/Concrete/Restore/Strategy/AbstractDirectoryExtractStrategy.php @@ -0,0 +1,76 @@ +getOutputStyle(); + $path = $this->getExtractDirectory(); + $name = $this->getExtractName(); + + $output->outputStep("Locating {$name} directory"); + + // Locate the core we need to override + $coreDirs = $job->findConcretePath($path); + + if (count($coreDirs) > 1) { + throw new \InvalidArgumentException("Unable to find {$name} directory, multiple detected."); + } + + if (!$coreDirs) { + throw new \InvalidArgumentException("Unable to find {$name} directory, not detected."); + } + + $file = array_shift($coreDirs); + $dir = dirname($file); + $output->outputDone($file); + + // Clear old directory if needed + if ($this->shouldClear($job)) { + $output->outputStep('Clearing old ' . $name); + + if (!$job->isDryRun()) { + $fs = new Filesystem(new Local($dir)); + if (!$fs->deleteDir(basename($file))) { + return false; + } + $output->outputDone(); + } else { + $output->outputDryrun(); + } + } + + // Extract all files to the path + $output->outputStep("Extracting {$name} directories... "); + $subpathCount = mb_substr_count($path, '/', 'utf8'); + $extractPath = $dir; + + while ($subpathCount-- && $extractPath) { + $extractPath = dirname($extractPath); + } + + if (!$job->isDryRun()) { + $job->getBackup()->extractTo($extractPath, [$path . '/'], true); + $output->outputDone(); + } else { + $output->outputDryrun(); + } + + // Output a string to make the success look nice + $output->outputFinal(); + + return true; + } +} diff --git a/src/Concrete/Restore/Strategy/AbstractOutputtingStrategy.php b/src/Concrete/Restore/Strategy/AbstractOutputtingStrategy.php new file mode 100644 index 0000000..8704274 --- /dev/null +++ b/src/Concrete/Restore/Strategy/AbstractOutputtingStrategy.php @@ -0,0 +1,12 @@ +getOutputStyle(); + $installation = $job->getInstallation(); + $installPath = $installation->getPath(); + $check = [ + '/index.php', + '/public/index.php', + '/web/index.php', + ]; + + $maintenance = Config::maintenancePage($installation); + $cachePath = $job->tempDir('indexes'); + $found = false; + foreach ($check as $item) { + if (!file_exists($installPath . $item)) { + continue; + } + + $output->outputStep('Backing up and replacing ' . $item); + + // cache the file and replace it with our maintenance page + $dirname = dirname($item); + if ($dirname !== '/') { + mkdir($cachePath . $dirname, 0777, true); + } + + copy($installPath . $item, $cachePath . $item); + if ($job->isDryRun()) { + $output->outputDryrun(); + } else { + file_put_contents($installPath . $item, $maintenance); + $output->outputDone(); + } + $found = true; + } + + $output->outputFinal(); + return $found; + } +} diff --git a/src/Concrete/Restore/Strategy/Finalize.php b/src/Concrete/Restore/Strategy/Finalize.php new file mode 100644 index 0000000..729d964 --- /dev/null +++ b/src/Concrete/Restore/Strategy/Finalize.php @@ -0,0 +1,120 @@ +findConcretePath('concrete/bin/concrete5'); + if (!$cliPath) { + throw new \RuntimeException('Unable to locate concrete5 cli tool.'); + } else { + $cliPath = array_shift($cliPath); + } + + return $this->generateProxies($job, $cliPath) + && $this->clearCache($job, $cliPath) + && $this->restoreIndexes($job) + && $this->sync($job); + } + + public function clean(Restoration $job): bool + { + return $this->clearTemp($job); + } + + private function restoreIndexes(Restoration $job): bool + { + $output = $this->getOutputStyle(); + $install = $job->getInstallation(); + $installPath = $install->getPath(); + $basePath = $job->tempDir('indexes'); + $allFiles = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $basePath, + \RecursiveDirectoryIterator::SKIP_DOTS + ) + ); + $dry = $job->isDryRun(); + + /** @var \SplFileInfo $file */ + foreach ($allFiles as $file) { + $output->outputStep('Restoring index file ' . $file->getPathname()); + + $newPath = $installPath . '/' . trim(substr($file->getPathname(), strlen($basePath)), '/'); + if (!$dry) { + copy($file->getPathname(), $newPath); + $output->outputDone(); + } else { + $output->outputDryrun(); + } + } + + $output->outputFinal(); + return true; + } + + private function sync(Restoration $job): bool + { + $sync = $job->tempDir('sync'); + //$sync = new Rsync(); + //var_dump($sync->getCommand($sync, $job->getInstallation())); + + return true; + } + + private function clearTemp(Restoration $job): bool + { + $filesystem = new Filesystem(new Local('/')); + return $filesystem->deleteDir($job->tempDir()); + } + + private function generateProxies(Restoration $job, string $path): bool + { + $output = $this->getOutputStyle(); + $output->outputStep('Regenerating database proxies'); + if ($job->isDryRun()) { + $output->outputDryrun(); + return true; + } + + $command = process($path . ' orm:generate:proxies'); + $command->mustRun(); + + if ($command->isSuccessful()) { + $output->outputDone(); + return true; + } + + $output->outputDone('Failed', ''); + return false; + } + + private function clearCache(Restoration $job, string $path): bool + { + $output = $this->getOutputStyle(); + $output->outputStep('Clearing cache'); + + if ($job->isDryRun()) { + $output->outputDryrun(); + return true; + } + + $command = process($path . ' c5:clear-cache'); + $command->mustRun(); + + if ($command->isSuccessful()) { + $output->outputDone(); + return true; + } + + $output->outputDone('Failed', ''); + return false; + } +} diff --git a/src/Concrete/Restore/Strategy/RestoreApplication.php b/src/Concrete/Restore/Strategy/RestoreApplication.php new file mode 100644 index 0000000..fbe746b --- /dev/null +++ b/src/Concrete/Restore/Strategy/RestoreApplication.php @@ -0,0 +1,25 @@ +getExtractDirectory(); + } + + protected function shouldClear(Restoration $job): bool + { + return false; + } +} diff --git a/src/Concrete/Restore/Strategy/RestoreConfig.php b/src/Concrete/Restore/Strategy/RestoreConfig.php new file mode 100644 index 0000000..1726745 --- /dev/null +++ b/src/Concrete/Restore/Strategy/RestoreConfig.php @@ -0,0 +1,27 @@ +getExtractDirectory(); + } + + protected function shouldClear(Restoration $job): bool + { + return false; + } +} diff --git a/src/Concrete/Restore/Strategy/RestoreDatabase.php b/src/Concrete/Restore/Strategy/RestoreDatabase.php new file mode 100644 index 0000000..ab8d76f --- /dev/null +++ b/src/Concrete/Restore/Strategy/RestoreDatabase.php @@ -0,0 +1,220 @@ +getOutputStyle(); + $backup = $job->getBackup(); + $manifest = $job->getManifest(); + $database = $manifest->getDatabase(); + $tempDir = $job->tempDir('database', true); + + if ($database) { + $output->outputStep('Extracting SQL file'); + $backup->extractTo($tempDir, $database); + $output->outputDone(); + } else { + $this->getOutputStyle()->error('Database not included in backup.'); + return false; + } + + $output->outputStep('Determining database credentials'); + + $connection = $this->getDatabaseCredentials($job); + if (!($sql = $this->testCredentials($connection))) { + $output->outputDone('Error', ''); + return false; + } + $output->outputDone(); + + $output->outputStep('Clearing database data'); + $tables = $sql->query('show tables')->fetchAll(PDO::FETCH_COLUMN); + $sql->exec('set FOREIGN_KEY_CHECKS=0'); + + foreach ($tables as $table) { + if ($job->isDryRun()) { + continue; + } + + if ($sql->exec("drop table `{$table}`") === false) { + throw new RuntimeException( + sprintf( + 'Unable to delete database table "%s" %s', + $table, + implode(', ', $sql->errorInfo()) + ) + ); + } + } + $sql->exec('set FOREIGN_KEY_CHECKS=1'); + if ($job->isDryRun()) { + $output->outputDryrun(); + } else { + $output->outputDone(); + } + + $output->outputStep('Restoring'); + + $input = fopen($tempDir . '/' . $database, 'r+'); + $process = process( + [ + 'mysql', + '-u' . dot_get($connection, 'username', ''), + '-p' . dot_get($connection, 'password', ''), + '-h' . dot_get($connection, 'server', ''), + dot_get($connection, 'database', ''), + ], + null, + null, + $input, + 0 + ); + + if ($job->isDryRun()) { + $output->outputDryrun(); + $output->outputFinal(); + return true; + } else { + $process->start( + function (string $channel, string $message): void { + if ($channel === 'err') { + throw new RuntimeException('Failed to restore database: ' . $message); + } + } + ); + + while ($process->isRunning()) { + sleep(1); + } + + $output->outputDone($process->isSuccessful() ? 'Done!' : 'Failed.'); + $output->outputFinal(); + return $process->isSuccessful(); + } + } + + /** + * @param Restoration $job + * @return DatabaseCredentialsType + */ + private function getDatabaseCredentials(Restoration $job): array + { + $extractConfigCredentials = function (array $config): array { + $default = dot_get($config, 'default-connection'); + if ($default && $defaultConnection = dot_get($config, 'connections.' . $default)) { + return [ + 'server' => (string)dot_get($defaultConnection, 'server', ''), + 'database' => (string)dot_get($defaultConnection, 'database', ''), + 'username' => (string)dot_get($defaultConnection, 'username', ''), + 'password' => (string)dot_get($defaultConnection, 'password', ''), + 'charset' => (string)dot_get($defaultConnection, 'character_set', ''), + 'collation' => (string)dot_get($defaultConnection, 'collation', ''), + 'cert' => (string)dot_get($defaultConnection, 'database', ''), + ]; + } + + throw new \InvalidArgumentException( + 'Invalid configuration provided. Couldn\'t resolve default connection.' + ); + }; + + // First try loading directly using the connection if there is one + try { + $connection = $this->getConnection(); + if ($connection && $connection instanceof ApplicationEnabledConnectionInterface) { + $config = $connection->getApplication()->make('config'); + $result = $config->get('database'); + + return $extractConfigCredentials($result); + } + } catch (Throwable $e) { + // Ignore errors + } + + // Next try calling the built in command line utility + try { + $paths = $job->findConcretePath('concrete/bin/concrete5'); + $cliPath = array_shift($paths); + if ($cliPath) { + $process = process( + [ + $cliPath, + 'c5:config', + 'get', + 'database' + ], + $job->getInstallation()->getPath() + ); + $process->mustRun(); + + $result = json_decode($process->getOutput(), true); + return $extractConfigCredentials($result); + } + } catch (Throwable $e) { + // Ignore errors + } + + // Finally try loading them directly, this is the least safe because these PHP files may refer to environment + // or declared variables + try { + $path = $job->findConcretePath('application/config/database.php'); + $configPath = array_shift($path); + + if ($configPath) { + return $extractConfigCredentials(include $configPath); + } + } catch (Throwable $e) { + // Ignore errors + } + + throw new RuntimeException('Unable to determine database credentials.'); + } + + /** + * @param array|null $connection + * @return ?PDO + * @psalm-assert-if-true DatabaseCredentialsType $connection + */ + private function testCredentials(?array $connection): ?PDO + { + if ($connection) { + // Test out database credentials + $sql = new PDO( + 'mysql:host=' . $connection['server'] . ';dbname=' . $connection['database'], + $connection['username'], + $connection['password'] + ); + + $result = $sql->query('SHOW VARIABLES LIKE "%version%"'); + if ($result->fetchAll()) { + return $sql; + } + } + + return null; + } +} diff --git a/src/Concrete/Restore/Strategy/RestoreIndex.php b/src/Concrete/Restore/Strategy/RestoreIndex.php new file mode 100644 index 0000000..450743c --- /dev/null +++ b/src/Concrete/Restore/Strategy/RestoreIndex.php @@ -0,0 +1,47 @@ +getOutputStyle(); + $backup = $job->getBackup(); + + $output->outputStep('Locating index'); + if (!$job->getManifest()->includesIndex()) { + return false; + } + $output->outputDone(); + + $output->outputStep('Reading from backup'); + $index = $backup->offsetGet('index.php'); + if (!$index || !($data = $index->getContent())) { + return false; + } + $output->outputDone(); + + $configDir = new Filesystem(new Local($job->tempDir())); + $configDir->addPlugin(new ListPaths()); + + $output->outputStep('Replacing cached indexes'); + foreach ($configDir->listPaths('indexes') as $file) { + $configDir->put($file, $data); + } + $output->outputDone(); + + $output->outputFinal(); + return true; + } +} diff --git a/src/Concrete/Restore/Strategy/RestorePackages.php b/src/Concrete/Restore/Strategy/RestorePackages.php new file mode 100644 index 0000000..5dfe00e --- /dev/null +++ b/src/Concrete/Restore/Strategy/RestorePackages.php @@ -0,0 +1,24 @@ +getExtractDirectory(); + } + + protected function shouldClear(Restoration $job): bool + { + return true; + } +} diff --git a/src/Concrete/Restore/Strategy/RestoreStorageLocations.php b/src/Concrete/Restore/Strategy/RestoreStorageLocations.php new file mode 100644 index 0000000..22a9a89 --- /dev/null +++ b/src/Concrete/Restore/Strategy/RestoreStorageLocations.php @@ -0,0 +1,75 @@ +getOutputStyle(); + $output->outputStep('Validating site'); + $install = $this->container->get(ConnectionInterface::class); + if (!$install instanceof ApplicationEnabledConnectionInterface) { + return false; + } + + $app = $install->getApplication(); + /** @var StorageLocationFactory $fileStorageFactory */ + $fileStorageFactory = $app->make(StorageLocationFactory::class); + + // Extract storage locations to a temp dir + $tmp = $job->tempDir('storage'); + $job->getBackup()->extractTo($tmp, 'storage/'); + + $output->outputDone(); + + foreach ($job->getManifest()->getStorageLocations() as $location) { + $output->outputStep('Extracting storage location ' . $location['name']); + + if ($job->isDryRun()) { + $output->outputDryrun(); + continue; + } + + $backupStorage = new Filesystem(new Local($tmp . '/storage/' . $location['id'])); + $locationObject = $fileStorageFactory->fetchByID($location['id']); + + /** @psalm-suppress DeprecatedClass Mountmanager is not removed in v2 */ + $manager = new MountManager([ + 'bu' => $backupStorage, + 'sl' => $locationObject->getFileSystemObject() + ]); + + foreach ($manager->listContents('bu://', true) as $file) { + $path = $file['path']; + if ($file['type'] === 'dir') { + $manager->createDir('sl://' . $path); + continue; + } + + try { + $manager->delete('sl://' . $path); + } catch (FileNotFoundException $e) { + // Ignore + } + $manager->copy('bu:///' . $path, 'sl://' . $path); + } + } + + $this->getOutputStyle()->outputFinal(); + return true; + } +} diff --git a/src/Concrete/Restore/StrategyInterface.php b/src/Concrete/Restore/StrategyInterface.php new file mode 100644 index 0000000..87527ff --- /dev/null +++ b/src/Concrete/Restore/StrategyInterface.php @@ -0,0 +1,9 @@ +inflatedDetectors(); + foreach ($detectors as $detector) { + if ($version = $detector->versionAtPath($path)) { + return $version; + } + } + + return null; + } + + /** + * @return DetectorInterface[] + */ + protected function inflatedDetectors(): array + { + $detectors = $this->detectors; + if (!$this->mapped) { + $detectors = array_map(function ($detector) { + if (is_string($detector)) { + $detector = $this->getContainer()->get($detector); + } + + return $detector; + }, $detectors); + + $this->mapped = true; + $this->detectors = $detectors; + } + + /** @psalm-var DetectorInterface[] */ + return $detectors; + } +} diff --git a/src/Installation/Detector/DetectorInterface.php b/src/Installation/Detector/DetectorInterface.php new file mode 100644 index 0000000..ed27543 --- /dev/null +++ b/src/Installation/Detector/DetectorInterface.php @@ -0,0 +1,11 @@ +loadVersion($versionFile)) { + return Version::fromVersionString($version); + } + } + + return null; + } + protected function loadVersion(string $versionFile): ?string + { + $contents = file_get_contents($versionFile, false, null, 0, 1000); + $matches = []; + if ($contents && preg_match("/\\\$APP_VERSION = '(?.+?)';/", $contents, $matches)) { + return isset($matches['version']) ? $matches['version'] : null; + } + + return null; + } +} diff --git a/src/Installation/Detector/Version7Detector.php b/src/Installation/Detector/Version7Detector.php new file mode 100644 index 0000000..923a995 --- /dev/null +++ b/src/Installation/Detector/Version7Detector.php @@ -0,0 +1,41 @@ +loadVersion($versionFile)) { + return Version::fromVersionString($version); + } + } + + return null; + } + + protected function loadVersion(string $versionFile): ?string + { + $contents = file_get_contents($versionFile, false, null, 0, 1000); + + $matches = []; + if ($contents && preg_match("/\s+'version'\s+=>\s+'(?.+?)',\n/m", $contents, $matches)) { + return isset($matches['version']) ? $matches['version'] : null; + } + + return null; + } +} diff --git a/src/Installation/Factory.php b/src/Installation/Factory.php deleted file mode 100644 index 145b58b..0000000 --- a/src/Installation/Factory.php +++ /dev/null @@ -1,48 +0,0 @@ -path = $path; + $this->version = $version; } /** * @return string */ - public function getPath() + public function getPath(): string { return $this->path; } - + /** + * @return Version + */ + public function getVersion(): Version + { + return $this->version; + } -} \ No newline at end of file + public function getFilesystem(): Filesystem + { + return new Filesystem(new Local($this->getPath())); + } +} diff --git a/src/Installation/InstallationAwareInterface.php b/src/Installation/InstallationAwareInterface.php new file mode 100644 index 0000000..f494005 --- /dev/null +++ b/src/Installation/InstallationAwareInterface.php @@ -0,0 +1,12 @@ +traitInstallation = $connection; + } + + protected function getInstallation(): Installation + { + return $this->traitInstallation; + } +} diff --git a/src/Installation/InstallationDetector.php b/src/Installation/InstallationDetector.php new file mode 100644 index 0000000..624b71e --- /dev/null +++ b/src/Installation/InstallationDetector.php @@ -0,0 +1,66 @@ +versionDetector = $detector; + } + + /** + * Deals with a potential relative path. + * + * @param string $path + * + * @return null|string + */ + protected function normalizePath(string $path): ?string + { + if (!$path) { + // Obtain the current working directory + // (have to do it this way because when of symlinks with local composer) + $path = dirname(getcwd() . DIRECTORY_SEPARATOR . $_SERVER['PHP_SELF']); + + // Load autoload relative to this location + $path .= implode(self::DIRECTORY_SEPARATOR, ['..', '..', self::DIRNAME_COMPOSER_PUBLIC]); + } + + if (substr($path, 0, 1) === '.') { + // This is a relative path + $path = getcwd() . DIRECTORY_SEPARATOR . $path; + } + return realpath($path) ?: null; // realpath just in case. + } + + public function detect(string $path): Installation + { + $path = $this->normalizePath($path); + if (!$path) { + throw new InstallationNotFound('Unable to determine path.'); + } + + $version = $this->versionDetector->versionAtPath($path); + + if (!$version) { + throw InstallationNotFound::atPath($path); + } + + return new Installation($path, $version); + } +} diff --git a/src/Installation/InstallationServiceProvider.php b/src/Installation/InstallationServiceProvider.php new file mode 100644 index 0000000..1dbea82 --- /dev/null +++ b/src/Installation/InstallationServiceProvider.php @@ -0,0 +1,45 @@ +getLeagueContainer()->add(Installation::class, function (): ?Installation { + $factory = $this->getContainer()->get(InstallationDetector::class); + $input = $this->getContainer()->get(InputInterface::class)->getOption('instance'); + if (!is_string($input)) { + throw new \RuntimeException('Invalid instance value given, input must be a string.'); + } + + return $factory->detect($input); + }, true); + + $this->getLeagueContainer() + ->add(InstallationDetector::class) + ->addArgument(BaseDetector::class); + } + + public function boot() + { + $this->getLeagueContainer()->inflector(InstallationAwareInterface::class) + ->invokeMethod('setInstallation', [Installation::class]); + } +} diff --git a/src/Installation/Manifest.php b/src/Installation/Manifest.php new file mode 100644 index 0000000..6d42f12 --- /dev/null +++ b/src/Installation/Manifest.php @@ -0,0 +1,411 @@ +created = new \DateTimeImmutable(); + } + + public function addPackage(string $handle, bool $installed, bool $included): self + { + $self = clone $this; + $self->packages[] = [ + 'handle' => $handle, + 'included' => $included, + 'installed' => $installed, + ]; + + return $self; + } + + /** + * @param string $handle + * @return array + * @psalm-return ?PackageType + */ + public function getPackage(string $handle): ?array + { + foreach ($this->packages as $package) { + if ($package['handle'] === $handle) { + return $package; + } + } + + return null; + } + + public function addStorageLocation(int $id, string $name, bool $default, bool $included): self + { + $self = clone $this; + $self->storageLocations[] = [ + 'id' => $id, + 'name' => $name, + 'default' => $default, + 'included' => $included, + ]; + + return $self; + } + + public function addApplicationItem(string $item): self + { + $self = $this; + if (!in_array($item, $this->applicationContents)) { + $self = clone $this; + $self->applicationContents[] = $item; + sort($self->applicationContents); + } + + return $self; + } + + public function addApplicationItems(array $items): self + { + $self = $this; + foreach ($items as $item) { + $self = $self->addApplicationItem($item); + } + + return $self; + } + + /** + * @return array + * @psalm-return ManifestType + */ + public function jsonSerialize() + { + return [ + 'created' => $this->getDateCreated() ? $this->getDateCreated()->format(self::DATE_FORMAT) : null, + 'site' => $this->getSiteName(), + 'url' => $this->getUrl(), + 'path' => $this->getPath(), + 'host' => $this->getHostName(), + 'version' => $this->getVersion(), + 'contents' => [ + 'application' => !!$this->getApplicationContents(), + 'applicationContents' => $this->getApplicationContents(), + 'core' => $this->includesCore(), + 'database' => $this->getDatabase(), + 'index' => $this->includesIndex(), + 'packages' => $this->getPackages(), + 'storageLocations' => $this->getStorageLocations(), + ] + ]; + } + + /** + * @psalm-suppress MismatchingDocblockParamType + * @psalm-param ManifestType $data + */ + public static function jsonDeserialize(array $data): Manifest + { + $createdDate = dot_get($data, 'created'); + $createdDateTime = $createdDate ? \DateTimeImmutable::createFromFormat(self::DATE_FORMAT, $createdDate) : null; + + $self = new Manifest(); + $self->created = $createdDateTime ?: null; + $self->siteName = dot_get($data, 'site'); + $self->hostName = dot_get($data, 'host'); + $self->url = dot_get($data, 'url'); + $self->installationPath = dot_get($data, 'path'); + $self->version = dot_get($data, 'version'); + $contents = dot_get($data, 'contents', []); + $self->database = dot_get($contents, 'database'); + $self->includeCore = (bool) dot_get($contents, 'core', false); + $self->includeIndex = (bool) dot_get($contents, 'index', false); + $self->applicationContents = (array) dot_get($contents, 'applicationContents'); + + // Load in packages + $packages = (array) dot_get($contents, 'packages'); + foreach ($packages as $package) { + $package = (array) $package; + $self = $self->addPackage( + dot_get($package, 'handle', ''), + dot_get($package, 'installed', false), + dot_get($package, 'included', false) + ); + } + + // Load in storage locations + $locations = (array) dot_get($contents, 'storageLocations'); + foreach ($locations as $location) { + $location = (array) $location; + $self = $self->addStorageLocation( + dot_get($location, 'id', 0), + dot_get($location, 'name', ''), + dot_get($location, 'default', false), + dot_get($location, 'included', false) + ); + } + + return $self; + } + + + /** + * @psalm-return PackageType[] + */ + public function getPackages(): array + { + return $this->packages; + } + + /** + * @psalm-return StorageLocationType[] + */ + public function getStorageLocations(): array + { + return $this->storageLocations; + } + + /** + * @return string[] + */ + public function getApplicationContents(): array + { + return $this->applicationContents; + } + + /** + * @return string|null + */ + public function getDatabase(): ?string + { + return $this->database; + } + + /** + * @return string + */ + public function getVersion(): string + { + return $this->version; + } + + /** + * @return string|null + */ + public function getPath(): ?string + { + return $this->installationPath; + } + + /** + * @return string|null + */ + public function getUrl(): ?string + { + return $this->url; + } + + /** + * @return string|null + */ + public function getHostName(): ?string + { + return $this->hostName; + } + + /** + * @return string|null + */ + public function getSiteName(): ?string + { + return $this->siteName; + } + + /** + * @return bool + */ + public function includesCore(): bool + { + return $this->includeCore; + } + + /** + * @return bool + */ + public function includesIndex(): bool + { + return $this->includeIndex; + } + + /** + * @param string|null $database + * @return Manifest + */ + public function setDatabase(?string $database): Manifest + { + $self = clone $this; + $self->database = $database; + return $self; + } + + /** + * @param string $version + * @return Manifest + */ + public function setVersion(string $version): Manifest + { + $self = clone $this; + $self->version = $version; + return $self; + } + + /** + * @param string|null $installationPath + * @return Manifest + */ + public function setInstallationPath(?string $installationPath): Manifest + { + $self = clone $this; + $self->installationPath = $installationPath; + return $self; + } + + /** + * @param string|null $url + * @return Manifest + */ + public function setUrl(?string $url): Manifest + { + $self = clone $this; + $self->url = $url; + return $self; + } + + /** + * @param string|null $hostName + * @return Manifest + */ + public function setHostName(?string $hostName): Manifest + { + $self = clone $this; + $self->hostName = $hostName; + return $self; + } + + /** + * @param string|null $siteName + * @return Manifest + */ + public function setSiteName(?string $siteName): Manifest + { + $self = clone $this; + $self->siteName = $siteName; + return $self; + } + + /** + * @param bool $includeCore + * @return Manifest + */ + public function setIncludeCore(bool $includeCore): Manifest + { + $self = clone $this; + $self->includeCore = $includeCore; + return $self; + } + + /** + * @param bool $includeIndex + * @return Manifest + */ + public function setIncludeIndex(bool $includeIndex): Manifest + { + $self = clone $this; + $self->includeIndex = $includeIndex; + return $self; + } + + /** + * @param \DateTimeInterface|null $created + */ + public function setDateCreated(?\DateTimeInterface $created): Manifest + { + if ($created instanceof \DateTime) { + $created = \DateTimeImmutable::createFromMutable($created); + } elseif (!$created instanceof \DateTimeImmutable) { + throw new \RuntimeException('Unknown datetime type provided.'); + } + + $self = clone $this; + $self->created = $created; + + return $self; + } + + /** + * @return \DateTimeImmutable|null + */ + public function getDateCreated(): ?\DateTimeImmutable + { + return $this->created; + } +} diff --git a/src/Installation/ManifestFactory.php b/src/Installation/ManifestFactory.php new file mode 100644 index 0000000..1af9c70 --- /dev/null +++ b/src/Installation/ManifestFactory.php @@ -0,0 +1,38 @@ +offsetGet('manifest.json')->getContent(), true); + + if (!$contents) { + throw InvalidManifest::atPath($path); + } + + return Manifest::jsonDeserialize($contents); + } + + public function forConnection(ConnectionInterface $connection): ?Manifest + { + if (!$connection instanceof ApplicationEnabledConnectionInterface) { + return null; + } + + // @TODO Implement this factory method + return new Manifest(); + } +} diff --git a/src/Installation/Validator.php b/src/Installation/Validator.php index 2b7963c..94de459 100644 --- a/src/Installation/Validator.php +++ b/src/Installation/Validator.php @@ -10,23 +10,34 @@ class Validator { - const DIRNAME_CONCRETE = 'concrete'; - const FILENAME_DISPATCHER = 'dispatcher.php'; + public const DIRNAME_CONCRETE = 'concrete'; + public const FILENAME_DISPATCHER = 'dispatcher.php'; public function isValid(Installation $installation): bool { if (!is_dir($installation->getPath())) { - throw new \RuntimeException(sprintf('Unable to locate installation directory: %s', $installation->getPath())); + throw new \RuntimeException( + sprintf( + 'Unable to locate installation directory: %s', + $installation->getPath() + ) + ); } - if (!file_exists( - $installation->getPath() . DIRECTORY_SEPARATOR . self::DIRNAME_CONCRETE . DIRECTORY_SEPARATOR . - self::FILENAME_DISPATCHER)) { - throw new \RuntimeException(sprintf('Installation directory %s does not appear to be a valid Concrete installation', $installation->getPath())); + $path = implode( + DIRECTORY_SEPARATOR, + [$installation->getPath(), self::DIRNAME_CONCRETE, self::FILENAME_DISPATCHER] + ); + + if (!file_exists($path)) { + throw new \RuntimeException( + sprintf( + 'Installation directory %s does not appear to be a valid Concrete installation', + $installation->getPath() + ) + ); } return true; } - - -} \ No newline at end of file +} diff --git a/src/Installation/Version.php b/src/Installation/Version.php new file mode 100644 index 0000000..3f56d38 --- /dev/null +++ b/src/Installation/Version.php @@ -0,0 +1,171 @@ +versionString = $version; + } + + public function getVersion(): string + { + return $this->versionString; + } + + public static function fromVersionString(string $version): Version + { + $version = self::normalizeVersionString($version); + return new self($version); + } + + /** + * Normalize a given version into something that works well with the comparator. + * + * @param string $version + * @param int $segments + * @param bool $validate + * @param bool $keepNoun + * @return string + * + * @psalm-pure + */ + public static function normalizeVersionString( + string $version, + int $segments = 5, + bool $validate = true, + bool $keepNoun = true + ): string { + $version = explode('.', trim($version)); + $finalSegment = array_pop($version); + $withoutNoun = (string)intval($finalSegment); + $noun = substr($finalSegment, strlen($withoutNoun)); + $version[] = $withoutNoun; + + if ($validate && count($version) > $segments) { + throw new InvalidArgumentException('Invalid version number provided, too many version segments.'); + } + + if (count($version) > $segments) { + // Delete segments + $version = array_slice($version, 0, $segments); + } else { + // Add segments + $version = array_pad($version, $segments, '0'); + } + + return implode('.', $version) . ($keepNoun ? $noun : ''); + } + + /** + * @return string + * @psalm-return '5.5'|'5.6'|'5.7'|'8'|'9'|'10' + */ + public function getMajorVersion(): string + { + $version = $this->getVersion(); + if (substr($version, 0, 1) === '5') { + $version = self::normalizeVersionString($version, 2, false, false); + } else { + $version = self::normalizeVersionString($version, 1, false, false); + } + + /** @var '5.6'|'5.7'|'8'|'9' $version */ + return $version; + } + + /** + * Evaluates the expression: $version1 > $version2. + * + * @param string $version + * + * @return bool + */ + public function greaterThan(string $version): bool + { + return Comparator::greaterThan($this->versionString, self::normalizeVersionString($version)); + } + + /** + * Evaluates the expression: $version1 >= $version2. + * + * @param string $version + * + * @return bool + */ + public function greaterThanOrEqualTo(string $version): bool + { + return Comparator::greaterThanOrEqualTo($this->versionString, self::normalizeVersionString($version)); + } + + /** + * Evaluates the expression: $version1 < $version2. + * + * @param string $version + * + * @return bool + */ + public function lessThan(string $version): bool + { + /** @psalm-suppress */ + return Comparator::lessThan($this->versionString, self::normalizeVersionString($version)); + } + + /** + * Evaluates the expression: $version1 <= $version2. + *t + * @param string $version + * + * @return bool + */ + public function lessThanOrEqualTo(string $version): bool + { + return Comparator::lessThanOrEqualTo($this->versionString, self::normalizeVersionString($version)); + } + + /** + * Evaluates the expression: $version1 == $version2. + * + * @param string $version + * + * @return bool + */ + public function equalTo(string $version): bool + { + return Comparator::equalTo($this->versionString, self::normalizeVersionString($version)); + } + + /** + * Evaluates the expression: $version1 != $version2. + * + * @param string $version + * + * @return bool + */ + public function notEqualTo(string $version): bool + { + return Comparator::notEqualTo($this->versionString, self::normalizeVersionString($version)); + } +} diff --git a/src/Util/Config.php b/src/Util/Config.php new file mode 100644 index 0000000..2f39efc --- /dev/null +++ b/src/Util/Config.php @@ -0,0 +1,39 @@ +getPath() . '/.concrete/' . $fileName; + } + + $check[] = Platform::configDirectory() . '/' . $fileName; + $check[] = __DIR__ . '/../../.concrete/' . $fileName; + + foreach ($check as $path) { + if (file_exists($path)) { + return $path; + } + } + + return null; + } + + public static function maintenancePage(Installation $intallation = null): string + { + $path = self::findFile('index.maintenance.php', $intallation); + if ($path) { + return file_get_contents($path); + } + + return 'MAINTENANCE MODE'; + } +} diff --git a/src/Util/Platform.php b/src/Util/Platform.php new file mode 100644 index 0000000..56b206f --- /dev/null +++ b/src/Util/Platform.php @@ -0,0 +1,148 @@ + + */ +class Platform +{ + /** + * Parses tildes and environment variables in paths. + * + * @param string $path + * @return string + */ + public static function expandPath(string $path): string + { + if (preg_match('#^~[\\/]#', $path)) { + return self::getUserDirectory() . substr($path, 1); + } + + return preg_replace_callback( + '#^(\$|(?P%))(?P\w++)(?(percent)%)(?P.*)#', + function (array $matches) { + // Treat HOME as an alias for USERPROFILE on Windows for legacy reasons + if (Platform::isWindows() && $matches['var'] == 'HOME') { + return (getenv('HOME') ?: getenv('USERPROFILE')) . $matches['path']; + } + + return getenv($matches['var']) . $matches['path']; + }, + $path + ); + } + + /** + * @return string The formal user home as detected from environment parameters + * @throws RuntimeException If the user home could not reliably be determined + */ + public static function getUserDirectory(): string + { + if (false !== ($home = getenv('HOME'))) { + return $home; + } + + if (self::isWindows() && false !== ($home = getenv('USERPROFILE'))) { + return $home; + } + + if (function_exists('posix_getuid') && function_exists('posix_getpwuid')) { + $info = posix_getpwuid(posix_getuid()); + + return $info['dir']; + } + + throw new RuntimeException('Could not determine user directory'); + } + + public static function configDirectory(): string + { + $configDir = self::getUserDirectory() . '/.config/concrete'; + if (!file_exists($configDir)) { + mkdir($configDir, 0777, true); + } + + return $configDir; + } + + public static function tempDirectory(bool $createSubpath = false): string + { + $tempDir = self::configDirectory() . '/tmp'; + if (!file_exists($tempDir)) { + mkdir($tempDir, 0777, true); + } + + if ($createSubpath) { + do { + $subpath = $tempDir . '/' . uniqid('tmpdir_'); + } while (file_exists($subpath)); + + mkdir($subpath, 0777, true); + + return $subpath; + } + + return $tempDir; + } + + /** + * @return bool Whether the host machine is running a Windows OS + */ + public static function isWindows() + { + return defined('PHP_WINDOWS_VERSION_BUILD'); + } + + /** + * @param string $str + * @return int return a guaranteed binary length of the string, regardless of silly mbstring configs + */ + public static function strlen(string $str): int + { + static $useMbString = null; + if (null === $useMbString) { + $useMbString = function_exists('mb_strlen') && ini_get('mbstring.func_overload'); + } + + if ($useMbString) { + return mb_strlen($str, '8bit'); + } + + return strlen($str); + } + + /** + * @param ?resource $fd + * @return bool + */ + public static function isTty($fd = null): bool + { + if ($fd === null) { + $fd = defined('STDOUT') ? STDOUT : fopen('php://stdout', 'w'); + } + + // modern cross-platform function, includes the fstat + // fallback so if it is present we trust it + if (function_exists('stream_isatty')) { + return stream_isatty($fd); + } + + // only trusting this if it is positive, otherwise prefer fstat fallback + if (function_exists('posix_isatty') && posix_isatty($fd)) { + return true; + } + + $stat = @fstat($fd); + // Check if formatted mode is S_IFCHR + return $stat ? 0020000 === ($stat['mode'] & 0170000) : false; + } +} diff --git a/src/Util/Ssh.php b/src/Util/Ssh.php new file mode 100644 index 0000000..ffcffe4 --- /dev/null +++ b/src/Util/Ssh.php @@ -0,0 +1,304 @@ +user = $user; + $this->host = $host; + $this->port = $port; + $this->processConfigurationClosure = function (Process $process): void { + }; + $this->onOutput = function (string $type, string $line): void { + }; + } + + /** + * @param string $user + * @param string $host + * @param int|null $port + * @return static + */ + public static function create(string $user, string $host, int $port = null): self + { + return new Ssh($user, $host, $port); + } + + public function withPrivateKey(string $pathToPrivateKey): self + { + $self = clone $this; + $self->pathToPrivateKey = $pathToPrivateKey; + + return $self; + } + + /** + * @param int $port + * @return $this + * @throws Exception If the port is negative + */ + public function withPort(int $port): self + { + if ($port < 0) { + throw new Exception('Port must be a positive integer.'); + } + $self = clone $this; + $self->port = $port; + + return $self; + } + + public function withProcessConfiguration(Closure $processConfigurationClosure): self + { + $self = clone $this; + $self->processConfigurationClosure = $processConfigurationClosure; + + return $self; + } + + public function withOnOutput(Closure $onOutput): self + { + $self = clone $this; + $self->onOutput = $onOutput; + + return $self; + } + + public function withStrictHostKeyChecking(): self + { + $self = clone $this; + $self->enableStrictHostChecking = true; + + return $self; + } + + public function withoutStrictHostKeyChecking(): self + { + $self = clone $this; + $self->enableStrictHostChecking = false; + + return $self; + } + + public function withQuietMode(): self + { + $self = clone $this; + $self->quietMode = true; + + return $self; + } + + public function withoutQuietMode(): self + { + $self = clone $this; + $self->quietMode = false; + + return $self; + } + + public function withoutPasswordAuthentication(): self + { + $self = clone $this; + $self->enablePasswordAuthentication = false; + + return $self; + } + + public function withPasswordAuthentication(): self + { + $self = clone $this; + $self->enablePasswordAuthentication = true; + + return $self; + } + + /** + * @param string|array $command + * @return string + */ + public function getExecuteCommand($command): string + { + $commands = $this->wrapArray($command); + $extraOptions = $this->getExtraSshOptions(); + $commandString = implode(PHP_EOL, $commands); + $delimiter = 'EOF-SPATIE-SSH'; + $target = $this->getTarget(); + + return "{$this->sshExecutable} {$extraOptions} {$target} 'bash -se' << \\$delimiter" . PHP_EOL + . $commandString . PHP_EOL + . $delimiter; + } + + /** + * @param string|array $command + * + * @return Process + */ + public function execute($command): Process + { + $sshCommand = $this->getExecuteCommand($command); + + return $this->run($sshCommand); + } + + /** + * @param string|array $command + * + * @return Process + */ + public function executeAsync($command): Process + { + $sshCommand = $this->getExecuteCommand($command); + + return $this->run($sshCommand, 'start'); + } + + /** + * @param string $sourcePath + * @param string $destinationPath + * @return string + */ + public function getDownloadCommand(string $sourcePath, string $destinationPath): string + { + return "{$this->scpExecutable} {$this->getExtraScpOptions()} {$this->getTarget()}:$sourcePath $destinationPath"; + } + + public function download(string $sourcePath, string $destinationPath): Process + { + $downloadCommand = $this->getDownloadCommand($sourcePath, $destinationPath); + + return $this->run($downloadCommand); + } + + public function getUploadCommand(string $sourcePath, string $destinationPath): string + { + return "{$this->scpExecutable} {$this->getExtraScpOptions()} $sourcePath {$this->getTarget()}:$destinationPath"; + } + + public function upload(string $sourcePath, string $destinationPath): Process + { + $uploadCommand = $this->getUploadCommand($sourcePath, $destinationPath); + + return $this->run($uploadCommand); + } + + protected function getExtraSshOptions(): string + { + $extraOptions = $this->getExtraOptions(); + + if (!is_null($this->port)) { + $extraOptions[] = "-p {$this->port}"; + } + + return implode(' ', $extraOptions); + } + + /** + * @return string + */ + protected function getExtraScpOptions(): string + { + $extraOptions = $this->getExtraOptions(); + + $extraOptions[] = '-r'; + + if (!is_null($this->port)) { + $extraOptions[] = "-P {$this->port}"; + } + + return implode(' ', $extraOptions); + } + + /** + * @return array + */ + private function getExtraOptions(): array + { + $extraOptions = []; + + if ($this->pathToPrivateKey) { + $extraOptions[] = "-i {$this->pathToPrivateKey}"; + } + + if (!$this->enableStrictHostChecking) { + $extraOptions[] = '-o StrictHostKeyChecking=no'; + $extraOptions[] = '-o UserKnownHostsFile=/dev/null'; + } + + if (!$this->enablePasswordAuthentication) { + $extraOptions[] = '-o PasswordAuthentication=no'; + } + + if ($this->quietMode) { + $extraOptions[] = '-q'; + } + + return $extraOptions; + } + + /** + * @param array|string $arrayOrString + * @return array + */ + protected function wrapArray($arrayOrString): array + { + return (array)$arrayOrString; + } + + public function run(string $command, string $method = 'run'): Process + { + $process = \process($command, null, null, null, 0); + ($this->processConfigurationClosure)($process); + $process->{$method}($this->onOutput); + + return $process; + } + + protected function getTarget(): string + { + return "{$this->user}@{$this->host}"; + } +} diff --git a/tests/Installation/Detector/AbstractVersionDetectorTest.php b/tests/Installation/Detector/AbstractVersionDetectorTest.php new file mode 100644 index 0000000..80adde9 --- /dev/null +++ b/tests/Installation/Detector/AbstractVersionDetectorTest.php @@ -0,0 +1,66 @@ + [], + 'invalid' => [], + ]; + + /** + * @dataProvider validPaths + */ + public function testDetectsValidPaths(string $path, string $expected) + { + $detector = new $this->class(); + $version = $detector->versionAtPath($path); + + $this->assertNotNull($version, 'Expected version not matched at path ' . $path); + $this->assertEquals( + Version::normalizeVersionString($expected), + $version->getVersion(), + 'Invalid version matched for path.' + ); + } + + /** + * @dataProvider invalidPaths + */ + public function testSkipsInvalidPaths(string $path) + { + $detector = new $this->class(); + $this->assertNull($detector->versionAtPath($path), 'Found version where none should be found.'); + } + + public function validPaths() + { + $base = realpath(__DIR__ . '/../../../'); + return array_map( + function ($path) use ($base) { + return [$base . '/' . ltrim($path[0], '/'), $path[1]]; + }, + $this->paths['valid'] + ); + } + + public function invalidPaths() + { + $base = realpath(__DIR__ . '/../../../'); + return array_map( + function ($path) use ($base) { + return [$base . $path[0]]; + }, + $this->paths['invalid'] + ); + } +} diff --git a/tests/Installation/Detector/Version6DetectorTest.php b/tests/Installation/Detector/Version6DetectorTest.php new file mode 100644 index 0000000..f55208e --- /dev/null +++ b/tests/Installation/Detector/Version6DetectorTest.php @@ -0,0 +1,19 @@ + [ + ['tests/fixtures/adapter/v6/Installed', '5.6.4'], + ], + 'invalid' => [ + ['tests/fixtures/adapter/v6/NotInstalled'], + ['tests/fixtures/adapter/v7'], + ['tests/fixtures/adapter/v8'], + ] + ]; +} diff --git a/tests/Installation/Detector/Version7DetectorTest.php b/tests/Installation/Detector/Version7DetectorTest.php new file mode 100644 index 0000000..b3f0c95 --- /dev/null +++ b/tests/Installation/Detector/Version7DetectorTest.php @@ -0,0 +1,21 @@ + [ + ['tests/fixtures/adapter/v7', '5.7.5.13'], + ['tests/fixtures/adapter/v8', '8.6.0a2'], + ], + 'invalid' => [ + ['tests/fixtures/adapter/v6/NotInstalled'], + ['tests/fixtures/adapter/v6/Installed'], + ] + ]; +} diff --git a/tests/Installation/InstallationDetectorTest.php b/tests/Installation/InstallationDetectorTest.php new file mode 100644 index 0000000..fd45170 --- /dev/null +++ b/tests/Installation/InstallationDetectorTest.php @@ -0,0 +1,13 @@ +setDateCreated(\DateTime::createFromFormat(Manifest::DATE_FORMAT, '2021-03-10T20:40:50+0000')) + ->setSiteName('concrete5 Site') + ->setUrl('https://some.url') + ->setInstallationPath('/home/foo/concrete5') + ->setHostName('foo.local') + ->setVersion('1.0') + ->setDatabase('db.sql') + ->setIncludeCore(true) + ->setIncludeIndex(true) + + // Add application stuff + ->addApplicationItems([ + 'views', + 'tools', + 'themes', + 'src', + 'single_pages', + 'page_templates', + 'mail', + 'languages', + 'attributes', + 'authentication', + 'blocks', + 'bootstrap', + 'config', + 'controllers', + 'elements', + 'jobs', + ]) + + // Add storage locations + ->addStorageLocation(1, 'red', true, true) + ->addStorageLocation(2, 'blue', true, false) + ->addStorageLocation(3, 'green', false, true) + ->addStorageLocation(4, 'yellow', false, false) + + // Add Packages + ->addPackage('foo', true, true) + ->addPackage('baz', false, true) + ->addPackage('zab', true, false) + ->addPackage('bar', false, false) + ; + + $contents = json_decode(file_get_contents(__DIR__ . '/../fixtures/manifest/complete.json'), true); + $this->assertSame($contents, $manifest->jsonSerialize()); + } + + /** + * @dataProvider filesToTest + */ + public function testCompleteJson(string $file): void + { + $contents = json_decode(file_get_contents(__DIR__ . '/../fixtures/manifest/' . $file), true); + $manifest = Manifest::jsonDeserialize($contents); + + // Validate the manifest against expected values + $this->assertEquals($contents, $manifest->jsonSerialize()); + } + + public function filesToTest(): iterable + { + yield ['complete.json']; + yield ['opposite.json']; + } +} diff --git a/tests/Installation/VersionTest.php b/tests/Installation/VersionTest.php new file mode 100644 index 0000000..8277de4 --- /dev/null +++ b/tests/Installation/VersionTest.php @@ -0,0 +1,90 @@ + $expected) { + $this->assertEquals($expected, $version->{$method}($compare), "{$method} {$errorString}"); + } + } + + public function versionComparisons(): array + { + $greater = [true, true, false, false, false, true]; + $equal = [false, true, false, true, true, false]; + $less = [false, false, true, true, false, true]; + + return [ + // Long version chains vs short version numbers + ['5.7.0', '5.7', $equal], + ['5.7.0.0.1', '5.7', $greater], + ['5.6.0.0.1', '5.7', $less], + ['5.7.5.2', '5', $greater], + ['5.7.5.2', '5.7.5.2', $equal], + ['5.7.5.2', '5.7.5.3', $less], + + // Broad spectrum + ['5.6', '9', $less], + ['9', '5.6', $greater], + + // Versions with nouns + ['8.5.0RC1', '8.5.0', $less], + ['8.5.0RC1', '8.5.0RC2', $less], + ['8.5.0RC3', '8.5.0RC2', $greater], + ['8.5.0-alpha', '8.5.0-beta', $less], + ['8.5.1-alpha', '8.5.0', $greater], + ['5.5.2.2a1', '5.5.2.2', $less], + ['5.5.2.2a1', '5.5.2.1', $greater], + ]; + } + + /** + * @dataProvider majorVersions + * @param string $version + * @param string $expected + */ + public function testMajorVersion(string $version, string $expected) + { + $version = Version::fromVersionString($version); + $this->assertEquals($expected, $version->getMajorVersion()); + } + + public function majorVersions(): array + { + return [ + ['5.5.2.2a1', '5.5'], + ['5.6.5.5.1RC2', '5.6'], + ['5.7.5.5.1', '5.7'], + ['8.5.5.1', '8'], + ['9', '9'], + ['9.0.0.0.0beta', '9'], + ['10.1.1', '10'], + ]; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..5f8d156 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,26 @@ +version; + } +} diff --git a/tests/fixtures/adapter/v6/Installed/concrete/config/version.php b/tests/fixtures/adapter/v6/Installed/concrete/config/version.php new file mode 100644 index 0000000..c277d19 --- /dev/null +++ b/tests/fixtures/adapter/v6/Installed/concrete/config/version.php @@ -0,0 +1,3 @@ + '5.7.5.13', + 'version_installed' => '5.7.5.13', + 'version_db' => '20160615000000', // the key of the latest database migration + + /** + * Installation status + * + * @var bool + */ + 'installed' => true, + + /** + * The current Site Name + * + * @var string concrete.core.site + */ + 'site' => 'concrete5', + + /** + * The current Locale + */ + 'locale' => 'en_US', + + /** + * The current Charset + */ + 'charset' => 'UTF-8', + + /** + * Maintenance mode + */ + 'maintenance_mode' => false, + + /** + * ------------------------------------------------------------------------ + * Debug settings + * ------------------------------------------------------------------------ + */ + 'debug' => array( + /** + * Display errors + * + * @var bool + */ + 'display_errors' => true, + + /** + * Site debug level + * + * @var string (message|debug) + */ + 'detail' => 'message' + ), + + /** + * ------------------------------------------------------------------------ + * Proxy Settings + * ------------------------------------------------------------------------ + */ + 'proxy' => array( + 'host' => null, + 'port' => null, + 'user' => null, + 'password' => null + ), + + /** + * ------------------------------------------------------------------------ + * File upload settings + * ------------------------------------------------------------------------ + */ + 'upload' => array( + + /** + * Allowed file extensions + * + * @var string semi-colon separated. + */ + 'extensions' => '*.flv;*.jpg;*.gif;*.jpeg;*.ico;*.docx;*.xla;*.png;*.psd;*.swf;*.doc;*.txt;*.xls;*.xlsx;' . + '*.csv;*.pdf;*.tiff;*.rtf;*.m4a;*.mov;*.wmv;*.mpeg;*.mpg;*.wav;*.3gp;*.avi;*.m4v;*.mp4;*.mp3;*.qt;*.ppt;' . + '*.pptx;*.kml;*.xml;*.svg;*.webm;*.ogg;*.ogv' + ), + + /** + * ------------------------------------------------------------------------ + * Mail settings + * ------------------------------------------------------------------------ + */ + 'mail' => array( + 'method' => 'PHP_MAIL', + 'methods' => array( + 'smtp' => array( + 'server' => '', + 'port' => '', + 'username' => '', + 'password' => '', + 'encryption' => '' + ) + ) + ), + + /** + * ------------------------------------------------------------------------ + * Cache settings + * ------------------------------------------------------------------------ + */ + 'cache' => array( + + /** + * Enabled + * + * @var bool + */ + 'enabled' => true, + + /** + * Lifetime + * + * @var int Seconds + */ + 'lifetime' => 21600, + + /** + * Cache overrides + * + * @var bool + */ + 'overrides' => true, + + /** + * Cache Blocks + * + * @var bool + */ + 'blocks' => true, + + /** + * Cache Assets + * + * @var bool + */ + 'assets' => false, + + /** + * Cache Theme CSS/JS + * + * @var bool + */ + 'theme_css' => true, + + /** + * Cache full page + * + * @var bool|string (block|all) + */ + 'pages' => false, + + /** + * Use Doctrine development mode + * + * @var bool + */ + 'doctrine_dev_mode' => false, + + /** + * How long to cache full page + * + * @var string + */ + 'full_page_lifetime' => 'default', + + /** + * Custom lifetime value, only used if concrete.cache.full_page_lifetime is 'custom' + * + * @var int + */ + 'full_page_lifetime_value' => null, + + /** + * Calculate the cache key reading the assets contents (true) of the assets modification time (false). + * + * @var bool + */ + 'full_contents_assets_hash' => false, + + 'directory' => DIR_FILES_UPLOADED_STANDARD . '/cache', + /** + * Relative path to the cache directory. If empty it'll be calculated from concrete.cache.directory + * @var string|null + */ + 'directory_relative' => null, + 'page' => array( + 'directory' => DIR_FILES_UPLOADED_STANDARD . '/cache/pages', + 'adapter' => 'file', + ), + 'environment' => array( + 'file' => 'environment.cache' + ), + + 'levels' => array( + 'expensive' => array( + 'drivers' => array( + 'core_ephemeral' => array( + 'class' => '\Stash\Driver\Ephemeral', + 'options' => array() + ), + + 'core_filesystem' => array( + 'class' => '\Stash\Driver\FileSystem', + 'options' => array( + 'path' => DIR_FILES_UPLOADED_STANDARD . '/cache', + 'dirPermissions' => DIRECTORY_PERMISSIONS_MODE_COMPUTED, + 'filePermissions' => FILE_PERMISSIONS_MODE_COMPUTED + ) + ), + ) + ), + 'object' => array( + 'drivers' => array( + 'core_ephemeral' => array( + 'class' => '\Stash\Driver\Ephemeral', + 'options' => array() + ) + ) + ) + ) + + ), + + 'multilingual' => array( + 'redirect_home_to_default_locale' => false, + 'use_browser_detected_locale' => false, + 'default_locale' => false, + 'default_source_locale' => 'en_US' + ), + + 'design' => array( + 'enable_custom' => true, + 'enable_layouts' => true + ), + + /** + * ------------------------------------------------------------------------ + * Logging settings + * ------------------------------------------------------------------------ + */ + 'log' => array( + + /** + * Log emails + * + * @var bool + */ + 'emails' => true, + + /** + * Log Errors + * + * @var bool + */ + 'errors' => true, + + /** + * Log Spam + * + * @var bool + */ + 'spam' => false, + + + 'queries' => array( + + /** + * Whether to log database queries or not. + * + * @var bool + */ + 'log' => false, + + + 'clear_on_reload' => false + + + + ) + ), + 'jobs' => array( + + 'enable_scheduling' => true + + ), + + 'filesystem' => array( + /** Temporary directory. + * @link \Concrete\Core\File\Service\File::getTemporaryDirectory + */ + 'temp_directory' => null, + 'permissions' => array( + 'file' => FILE_PERMISSIONS_MODE_COMPUTED, + 'directory' => DIRECTORY_PERMISSIONS_MODE_COMPUTED + ) + ), + + 'editor' => array( + 'concrete' => array( + 'enable_filemanager' => true, + 'enable_sitemap' => true + ), + 'plugins' => array( + 'selected' => array( + 'concrete5lightbox', + 'undoredo', + 'specialcharacters', + 'table' + ) + ) + ), + + /** + * ------------------------------------------------------------------------ + * Email settings + * ------------------------------------------------------------------------ + */ + 'email' => array( + + /** + * Enable emails + * + * @var bool + */ + 'enabled' => true, + 'default' => array( + 'address' => 'concrete5-noreply@' . (isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'localhost'), + 'name' => '' + ), + 'form_block' => array( + 'address' => false + ), + 'forgot_password' => array( + 'address' => null, + 'name' => null, + ), + 'validate_registration' => array( + 'address' => null, + 'name' => null, + ), + ), + + /** + * ------------------------------------------------------------------------ + * Marketplace settings + * ------------------------------------------------------------------------ + */ + 'marketplace' => array( + /** + * Enable marketplace integration + * + * @var bool concrete.marketplace.enabled + */ + 'enabled' => true, + + /** + * Time it takes for a request to timeout + * + * @var int concrete.marketplace.request_timeout + */ + 'request_timeout' => 30, + + /** + * Marketplace Token + * + * @var null|string concrete.marketplace.token + */ + 'token' => null, + + /** + * Marketplace Site url Token + * + * @var null|string concrete.marketplace.site_token + */ + 'site_token' => null, + + /** + * Enable intelligent search integration + * + * @var bool concrete.marketplace.intelligent_search + */ + 'intelligent_search' => true, + + /** + * Log requests + * + * @var bool concrete.marketplace.log_requests + */ + 'log_requests' => false + ), + + /** + * ------------------------------------------------------------------------ + * Getting external news and help from concrete5.org + * ------------------------------------------------------------------------ + */ + 'external' => array( + + /** + * Provide help within the intelligent search + * + * @var bool concrete.external.intelligent_search_help + */ + 'intelligent_search_help' => true, + + /** + * Display an overlay with up-to-date news from concrete5 + * + * @var bool concrete.external.news_overlay + */ + 'news_overlay' => true, + + /** + * Enable concrete5 news within your site + * + * @var bool concrete.external.news + */ + 'news' => true, + ), + + /** + * -------------------------------------------------------------------- + * Miscellaneous settings + * -------------------------------------------------------------------- + */ + 'misc' => array( + 'user_timezones' => false, + 'package_backup_directory' => DIR_FILES_UPLOADED_STANDARD . '/trash', + 'enable_progressive_page_reindex' => true, + 'mobile_theme_id' => 0, + 'sitemap_approve_immediately' => true, + 'enable_translate_locale_en_us' => false, + 'page_search_index_lifetime' => 259200, + 'enable_trash_can' => true, + 'app_version_display_in_header' => true, + 'default_jpeg_image_compression' => 80, + 'help_overlay' => true, + 'require_version_comments' => false, + ), + + 'theme' => array( + + 'compress_preprocessor_output' => true, + 'generate_less_sourcemap' => false, + ), + + 'updates' => array( + + 'enable_auto_update_core' => false, + 'enable_auto_update_packages' => false, + 'enable_permissions_protection' => true, + 'check_threshold' => 172800, + 'services' => array( + 'get_available_updates' => 'http://www.concrete5.org/tools/update_core', + 'inspect_update' => 'http://www.concrete5.org/tools/inspect_update' + ) + ), + 'paths' => array( + 'trash' => '/!trash', + 'drafts' => '/!drafts' + ), + 'icons' => array( + 'page_template' => array( + 'width' => 120, + 'height' => 90 + ), + 'theme_thumbnail' => array( + 'width' => 120, + 'height' => 90 + ), + 'file_manager_listing' => array( + 'handle' => 'file_manager_listing', + 'width' => 60, + 'height' => 60 + ), + 'file_manager_detail' => array( + 'handle' => 'file_manager_detail', + 'width' => 400 + ), + 'user_avatar' => array( + 'width' => 80, + 'height' => 80, + 'default' => ASSETS_URL_IMAGES . '/avatar_none.png' + ) + ), + + 'file_manager' => array( + 'images' => array( + 'use_exim_data_to_rotate_images' => false, + 'manipulation_library' => 'gd' + ), + 'results' => 10 + ), + + 'sitemap_xml' => array( + 'file' => 'sitemap.xml', + 'frequency' => 'weekly', + 'priority' => 0.5 + ), + + /** + * ------------------------------------------------------------------------ + * Accessibility + * ------------------------------------------------------------------------ + */ + 'accessibility' => array( + /** + * Show titles in the concrete5 toolbars + * + * @var bool + */ + 'toolbar_titles' => false, + + /** + * Increase the font size in the concrete5 toolbars + * + * @var bool + */ + 'toolbar_large_font' => false, + + /** + * Show help system + * + * @var bool + */ + 'display_help_system' => true + ), + + /** + * ------------------------------------------------------------------------ + * Internationalization + * ------------------------------------------------------------------------ + */ + 'i18n' => array( + + /** + * Allow users to choose language on login + * + * @var bool + */ + 'choose_language_login' => false + + ), + 'urls' => array( + 'concrete5' => 'http://www.concrete5.org', + 'concrete5_secure' => 'https://www.concrete5.org', + 'newsflow' => 'http://newsflow.concrete5.org', + 'background_feed' => '//backgroundimages.concrete5.org/wallpaper', + 'background_feed_secure' => 'https://backgroundimages.concrete5.org/wallpaper', + 'background_info' => 'http://backgroundimages.concrete5.org/get_image_data.php', + 'help' => array( + 'developer' => 'http://www.concrete5.org/documentation/developers/5.7/', + 'user' => 'http://www.concrete5.org/documentation/using-concrete5-7', + 'forum' => 'http://www.concrete5.org/community/forums' + ), + 'paths' => array( + 'menu_help_service' => '/tools/get_remote_help_list/', + 'site_page' => '/private/sites', + 'newsflow_slot_content' => '/tools/slot_content/', + 'marketplace' => array( + 'connect' => '/marketplace/connect', + 'connect_success' => '/marketplace/connect/-/connected', + 'connect_validate' => '/marketplace/connect/-/validate', + 'connect_new_token' => '/marketplace/connect/-/generate_token', + 'checkout' => '/cart/-/add/', + 'purchases' => '/marketplace/connect/-/get_available_licenses', + 'item_information' => '/marketplace/connect/-/get_item_information', + 'item_free_license' => '/marketplace/connect/-/enable_free_license', + 'remote_item_list' => '/marketplace/' + ) + ) + ), + + /** + * ------------------------------------------------------------------------ + * White labeling. + * ------------------------------------------------------------------------ + */ + 'white_label' => array( + + /** + * Custom Logo source path relative to the public directory. + * + * @var bool|string The logo path + */ + 'logo' => false, + + /** + * Custom Name + * + * @var bool|string The name + */ + 'name' => false, + + /** + * Dashboard background image url + * + * @var null|string + */ + 'dashboard_background' => null + ), + 'session' => array( + + 'name' => 'CONCRETE5', + 'handler' => 'file', + 'save_path' => null, + 'max_lifetime' => 7200, + 'cookie' => array( + 'cookie_path' => false, // set a specific path here if you know it, otherwise it'll default to relative + 'cookie_lifetime' => 0, + 'cookie_domain' => false, + 'cookie_secure' => false, + 'cookie_httponly' => true + ) + ), + + /** + * ------------------------------------------------------------------------ + * User information and registration settings. + * ------------------------------------------------------------------------ + */ + 'user' => array( + /** + * -------------------------------------------------------------------- + * Registration settings. + * -------------------------------------------------------------------- + */ + 'registration' => array( + + /** + * Registration + * + * @var bool + */ + 'enabled' => false, + + /** + * Registration type + * + * @var string The type (disabled|enabled|validate_email|manual_approve) + */ + 'type' => 'disabled', + + /** + * Enable Registration Captcha + * + * @var bool + */ + 'captcha' => true, + + /** + * Use emails instead of usernames to log in + * + * @var bool + */ + 'email_registration' => false, + + /** + * Validate emails during registration + * + * @var bool + */ + 'validate_email' => false, + + /** + * Admins approve each registration + * + * @var bool + */ + 'approval' => false, + + /** + * Send notifications after successful registration. + * + * @var bool|string Email to notify + */ + 'notification' => false + ), + + /** + * -------------------------------------------------------------------- + * Gravatar Settings + * -------------------------------------------------------------------- + */ + 'gravatar' => array( + 'enabled' => false, + 'max_level' => 0, + 'image_set' => 0 + ), + 'group' => array( + + 'badge' => array( + + 'default_point_value' => 50 + ) + + ), + + /** + * Enable public user profiles + * + * @var bool + */ + 'profiles_enabled' => false, + + 'username' => array( + 'maximum' => 64, + 'minimum' => 3, + 'allow_spaces' => false + + ), + 'password' => array( + 'maximum' => 128, + 'minimum' => 5, + 'hash_portable' => false, + 'hash_cost_log2' => 12, + 'legacy_salt' => '', + ), + 'private_messages' => array( + 'throttle_max' => 20, + 'throttle_max_timespan' => 15 // minutes + ) + + ), + + /** + * ------------------------------------------------------------------------ + * Spam + * ------------------------------------------------------------------------ + */ + 'spam' => array( + /** + * Whitelist group ID + * + * @var int + */ + 'whitelist_group' => 0, + + /** + * Notification email + * + * @var string + */ + 'notify_email' => '' + ), + + /** + * ------------------------------------------------------------------------ + * Security + * ------------------------------------------------------------------------ + */ + 'security' => array( + 'session' => array( + + 'invalidate_on_user_agent_mismatch' => true, + + 'invalidate_on_ip_mismatch' => true + + ), + 'ban' => array( + 'ip' => array( + + 'enabled' => true, + + /** + * Maximum attempts + */ + 'attempts' => 5, + + /** + * Threshold time + */ + 'time' => 300, + + /** + * Ban length in minutes + */ + 'length' => 10 + ) + ), + 'misc' => array( + + /** + * Defence Click Jacking. + * + * @var bool|string DENY, SAMEORIGIN, ALLOW-FROM uri + */ + 'x_frame_options' => 'SAMEORIGIN' + ) + ), + + /** + * ------------------------------------------------------------------------ + * Permissions and behaviors toggles. + * ------------------------------------------------------------------------ + */ + 'permissions' => array( + /** + * Forward to login if access is denied + * + * @var bool + */ + 'forward_to_login' => true, + + /** + * Permission model + * + * @var string The permission model (simple|advanced) + */ + 'model' => 'simple', + ), + + /** + * ------------------------------------------------------------------------ + * SEO Settings + * ------------------------------------------------------------------------ + */ + 'seo' => array( + + 'tracking' => array( + /** + * User defined tracking code + * + * @var string + */ + 'code' => '', + + /** + * Tracking code position + * + * @var string (top|bottom) + */ + 'code_position' => 'bottom' + + ), + 'exclude_words' => 'a, an, as, at, before, but, by, for, from, is, in, into, like, of, off, on, onto, per, ' . + 'since, than, the, this, that, to, up, via, with', + + /** + * URL rewriting + * + * Doesn't impact concrete.seo.url_rewriting_all which is set at a lower level and + * controls whether ALL items will be rewritten. + * + * @var bool + */ + 'url_rewriting' => false, + 'url_rewriting_all' => false, + 'redirect_to_canonical_url' => false, + 'canonical_url' => null, + 'canonical_ssl_url' => null, + 'trailing_slash' => false, + 'title_format' => '%1$s :: %2$s', + 'title_segment_separator' => ' :: ', + 'page_path_separator' => '-', + 'group_name_separator' => ' / ', + 'segment_max_length' => 128, + 'paging_string' => 'ccm_paging_p' + ), + + /** + * ------------------------------------------------------------------------ + * Statistics Settings + * ------------------------------------------------------------------------ + */ + 'statistics' => array( + 'track_downloads' => true + ), + 'limits' => array( + 'sitemap_pages' => 100, + 'delete_pages' => 100, + 'copy_pages' => 10, + 'page_search_index_batch' => 200, + 'job_queue_batch' => 10, + 'style_customizer' => array( + 'size_min' => -50, + 'size_max' => 200, + ) + ), + + 'page' => array( + 'search' => array( + // Always reindex pages (usually it isn't performed when approving workflows) + 'always_reindex' => false, + ) + ), +); diff --git a/tests/fixtures/adapter/v8/concrete/bootstrap/autoload.php b/tests/fixtures/adapter/v8/concrete/bootstrap/autoload.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/adapter/v8/concrete/bootstrap/configure.php b/tests/fixtures/adapter/v8/concrete/bootstrap/configure.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/adapter/v8/concrete/bootstrap/start.php b/tests/fixtures/adapter/v8/concrete/bootstrap/start.php new file mode 100644 index 0000000..2e7723c --- /dev/null +++ b/tests/fixtures/adapter/v8/concrete/bootstrap/start.php @@ -0,0 +1,34 @@ +app = $app; + } + + public function boot() + { + $this->app->booted = true; + } + } +} + +namespace Concrete\Core\Application { + + class Application + { + public $booted = false; + + public function getRuntime() + { + return new \Concrete\Core\Foundation\Runtime\Runtime($this); + } + } + + return new Application(); +} diff --git a/tests/fixtures/adapter/v8/concrete/config/concrete.php b/tests/fixtures/adapter/v8/concrete/config/concrete.php new file mode 100644 index 0000000..5f1c8a1 --- /dev/null +++ b/tests/fixtures/adapter/v8/concrete/config/concrete.php @@ -0,0 +1,1190 @@ + '8.6.0a2', + 'version_installed' => '8.6.0a2', + 'version_db' => '20200501000000', // the key of the latest database migration + + /* + * Installation status + * + * @var bool + */ + 'installed' => true, + + /* + * The current Locale + */ + 'locale' => 'en_US', + + /* + * The current Charset + */ + 'charset' => 'UTF-8', + + /* + * The byte-order-mark for the current charset + */ + 'charset_bom' => "\xEF\xBB\xBF", + + /* + * Maintenance mode + */ + 'maintenance_mode' => false, + + /* + * ------------------------------------------------------------------------ + * Debug settings + * ------------------------------------------------------------------------ + */ + 'debug' => [ + /* + * Display errors + * + * @var bool + */ + 'display_errors' => true, + + /* + * Site debug level + * + * @var string (message|debug) + */ + 'detail' => 'debug', + + /* + * Error reporting level + * + * @var int|null + */ + 'error_reporting' => null, + ], + + /* + * ------------------------------------------------------------------------ + * Proxy Settings + * ------------------------------------------------------------------------ + */ + 'proxy' => [ + 'host' => null, + 'port' => null, + 'user' => null, + 'password' => null, + ], + + /* + * ------------------------------------------------------------------------ + * File upload settings + * ------------------------------------------------------------------------ + */ + 'upload' => [ + /* + * Allowed file extensions + * + * @var string semi-colon separated. + */ + 'extensions' => '*.flv;*.jpg;*.gif;*.jpeg;*.ico;*.docx;*.xla;*.png;*.psd;*.swf;*.doc;*.txt;*.xls;*.xlsx;' . + '*.csv;*.pdf;*.tiff;*.rtf;*.m4a;*.mov;*.wmv;*.mpeg;*.mpg;*.wav;*.3gp;*.avi;*.m4v;*.mp4;*.mp3;*.qt;*.ppt;' . + '*.pptx;*.kml;*.xml;*.svg;*.webm;*.ogg;*.ogv', + /* + * Disallowed file extension list (takes the precedence over the extensions whitelist). + * + * @var string semi-colon separated. + */ + 'extensions_blacklist' => '*.php;*.php2;*.php3;*.php4;*.php5;*.php7;*.php8;*.phtml;*.phar;*.htaccess;*.pl;*.phpsh;*.pht;*.shtml;*.cgi', + + 'chunking' => [ + // Enable uploading files in chunks? + 'enabled' => true, + // The chunk size (if empty we'll automatically determine it) + 'chunkSize' => null, + ], + ], + + /* + * ------------------------------------------------------------------------ + * Export settings + * ------------------------------------------------------------------------ + */ + 'export' => [ + 'csv' => [ + // Include the BOM (byte-order mark) in generated CSV files? + // @var bool + 'include_bom' => false, + 'datetime_format' => 'ATOM', + ], + ], + + /* + * ------------------------------------------------------------------------ + * Interface settings + * ------------------------------------------------------------------------ + */ + 'interface' => [ + 'panel' => [ + /* + * Enable the page relations panel + */ + 'page_relations' => false, + ], + ], + + /* + * ------------------------------------------------------------------------ + * Mail settings + * ------------------------------------------------------------------------ + */ + 'mail' => [ + 'method' => 'PHP_MAIL', + 'methods' => [ + 'smtp' => [ + 'server' => '', + 'port' => '', + 'username' => '', + 'password' => '', + 'encryption' => '', + 'messages_per_connection' => null, + ], + ], + ], + + /* + * ------------------------------------------------------------------------ + * Cache settings + * ------------------------------------------------------------------------ + */ + 'cache' => [ + /* + * Enabled + * + * @var bool + */ + 'enabled' => true, + + /* + * Lifetime + * + * @var int Seconds + */ + 'lifetime' => 21600, + + /* + * Cache overrides + * + * @var bool + */ + 'overrides' => true, + + /* + * Cache Blocks + * + * @var bool + */ + 'blocks' => true, + + /* + * Cache Assets + * + * @var bool + */ + 'assets' => false, + + /* + * Cache Theme CSS/JS + * + * @var bool + */ + 'theme_css' => true, + + /* + * Cache full page + * + * @var bool|string (blocks|all) + */ + 'pages' => false, + + /* + * Use Doctrine development mode + * + * @var bool + */ + 'doctrine_dev_mode' => false, + + /* + * How long to cache full page + * + * @var string + */ + 'full_page_lifetime' => 'default', + + /* + * Custom lifetime value, only used if concrete.cache.full_page_lifetime is 'custom' + * + * @var int + */ + 'full_page_lifetime_value' => null, + + /* + * Calculate the cache key reading the assets contents (true) of the assets modification time (false). + * + * @var bool + */ + 'full_contents_assets_hash' => false, + + 'directory' => DIR_FILES_UPLOADED_STANDARD . '/cache', + /* + * Relative path to the cache directory. If empty it'll be calculated from concrete.cache.directory + * @var string|null + */ + 'directory_relative' => null, + 'page' => [ + 'directory' => DIR_FILES_UPLOADED_STANDARD . '/cache/pages', + 'adapter' => 'file', + ], + + 'levels' => [ + 'overrides' => [ + 'drivers' => [ + 'core_ephemeral' => [ + 'class' => '\Stash\Driver\Ephemeral', + 'options' => [], + ], + + 'core_filesystem' => [ + 'class' => \Concrete\Core\Cache\Driver\FileSystemStashDriver::class, + 'options' => [ + 'path' => DIR_FILES_UPLOADED_STANDARD . '/cache/overrides', + 'dirPermissions' => DIRECTORY_PERMISSIONS_MODE_COMPUTED, + 'filePermissions' => FILE_PERMISSIONS_MODE_COMPUTED, + ], + ], + 'redis' => [ + 'class' => \Concrete\Core\Cache\Driver\RedisStashDriver::class, + 'options' => [ + /* Example configuration for servers + 'servers' => [ + [ + 'server' => 'localhost', + 'port' => 6379, + 'ttl' => 10 //Connection Timeout - not TTL for objects + ], + [ + 'server' => 'outside.server', + 'port' => 6379, + 'ttl' => 10 + ], + ],*/ + 'prefix' => 'c5_overrides', + 'database' => 0, // Use different Redis Databases - optional + ], + ], + ], + 'preferred_driver' => 'core_filesystem', // Use this to specify a preferred driver + ], + 'expensive' => [ + 'drivers' => [ + 'core_ephemeral' => [ + 'class' => '\Stash\Driver\Ephemeral', + 'options' => [], + ], + 'core_filesystem' => [ + 'class' => \Concrete\Core\Cache\Driver\FileSystemStashDriver::class, + 'options' => [ + 'path' => DIR_FILES_UPLOADED_STANDARD . '/cache/expensive', + 'dirPermissions' => DIRECTORY_PERMISSIONS_MODE_COMPUTED, + 'filePermissions' => FILE_PERMISSIONS_MODE_COMPUTED, + ], + ], + 'redis' => [ + 'class' => \Concrete\Core\Cache\Driver\RedisStashDriver::class, + 'options' => [ + 'prefix' => 'c5_expensive', + 'database' => 0, // Use different Redis Databases - optional + ], + ], + ], + 'preferred_driver' => 'core_filesystem', // Use this to specify a preferred driver + ], + 'object' => [ + 'drivers' => [ + 'core_ephemeral' => [ + 'class' => '\Stash\Driver\Ephemeral', + 'options' => [], + ], + 'redis' => [ + 'class' => \Concrete\Core\Cache\Driver\RedisStashDriver::class, + 'options' => [ + 'prefix' => 'c5_object', + 'database' => 0, // Use different Redis Databases - optional + ], + ], + ], + 'preferred_driver' => 'core_ephemeral', // Use this to specify a preferred driver + ], + ], + + 'clear' => [ + 'thumbnails' => false, + ], + ], + + 'design' => [ + 'enable_custom' => true, + 'enable_layouts' => true, + ], + + /* + * ------------------------------------------------------------------------ + * Logging settings + * ------------------------------------------------------------------------ + */ + 'log' => [ + /* + * Whether to log emails + * + * @var bool + */ + 'emails' => true, + + /* + * Whether to log Errors + * + * @var bool + */ + 'errors' => true, + + /* + * Whether to log Spam + * + * @var bool + */ + 'spam' => false, + + /* + * Whether to log REST API requests headers + * + * @var bool + */ + 'api' => false, + + 'enable_dashboard_report' => true, + + 'configuration' => [ + /* + * Configuration mode + * + * @var string simple|advanced + */ + 'mode' => 'simple', + 'simple' => [ + /* + * What log level to store core logs in the database + * @var string + */ + 'core_logging_level' => 'NOTICE', + + /* + * Which handle to use + * + * @var string (database|file) + */ + 'handler' => 'database', + + 'file' => [ + /* + * File path to store logs + * + * @var string + */ + 'file' => '', + ], + ], + + 'advanced' => [ + 'configuration' => [], + ], + ], + ], + 'jobs' => [ + 'enable_scheduling' => true, + ], + + 'filesystem' => [ + /* Temporary directory. + * @link \Concrete\Core\File\Service\File::getTemporaryDirectory + */ + 'temp_directory' => null, + 'permissions' => [ + 'file' => FILE_PERMISSIONS_MODE_COMPUTED, + 'directory' => DIRECTORY_PERMISSIONS_MODE_COMPUTED, + ], + ], + + /* + * ------------------------------------------------------------------------ + * Email settings + * ------------------------------------------------------------------------ + */ + 'email' => [ + /* + * Enable emails + * + * @var bool + */ + 'enabled' => true, + 'default' => [ + 'address' => 'concrete5-noreply@concrete5', + 'name' => '', + ], + 'form_block' => [ + 'address' => false, + ], + 'forgot_password' => [ + 'address' => null, + 'name' => null, + ], + 'validate_registration' => [ + 'address' => null, + 'name' => null, + ], + 'workflow_notification' => [ + 'address' => null, + 'name' => null, + ], + ], + + /* + * ------------------------------------------------------------------------ + * Form settings + * ------------------------------------------------------------------------ + */ + 'form' => [ + /* + * Whether to store form submissions. Auto means form submissions will be stored, but the block + * will offer an option to disable on a per-block basis. True means they will always be stored, + * and false means they will never be stored. + * + * @var string "auto", true or false + */ + 'store_form_submissions' => 'auto', + ], + + /* + * ------------------------------------------------------------------------ + * Marketplace settings + * ------------------------------------------------------------------------ + */ + 'marketplace' => [ + /* + * Enable marketplace integration + * + * @var bool concrete.marketplace.enabled + */ + 'enabled' => true, + + /* + * Time it takes for a request to timeout + * + * @var int concrete.marketplace.request_timeout + */ + 'request_timeout' => 30, + + /* + * Marketplace Token + * + * @var null|string concrete.marketplace.token + */ + 'token' => null, + + /* + * Marketplace Site url Token + * + * @var null|string concrete.marketplace.site_token + */ + 'site_token' => null, + + /* + * Enable intelligent search integration + * + * @var bool concrete.marketplace.intelligent_search + */ + 'intelligent_search' => true, + + /* + * Log requests + * + * @var bool concrete.marketplace.log_requests + */ + 'log_requests' => false, + ], + + /* + * ------------------------------------------------------------------------ + * Getting external news and help from concrete5.org + * ------------------------------------------------------------------------ + */ + 'external' => [ + /* + * Provide help within the intelligent search + * + * @var bool concrete.external.intelligent_search_help + */ + 'intelligent_search_help' => true, + + /* + * Enable concrete5 news within your site + * + * @var bool concrete.external.news + */ + 'news' => true, + ], + + /* + * -------------------------------------------------------------------- + * Miscellaneous settings + * -------------------------------------------------------------------- + */ + 'misc' => [ + 'user_timezones' => false, + 'package_backup_directory' => DIR_FILES_UPLOADED_STANDARD . '/trash', + 'enable_progressive_page_reindex' => true, + 'mobile_theme_id' => 0, + 'sitemap_approve_immediately' => true, + 'enable_translate_locale_en_us' => false, + 'page_search_index_lifetime' => 259200, + 'enable_trash_can' => true, + 'app_version_display_in_header' => true, + /* + * The JPEG compression level (in range 0... 100) + */ + 'default_jpeg_image_compression' => 80, + /* + * The PNG compression level (in range 0... 9) + */ + 'default_png_image_compression' => 9, + /* + * The default thumbnail format: jpeg, png, auto (if auto: we'll create a jpeg if the source image is jpeg, we'll create a png otherwise). + */ + 'default_thumbnail_format' => 'auto', + /* + * The threshold (total number of pixels - width x height x number of frames) + * after which we'll reload images instead of creating in-memory clones. + * If empty: unlimited + */ + 'inplace_image_operations_limit' => 4194304, + /* + * @var string (now|async) + */ + 'basic_thumbnailer_generation_strategy' => 'now', + 'help_overlay' => true, + 'require_version_comments' => false, + /* + * Control whether a block type can me moved to different block type sets + * + * @var bool + */ + 'enable_move_blocktypes_across_sets' => false, + /* + * Control whether or not the image editor should add crossOrigin when loading images from external sources (s3, etc) + */ + 'image_editor_cors_policy' => [ + 'enable_cross_origin' => false, + 'anonymous_request' => true, + ], + /* + * Check whether to add a "generator" tag with the concrete5 version to the site pages + * + * @var bool + */ + 'generator_tag_display_in_header' => true, + ], + + 'theme' => [ + 'compress_preprocessor_output' => true, + 'generate_less_sourcemap' => false, + ], + + 'updates' => [ + 'enable_auto_update_packages' => false, + 'enable_permissions_protection' => true, + 'check_threshold' => 172800, + 'services' => [ + 'get_available_updates' => 'http://www.concrete5.org/tools/update_core', + 'inspect_update' => 'http://www.concrete5.org/tools/inspect_update', + ], + // Set to true to skip checking if there's a newer core version available (useful for example if the core is upgraded via composer) + 'skip_core' => false, + // List of package handles that shouldn't be checked for new versions in marketplace (useful for example if the core is upgraded via composer) + // Set to true to skip all the packages + 'skip_packages' => [], + ], + 'paths' => [ + 'trash' => '/!trash', + 'drafts' => '/!drafts', + ], + 'icons' => [ + 'page_template' => [ + 'width' => 120, + 'height' => 90, + ], + 'theme_thumbnail' => [ + 'width' => 120, + 'height' => 90, + ], + 'file_manager_listing' => [ + 'handle' => 'file_manager_listing', + 'width' => 60, + 'height' => 60, + ], + 'file_manager_detail' => [ + 'handle' => 'file_manager_detail', + 'width' => 400, + 'height' => 400, + ], + 'user_avatar' => [ + 'width' => 80, + 'height' => 80, + 'default' => ASSETS_URL_IMAGES . '/avatar_none.png', + ], + ], + + 'file_manager' => [ + 'images' => [ + 'use_exif_data_to_rotate_images' => false, + 'manipulation_library' => 'gd', + 'create_high_dpi_thumbnails' => true, + /* + * The style of preview image used in the file_manager + * + * @var string 'small'(default,30x30), 'large(60x60)' or 'full(size of file_manager_listing)' + */ + 'preview_image_size' => 'small', + /* + * Show file_manager_detail thumbnail as preview image in popover + * + * @var boolean + */ + 'preview_image_popover' => true, + // SVG sanitization + 'svg_sanitization' => [ + // The operation that the SVG sanitizer should perform. + // This must be value of one of the Concrete\Core\File\Import\Processor\SvgProcessor::ACTION_... constants + 'action' => 'sanitize', + // Space-separated list of tags to be kept + 'allowed_tags' => '', + // Space-separated list of attributes to be kept + 'allowed_attributes' => '', + ], + /* + * Background color of the Image Editor saveArea + * Leave empty to use a transparent background + * + * @var string + */ + 'image_editor_save_area_background_color' => '', + ], + /* + * Options for the results per page dropdown + * + * @var array + */ + 'items_per_page_options' => [10, 25, 50, 100, 250], + /* + * Default number of results per page + * + * @var int + */ + 'results' => 10, + ], + + 'search_users' => [ + 'results' => 10, + ], + + 'sitemap_xml' => [ + 'file' => 'sitemap.xml', + 'frequency' => 'weekly', + 'priority' => 0.5, + ], + + /* + * ------------------------------------------------------------------------ + * Accessibility + * ------------------------------------------------------------------------ + */ + 'accessibility' => [ + /* + * Show titles in the concrete5 toolbars + * + * @var bool + */ + 'toolbar_titles' => false, + + /* + * Increase the font size in the concrete5 toolbars + * + * @var bool + */ + 'toolbar_large_font' => false, + + /* + * Show help system + * + * @var bool + */ + 'display_help_system' => true, + + /* + * Show tooltips in the concrete5 toolbars + * + * @var bool + */ + 'toolbar_tooltips' => true, + ], + + /* + * ------------------------------------------------------------------------ + * Internationalization + * ------------------------------------------------------------------------ + */ + 'i18n' => [ + /* + * Allow users to choose language on login + * + * @var bool + */ + 'choose_language_login' => false, + // Fetch language files when installing a package connected to the marketplace [boolean] + 'auto_install_package_languages' => true, + // Community Translation instance offering concrete5 translations + 'community_translation' => [ + // API entry point of the Community Translation instance + 'entry_point' => 'http://translate.concrete5.org/api', + // API Token to be used for the Community Translation instance + 'api_token' => '', + // Languages below this translation progress won't be considered + 'progress_limit' => 60, + // Lifetime (in seconds) of the cache items associated to downloaded data + 'cache_lifetime' => 3600, // 1 hour + // Base URI for package details + 'package_url' => 'https://translate.concrete5.org/translate/package', + ], + ], + 'urls' => [ + 'concrete5' => 'http://www.concrete5.org', + 'concrete5_secure' => 'https://www.concrete5.org', + 'newsflow' => 'http://newsflow.concrete5.org', + 'background_feed' => '//backgroundimages.concrete5.org/wallpaper', + 'privacy_policy' => '//www.concrete5.org/legal/privacy-policy', + 'background_feed_secure' => 'https://backgroundimages.concrete5.org/wallpaper', + 'background_info' => 'http://backgroundimages.concrete5.org/get_image_data.php', + 'videos' => 'https://www.youtube.com/user/concrete5cms/videos', + 'help' => [ + 'developer' => 'http://documentation.concrete5.org/developers', + 'user' => 'http://documentation.concrete5.org/editors', + 'forum' => 'http://www.concrete5.org/community/forums', + 'slack' => 'https://www.concrete5.org/slack', + ], + 'paths' => [ + 'menu_help_service' => '/tools/get_remote_help_list/', + 'site_page' => '/private/sites', + 'newsflow_slot_content' => '/tools/slot_content/', + 'marketplace' => [ + 'connect' => '/marketplace/connect', + 'connect_success' => '/marketplace/connect/-/connected', + 'connect_validate' => '/marketplace/connect/-/validate', + 'connect_new_token' => '/marketplace/connect/-/generate_token', + 'checkout' => '/cart/-/add', + 'purchases' => '/marketplace/connect/-/get_available_licenses', + 'item_information' => '/marketplace/connect/-/get_item_information', + 'item_free_license' => '/marketplace/connect/-/enable_free_license', + 'remote_item_list' => '/marketplace/', + ], + ], + ], + + /* + * ------------------------------------------------------------------------ + * White labeling. + * ------------------------------------------------------------------------ + */ + 'white_label' => [ + /* + * Custom Logo source path relative to the public directory. + * + * @var bool|string The logo path + */ + 'logo' => false, + + /* + * Custom Name + * + * @var bool|string The name + */ + 'name' => false, + + /* + * Background image url + * + * @var null|string + */ + 'background_image' => null, + ], + 'session' => [ + 'name' => 'CONCRETE5', + 'handler' => 'file', + 'redis' => [ + 'database' => 1, // Use different Redis Databases - optional + ], + 'save_path' => null, + // Minimum duration (in seconds) of an "unoutched" session + 'max_lifetime' => 7200, + // gc_probability and gc_divisor together define the probability to + // cleanup expided sessions ("garbage collection"). + // Example: if gc_probability is 1 and gc_divisor is 100, on average we'll have 1 GC every 100 requests (1%) + // Example: if gc_probability is 5 and gc_divisor is 20, on average we'll have 1 GC every 20 requests (25%) + 'gc_probability' => 1, + 'gc_divisor' => 100, + 'cookie' => [ + 'cookie_path' => false, // set a specific path here if you know it, otherwise it'll default to relative + 'cookie_lifetime' => 0, + 'cookie_domain' => false, + 'cookie_secure' => false, + 'cookie_httponly' => true, + 'cookie_raw' => false, + 'cookie_samesite' => null, + ], + 'remember_me' => [ + 'lifetime' => 1209600, // 2 weeks in seconds + ], + ], + + /* + * ------------------------------------------------------------------------ + * User information and registration settings. + * ------------------------------------------------------------------------ + */ + 'user' => [ + /* + * -------------------------------------------------------------------- + * Registration settings. + * -------------------------------------------------------------------- + */ + 'registration' => [ + /* + * Registration + * + * @var bool + */ + 'enabled' => false, + + /* + * Registration type + * + * @var string The type (disabled|enabled|validate_email) + */ + 'type' => 'disabled', + + /* + * Enable Registration Captcha + * + * @var bool + */ + 'captcha' => true, + + /* + * Use emails instead of usernames to log in + * + * @var bool + */ + 'email_registration' => false, + + /* + * Determines whether the username field is displayed when registering + */ + 'display_username_field' => true, + + /* + * Determines whether the confirm password field is displayed when registering + */ + 'display_confirm_password_field' => true, + + /* + * Validate emails during registration + * + * @var bool + */ + 'validate_email' => false, + + /* + * Admins approve each registration + * + * @var bool + */ + 'approval' => false, + + /* + * Send notifications after successful registration. + * + * @var bool|string Email to notify + */ + 'notification' => false, + ], + + /* + * -------------------------------------------------------------------- + * Gravatar Settings + * -------------------------------------------------------------------- + */ + 'group' => [ + 'badge' => [ + 'default_point_value' => 50, + ], + ], + + 'username' => [ + 'maximum' => 64, + 'minimum' => 3, + 'allowed_characters' => [ + 'boundary' => 'A-Za-z0-9', + 'middle' => 'A-Za-z0-9_\.', + 'requirement_string' => 'A username may only contain letters, numbers, dots (not at the beginning/end), and underscores (not at the beginning/end).', + 'error_string' => 'A username may only contain letters, numbers, dots (not at the beginning/end), and underscores (not at the beginning/end).', + ], + ], + 'password' => [ + 'maximum' => 128, + 'minimum' => 5, + 'required_special_characters' => 0, + 'required_lower_case' => 0, + 'required_upper_case' => 0, + 'reuse' => 0, + 'custom_regex' => [], + 'hash_portable' => false, + 'hash_cost_log2' => 12, + 'legacy_salt' => '', + ], + 'email' => [ + 'test_mx_record' => false, + 'strict' => true, + ], + 'private_messages' => [ + 'throttle_max' => 20, + 'throttle_max_timespan' => 15, // minutes + ], + + 'deactivation' => [ + 'enable_login_threshold_deactivation' => false, + 'login' => [ + 'threshold' => 120, // in days + ], + 'authentication_failure' => [ + 'enabled' => false, + 'amount' => 5, // The number of failures + 'duration' => 300, // In so many seconds + ], + 'message' => 'This user is inactive. Please contact us regarding this account.', + ], + ], + + /* + * ------------------------------------------------------------------------ + * Spam + * ------------------------------------------------------------------------ + */ + 'spam' => [ + /* + * Whitelist group ID + * + * @var int + */ + 'whitelist_group' => 0, + + /* + * Notification email + * + * @var string + */ + 'notify_email' => '', + ], + + /* + * ------------------------------------------------------------------------ + * Calendar + * ------------------------------------------------------------------------ + */ + 'calendar' => [ + 'colors' => [ + 'text' => '#ffffff', + 'background' => '#3A87AD', + ], + ], + + /* + * ------------------------------------------------------------------------ + * Security + * ------------------------------------------------------------------------ + */ + 'security' => [ + 'session' => [ + 'invalidate_on_user_agent_mismatch' => true, + + 'invalidate_on_ip_mismatch' => true, + + 'invalidate_inactive_users' => [ + // Is the automatically logout inactive users setting enabled? + 'enabled' => false, + // Time window (in seconds) for inactive users to be automatically logout + 'time' => 300, + ], + ], + 'misc' => [ + /* + * Defence Click Jacking. + * + * @var bool|string DENY, SAMEORIGIN, ALLOW-FROM uri + */ + 'x_frame_options' => 'SAMEORIGIN', + ], + ], + + /* + * ------------------------------------------------------------------------ + * Permissions and behaviors toggles. + * ------------------------------------------------------------------------ + */ + 'permissions' => [ + /* + * Forward to login if access is denied + * + * @var bool + */ + 'forward_to_login' => true, + + /* + * Permission model + * + * @var string The permission model (simple|advanced) + */ + 'model' => 'simple', + ], + + /* + * ------------------------------------------------------------------------ + * SEO Settings + * ------------------------------------------------------------------------ + */ + 'seo' => [ + 'exclude_words' => 'a, an, as, at, before, but, by, for, from, is, in, into, like, of, off, on, onto, per, ' . + 'since, than, the, this, that, to, up, via, with', + + /* + * URL rewriting + * + * Doesn't impact concrete.seo.url_rewriting_all which is set at a lower level and + * controls whether ALL items will be rewritten. + * + * @var bool + */ + 'url_rewriting' => false, + 'url_rewriting_all' => false, + 'redirect_to_canonical_url' => false, + 'canonical_url' => null, + 'canonical_url_alternative' => null, + 'trailing_slash' => false, + 'title_format' => '%2$s :: %1$s', + 'title_segment_separator' => ' :: ', + 'page_path_separator' => '-', + 'group_name_separator' => ' / ', + 'segment_max_length' => 128, + 'paging_string' => 'ccm_paging_p', + ], + + /* + * ------------------------------------------------------------------------ + * Statistics Settings + * ------------------------------------------------------------------------ + */ + 'statistics' => [ + 'track_downloads' => true, + ], + 'limits' => [ + 'sitemap_pages' => 100, + 'delete_pages' => 100, + 'copy_pages' => 10, + 'page_search_index_batch' => 200, + 'job_queue_batch' => 10, + 'style_customizer' => [ + 'size_min' => -50, + 'size_max' => 200, + ], + ], + + 'page' => [ + 'search' => [ + // Always reindex pages (usually it isn't performed when approving workflows) + 'always_reindex' => false, + ], + ], + + 'editor' => [ + 'plugins' => [ + 'selected' => [], + ], + ], + + 'composer' => [ + // [float] The time in seconds until idle triggers a save (set to 0 to disable autosave) + 'idle_timeout' => 1, + ], + + /* + * ------------------------------------------------------------------------ + * API settings + * ------------------------------------------------------------------------ + */ + 'api' => [ + /* + * Enabled + * + * @var bool + */ + 'enabled' => false, + + /** + * Which grant types do we allow to connect to the API. + * + * @var array + */ + 'grant_types' => [ + 'client_credentials' => true, + 'authorization_code' => true, + 'password_credentials' => false, + 'refresh_token' => true, + ], + ], + + 'mutex' => [ + 'semaphore' => [ + 'priority' => 100, + 'class' => Concrete\Core\System\Mutex\SemaphoreMutex::class, + ], + 'file_lock' => [ + 'priority' => 50, + 'class' => Concrete\Core\System\Mutex\FileLockMutex::class, + ], + ], + + 'social' => [ + 'additional_services' => [ + // Add here a list of arrays like this: + // ['service_handle', 'Service Name', 'icon'] + // Where 'icon' is the handle of a FontAwesome 4 icon (see https://fontawesome.com/v4.7.0/icons/ ) + ], + ], +]; diff --git a/tests/fixtures/manifest/complete.json b/tests/fixtures/manifest/complete.json new file mode 100644 index 0000000..17df986 --- /dev/null +++ b/tests/fixtures/manifest/complete.json @@ -0,0 +1,80 @@ +{ + "created": "2021-03-10T20:40:50+0000", + "site": "concrete5 Site", + "url": "https://some.url", + "path": "\/home\/foo\/concrete5", + "host": "foo.local", + "version": "1.0", + "contents": { + "application": true, + "applicationContents": [ + "attributes", + "authentication", + "blocks", + "bootstrap", + "config", + "controllers", + "elements", + "jobs", + "languages", + "mail", + "page_templates", + "single_pages", + "src", + "themes", + "tools", + "views" + ], + "core": true, + "database": "db.sql", + "index": true, + "packages": [ + { + "handle": "foo", + "included": true, + "installed": true + }, + { + "handle": "baz", + "included": true, + "installed": false + }, + { + "handle": "zab", + "included": false, + "installed": true + }, + { + "handle": "bar", + "included": false, + "installed": false + } + ], + "storageLocations": [ + { + "id": 1, + "name": "red", + "default": true, + "included": true + }, + { + "id": 2, + "name": "blue", + "default": true, + "included": false + }, + { + "id": 3, + "name": "green", + "default": false, + "included": true + }, + { + "id": 4, + "name": "yellow", + "default": false, + "included": false + } + ] + } +} diff --git a/tests/fixtures/manifest/opposite.json b/tests/fixtures/manifest/opposite.json new file mode 100644 index 0000000..7ee0975 --- /dev/null +++ b/tests/fixtures/manifest/opposite.json @@ -0,0 +1,58 @@ +{ + "created": "2020-02-10T20:40:50+0000", + "site": "", + "url": "", + "path": "", + "host": "", + "version": "", + "contents": { + "database": "", + "storageLocations": [ + { + "id": 1, + "name": "red", + "default": false, + "included": false + }, + { + "id": 2, + "name": "blue", + "default": false, + "included": true + }, + { + "id": 3, + "name": "green", + "default": true, + "included": false + }, + { + "id": 4, + "name": "yellow", + "default": true, + "included": true + } + ], + "application": false, + "applicationContents": [], + "packages": [ + { + "handle": "foo", + "installed": false, + "included": false + }, + { + "handle": "baz", + "installed": true, + "included": false + }, + { + "handle": "bar", + "installed": true, + "included": true + } + ], + "core": false, + "index": false + } +}