├── .drone.yml ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── php.yml ├── .gitignore ├── .php-cs-fixer.php ├── .scrutinizer.yml ├── LICENSE ├── README.md ├── bin └── stats ├── cache └── .gitignore ├── composer.json ├── composer.lock ├── etc ├── config.dist.json ├── migrations │ ├── 20160618001.sql │ ├── 20180413001.sql │ └── 20200313001.sql ├── mysql.sql └── unatantum.sql ├── logs └── .gitignore ├── phpunit.xml.dist ├── ruleset.xml ├── snapshots └── .gitignore ├── src ├── Commands │ ├── Database │ │ ├── MigrateCommand.php │ │ └── MigrationStatusCommand.php │ ├── SnapshotCommand.php │ ├── SnapshotRecentlyUpdatedCommand.php │ ├── Tags │ │ ├── AbstractTagCommand.php │ │ ├── FetchJoomlaTagsCommand.php │ │ └── FetchPhpTagsCommand.php │ └── UpdateCommand.php ├── Controllers │ ├── DisplayStatisticsController.php │ └── SubmitDataController.php ├── Database │ ├── Exception │ │ ├── CannotInitializeMigrationsException.php │ │ ├── UnknownMigrationException.php │ │ └── UnreadableMigrationException.php │ ├── Migrations.php │ └── MigrationsStatus.php ├── Decorators │ └── ValidateVersion.php ├── EventListener │ ├── AnalyticsSubscriber.php │ └── ErrorSubscriber.php ├── GitHub │ ├── GitHub.php │ ├── Package.php │ └── Package │ │ └── Repositories.php ├── Kernel.php ├── Kernel │ ├── ConsoleKernel.php │ └── WebKernel.php ├── Providers │ ├── AnalyticsServiceProvider.php │ ├── ConsoleServiceProvider.php │ ├── DatabaseServiceProvider.php │ ├── EventServiceProvider.php │ ├── FlysystemServiceProvider.php │ ├── GitHubServiceProvider.php │ ├── MonologServiceProvider.php │ ├── RepositoryServiceProvider.php │ └── WebApplicationServiceProvider.php ├── Repositories │ ├── InfluxdbRepository.php │ └── StatisticsRepository.php ├── Views │ └── Stats │ │ └── StatsJsonView.php └── WebApplication.php ├── tests ├── Commands │ ├── Database │ │ ├── MigrateCommandTest.php │ │ └── MigrationStatusCommandTest.php │ ├── SnapshotCommandTest.php │ ├── SnapshotRecentlyUpdatedCommandTest.php │ └── Tags │ │ ├── FetchJoomlaTagsCommandTest.php │ │ └── FetchPhpTagsCommandTest.php ├── Controllers │ ├── DisplayStatisticsControllerTest.php │ └── SubmitDataControllerTest.php ├── Database │ └── MigrationsTest.php ├── DatabaseManager.php ├── DatabaseTestCase.php ├── EventListener │ ├── AnalyticsSubscriberTest.php │ └── ErrorSubscriberTest.php ├── Kernel │ ├── ConsoleKernelTest.php │ └── WebKernelTest.php ├── KernelTest.php ├── Repositories │ └── StatisticsRepositoryTest.php ├── Views │ └── Stats │ │ └── StatsJsonViewTest.php └── bootstrap.php ├── versions ├── joomla.json └── php.json └── www ├── .htaccess └── index.php /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: pipeline 3 | name: default 4 | 5 | clone: 6 | 7 | steps: 8 | - name: composer 9 | image: joomlaprojects/docker-images:php8.1 10 | volumes: 11 | - name: composer-cache 12 | path: /tmp/composer-cache 13 | commands: 14 | - composer install --no-progress 15 | 16 | - name: phpcs 17 | image: joomlaprojects/docker-images:php8.1 18 | depends_on: [ composer ] 19 | commands: 20 | - echo $(date) 21 | - ./vendor/bin/phpcs --config-set installed_paths vendor/joomla/coding-standards 22 | - ./vendor/bin/phpcs --extensions=php -p --standard=ruleset.xml . 23 | - echo $(date) 24 | 25 | - name: php81 26 | depends_on: [ phpcs ] 27 | image: joomlaprojects/docker-images:php8.1 28 | commands: 29 | - php -v 30 | - ./vendor/bin/phpunit 31 | 32 | - name: deployment 33 | image: appleboy/drone-ssh 34 | depends_on: 35 | - php81 36 | settings: 37 | host: 38 | from_secret: stats_host 39 | username: 40 | from_secret: stats_username 41 | port: 22 42 | key: 43 | from_secret: stats_key 44 | script: 45 | - cd /home/devj/jstats-server 46 | - bin/stats update:server 47 | - chmod 644 www/index.php 48 | when: 49 | branch: 50 | - master 51 | status: 52 | - success 53 | event: 54 | - push 55 | 56 | volumes: 57 | - name: composer-cache 58 | host: 59 | path: /tmp/composer-cache 60 | 61 | services: 62 | - name: mysql 63 | image: mysql:5.7 64 | environment: 65 | MYSQL_USER: joomla_ut 66 | MYSQL_PASSWORD: joomla_ut 67 | MYSQL_ROOT_PASSWORD: joomla_ut 68 | MYSQL_DATABASE: test_joomla 69 | --- 70 | kind: signature 71 | hmac: 8be22045dcdba2900053efa460a017b12d49fa4b0e52f759424c359a503e13cf 72 | 73 | ... 74 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### Steps to reproduce the issue 2 | 3 | 4 | 5 | #### Expected result 6 | 7 | 8 | 9 | #### Actual result 10 | 11 | 12 | 13 | #### System information (as much as possible) 14 | 15 | 16 | 17 | #### Additional comments 18 | 19 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Pull Request for Issue # . 2 | 3 | #### Summary of Changes 4 | 5 | #### Testing Instructions 6 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: PHP CRON CI 5 | 6 | on: 7 | push: 8 | schedule: 9 | # * is a special character in YAML so you have to quote this string 10 | # Runs every day at 00:00 11 | - cron: '0 0 * * *' 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | # - name: Validate composer.json and composer.lock 22 | # run: composer validate --strict 23 | 24 | # - name: Cache Composer packages 25 | # id: composer-cache 26 | # uses: actions/cache@v2 27 | # with: 28 | # path: vendor 29 | # key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 30 | # restore-keys: | 31 | # ${{ runner.os }}-php- 32 | 33 | - name: Install dependencies 34 | run: composer install --prefer-dist --no-progress --no-suggest 35 | 36 | - name: Update PHP versions.json 37 | run: php bin/stats tags:php 38 | 39 | - uses: stefanzweifel/git-auto-commit-action@v4.9.2 40 | with: 41 | commit_message: Adding back the json created... 42 | branch: ${{ github.head_ref }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor 3 | /phpunit.xml 4 | /build/coverage 5 | /etc/config.json 6 | /.php_cs.cache 7 | /.phpunit.result.cache 8 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in( 5 | [ 6 | __DIR__ . '/', 7 | __DIR__ . '/bin', 8 | __DIR__ . '/src', 9 | __DIR__ . '/tests', 10 | __DIR__ . '/www', 11 | ] 12 | ); 13 | 14 | $config = new PhpCsFixer\Config(); 15 | $config 16 | ->setRiskyAllowed(true) 17 | ->setHideProgress(false) 18 | ->setUsingCache(false) 19 | ->setRules( 20 | [ 21 | // Basic ruleset is PSR 12 22 | '@PSR12' => true, 23 | // Short array syntax 24 | 'array_syntax' => ['syntax' => 'short'], 25 | // List of values separated by a comma is contained on a single line should not have a trailing comma like [$foo, $bar,] = ... 26 | 'no_trailing_comma_in_singleline' => true, 27 | // Arrays on multiline should have a trailing comma 28 | 'trailing_comma_in_multiline' => ['elements' => ['arrays']], 29 | // Align elements in multiline array and variable declarations on new lines below each other 30 | 'binary_operator_spaces' => ['operators' => ['=>' => 'align_single_space_minimal', '=' => 'align']], 31 | // The "No break" comment in switch statements 32 | 'no_break_comment' => ['comment_text' => 'No break'], 33 | // Remove unused imports 34 | 'no_unused_imports' => true, 35 | // Classes from the global namespace should not be imported 36 | 'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false], 37 | // Alpha order imports 38 | 'ordered_imports' => ['imports_order' => ['class', 'function', 'const'], 'sort_algorithm' => 'alpha'], 39 | // There should not be useless else cases 40 | 'no_useless_else' => true, 41 | // Native function invocation 42 | 'native_function_invocation' => ['include' => ['@compiler_optimized']], 43 | ] 44 | ) 45 | ->setFinder($finder); 46 | 47 | return $config; 48 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - php 3 | 4 | tools: 5 | external_code_coverage: true 6 | 7 | filter: 8 | excluded_paths: 9 | - tests/* 10 | - vendor/* 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Joomla Environment Stats 2 | 3 | In order to better understand our install base and end user environments, a plugin has been created to send those stats back to a Joomla 4 | controlled central server. No worries though, __no__ identifying data is captured at any point, and we only keep the data last sent to us. 5 | 6 | ## Build Status 7 | Travis-CI: [![Build Status](https://travis-ci.org/joomla/statistics-server.png)](https://travis-ci.org/joomla/statistics-server) 8 | Scrutinizer-CI: [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/joomla/statistics-server/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/joomla/statistics-server/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/joomla/statistics-server/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/joomla/statistics-server/?branch=master) [![Build Status](https://scrutinizer-ci.com/g/joomla/statistics-server/badges/build.png?b=master)](https://scrutinizer-ci.com/g/joomla/statistics-server/build-status/master) 9 | 10 | ## Requirements 11 | 12 | * PHP 8.1+ 13 | * PDO with MySQL support 14 | * MySQL 15 | * Composer 16 | * Apache with mod_rewrite enabled and configured to allow the .htaccess file to be read 17 | 18 | ## Installation 19 | 20 | 1. Clone this repo on your web server 21 | 2. Create a database on your MySQL server 22 | 3. Copy `etc/config.dist.json` to `etc/config.json` and fill in your database credentials 23 | 4. Run the `composer install` command to install all dependencies 24 | 5. Run the `bin/stats install` command to create the application's database 25 | 26 | ## Additional Configuration 27 | 28 | The `DisplayStatisticsController` optionally supports additional configuration values which affect the application's behavior, to include: 29 | 30 | * Raw Data Access - The API supports requesting the raw, unfiltered API data by sending a `Joomla-Raw` header with the API request. The value of this must match the `stats.rawdata` configuration key. 31 | 32 | Additionally, the application behavior is affected by the following configuration settings: 33 | 34 | * Error Reporting - The `errorReporting` configuration key can be set to a valid bitmask to be passed into the `error_reporting()` function 35 | * Logging - The application's logging levels can be fine tuned by adjusting the `log` configuration keys: 36 | * `log.level` - The default logging level to use for all application loggers 37 | * `log.application` - The logging level to use specifically for the `monolog.handler.application` logger; defaults to the `log.level` value 38 | * `log.database` - The logging level to use specifically for the `monolog.handler.database` logger; defaults to the `log.level` value (Note: if `database.debug` is set to true then this level will ALWAYS correspond to the debug level) 39 | 40 | ## Deployments 41 | * Joomla's Jenkins server will automatically push any commits to the `master` branch to the production server 42 | * TODO - Future iterations of this setup should require a passing Travis-CI build before deploying 43 | * Because of the use of custom delimiters in the database schema (which are not parsed correctly with PDO), database migrations are not automatically executed 44 | * If a change is pushed that includes updates to the database schema, then the merger needs to log into the server and run any migrations required; the application's `database:migrate` command will take care of this 45 | * `php /path/to/application/bin/stats database:migrate` 46 | * Don’t put any triggers inside the migrations, those should be added to the main `etc/mysql.sql` schema file then manually run on the database using your preferred database management tool 47 | -------------------------------------------------------------------------------- /bin/stats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); 26 | } 27 | catch (\Throwable $throwable) 28 | { 29 | error_log($throwable); 30 | 31 | fwrite(STDOUT, "\nAn error occurred while running the application: " . $throwable->getMessage() . "\n"); 32 | fwrite(STDOUT, "\n" . $throwable->getTraceAsString() . "\n"); 33 | 34 | if ($prev = $throwable->getPrevious()) 35 | { 36 | fwrite(STDOUT, "\n\nPrevious Exception: " . $prev->getMessage() . "\n"); 37 | fwrite(STDOUT, "\n" . $prev->getTraceAsString() . "\n"); 38 | } 39 | 40 | exit(1); 41 | } 42 | -------------------------------------------------------------------------------- /cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joomla/statistics-server", 3 | "type": "project", 4 | "description": "Joomla Stats Collection Server", 5 | "keywords": ["joomla"], 6 | "homepage": "http://github.com/joomla/statistics-server", 7 | "license": "GPL-2.0-or-later", 8 | "require": { 9 | "php": "^8.1.0", 10 | "ext-json": "*", 11 | "ext-pdo": "*", 12 | "joomla/application": "^3.0", 13 | "joomla/console": "^3.0", 14 | "joomla/controller": "^3.0", 15 | "joomla/database": "^3.0", 16 | "joomla/event": "^3.0", 17 | "joomla/di": "^3.0", 18 | "joomla/filter": "^3.0", 19 | "joomla/github": "^3.0-dev", 20 | "joomla/http": "^3.0", 21 | "joomla/input": "^3.0", 22 | "joomla/registry": "^3.0", 23 | "joomla/router": "^3.0", 24 | "joomla/string": "^3.0", 25 | "joomla/uri": "^3.0", 26 | "joomla/utilities": "^3.0", 27 | "joomla/view": "^3.0", 28 | "league/flysystem": "^1.0.69", 29 | "monolog/monolog": "^2.1", 30 | "psr/log": "^1.1.3", 31 | "ramsey/uuid": "^3.9.2", 32 | "symfony/process": "^5.1", 33 | "theiconic/php-ga-measurement-protocol": "^2.7.2", 34 | "influxdata/influxdb-client-php": "^3.5" 35 | }, 36 | "require-dev": { 37 | "friendsofphp/php-cs-fixer": "^v3.58.1", 38 | "joomla/coding-standards": "~3.0@dev", 39 | "joomla/test": "^2.0@beta", 40 | "league/flysystem-memory": "^1.0.2", 41 | "phpunit/phpunit": "^10.5.20" 42 | }, 43 | "autoload": { 44 | "psr-4": { 45 | "Joomla\\StatsServer\\": "src/" 46 | } 47 | }, 48 | "autoload-dev": { 49 | "psr-4": { 50 | "Joomla\\StatsServer\\Tests\\": "tests/" 51 | } 52 | }, 53 | "replace": { 54 | "paragonie/random_compat": "*", 55 | "symfony/polyfill-php55": "*", 56 | "symfony/polyfill-php70": "*", 57 | "symfony/polyfill-php72": "*" 58 | }, 59 | "config": { 60 | "platform": { 61 | "php": "8.1.0" 62 | }, 63 | "allow-plugins": { 64 | "php-http/discovery": true 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /etc/config.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "driver" : "mysql", 4 | "host" : "", 5 | "user" : "", 6 | "password": "", 7 | "database": "", 8 | "prefix" : "" 9 | }, 10 | "influxdb": { 11 | "url" : "", 12 | "token" : "", 13 | "org" : "", 14 | "bucket": "" 15 | }, 16 | "stats": { 17 | "rawdata": false 18 | }, 19 | "log": { 20 | "level": "error" 21 | }, 22 | "github": { 23 | "gh": { 24 | "token": false 25 | }, 26 | "api": { 27 | "username": false, 28 | "password": false 29 | } 30 | }, 31 | "errorReporting": 0 32 | } 33 | -------------------------------------------------------------------------------- /etc/migrations/20160618001.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `#__jstats` ( 2 | `unique_id` varchar(40) NOT NULL, 3 | `php_version` varchar(15) NOT NULL, 4 | `db_type` varchar(15) NOT NULL, 5 | `db_version` varchar(50) NOT NULL, 6 | `cms_version` varchar(15) NOT NULL, 7 | `server_os` varchar(255) NOT NULL, 8 | `modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', 9 | PRIMARY KEY (`unique_id`), 10 | KEY `idx_php_version` (`php_version`), 11 | KEY `idx_db_type` (`db_type`), 12 | KEY `idx_db_version` (`db_version`), 13 | KEY `idx_cms_version` (`cms_version`), 14 | KEY `idx_server_os` (`server_os`), 15 | KEY `idx_database` (`db_type`, `db_version`) 16 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci; 17 | 18 | CREATE TABLE `#__migrations` ( 19 | `version` varchar(25) NOT NULL COMMENT 'Applied migration versions', 20 | KEY `version` (`version`) 21 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci; 22 | -------------------------------------------------------------------------------- /etc/migrations/20180413001.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_php_version` ( 2 | `php_version` varchar(15) NOT NULL, 3 | `count` INT NOT NULL, 4 | PRIMARY KEY (`php_version`), 5 | KEY `idx_version_count` (`php_version`, `count`) 6 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci; 7 | 8 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_db_version` ( 9 | `db_version` varchar(50) NOT NULL, 10 | `count` INT NOT NULL, 11 | PRIMARY KEY (`db_version`), 12 | KEY `idx_version_count` (`db_version`, `count`) 13 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci; 14 | 15 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_db_type` ( 16 | `db_type` varchar(15) NOT NULL, 17 | `count` INT NOT NULL, 18 | PRIMARY KEY (`db_type`), 19 | KEY `idx_version_count` (`db_type`, `count`) 20 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci; 21 | 22 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_cms_version` ( 23 | `cms_version` varchar(15) NOT NULL, 24 | `count` INT NOT NULL, 25 | PRIMARY KEY (`cms_version`), 26 | KEY `idx_version_count` (`cms_version`, `count`) 27 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci; 28 | 29 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_server_os` ( 30 | `server_os` varchar(255) NOT NULL, 31 | `count` INT NOT NULL, 32 | PRIMARY KEY (`server_os`), 33 | KEY `idx_version_count` (`server_os`, `count`) 34 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci; 35 | -------------------------------------------------------------------------------- /etc/migrations/20200313001.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_cms_php_version` ( 2 | `cms_version` varchar(15) NOT NULL, 3 | `php_version` varchar(15) NOT NULL, 4 | `count` INT NOT NULL, 5 | PRIMARY KEY (`cms_version`,`php_version`) 6 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci; 7 | 8 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_db_type_version` ( 9 | `db_type` varchar(15) NOT NULL, 10 | `db_version` varchar(15) NOT NULL, 11 | `count` INT NOT NULL, 12 | PRIMARY KEY (`db_type`,`db_version`) 13 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci; 14 | -------------------------------------------------------------------------------- /etc/unatantum.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO `#__jstats_counter_server_os` 2 | SELECT server_os , COUNT(*) as count 3 | FROM `#__jstats` 4 | GROUP BY server_os; 5 | 6 | INSERT INTO `#__jstats_counter_php_version` 7 | SELECT php_version , COUNT(*) as count 8 | FROM `#__jstats` 9 | GROUP BY php_version; 10 | 11 | INSERT INTO `#__jstats_counter_db_version` 12 | SELECT db_version , COUNT(*) as count 13 | FROM `#__jstats` 14 | GROUP BY db_version; 15 | 16 | INSERT INTO `#__jstats_counter_db_type` 17 | SELECT db_type , COUNT(*) as count 18 | FROM `#__jstats` 19 | GROUP BY db_type; 20 | 21 | INSERT INTO `#__jstats_counter_cms_version` 22 | SELECT cms_version , COUNT(*) as count 23 | FROM `#__jstats` 24 | GROUP BY cms_version; 25 | 26 | INSERT INTO `#__jstats_counter_cms_php_version` 27 | SELECT cms_version, php_version , COUNT(*) as count 28 | FROM `#__jstats` 29 | GROUP BY cms_version, php_version; 30 | 31 | INSERT INTO `#__jstats_counter_db_type_version` 32 | SELECT db_type, db_version , COUNT(*) as count 33 | FROM `#__jstats` 34 | GROUP BY db_type, db_version; 35 | -------------------------------------------------------------------------------- /logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | src 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /ruleset.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | */build/* 12 | */cache/* 13 | */etc/* 14 | */logs/* 15 | */snapshots/* 16 | */tests/* 17 | */versions/* 18 | 19 | 20 | */vendor/* 21 | 22 | 23 | */Gruntfile.js 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | www/index\.php 37 | 38 | 39 | ./bin 40 | ./src 41 | ./tests 42 | ./www 43 | 44 | 45 | -------------------------------------------------------------------------------- /snapshots/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /src/Commands/Database/MigrateCommand.php: -------------------------------------------------------------------------------- 1 | migrations = $migrations; 50 | 51 | parent::__construct(); 52 | } 53 | 54 | /** 55 | * Internal function to execute the command. 56 | * 57 | * @param InputInterface $input The input to inject into the command. 58 | * @param OutputInterface $output The output to inject into the command. 59 | * 60 | * @return integer The command exit code 61 | */ 62 | protected function doExecute(InputInterface $input, OutputInterface $output): int 63 | { 64 | $symfonyStyle = new SymfonyStyle($input, $output); 65 | 66 | $symfonyStyle->title('Database Migrations: Migrate'); 67 | 68 | // If a version is given, we are only executing that migration 69 | $version = $input->getOption('mversion'); 70 | 71 | try { 72 | $this->migrations->migrateDatabase($version); 73 | } catch (\Exception $exception) { 74 | $this->logger->critical( 75 | 'Error migrating database', 76 | ['exception' => $exception] 77 | ); 78 | 79 | $symfonyStyle->error(sprintf('Error migrating database: %s', $exception->getMessage())); 80 | 81 | return 1; 82 | } 83 | 84 | if ($version) { 85 | $message = sprintf('Database migrated to version "%s".', $version); 86 | } else { 87 | $message = 'Database migrated to latest version.'; 88 | } 89 | 90 | $this->logger->info($message); 91 | 92 | $symfonyStyle->success($message); 93 | 94 | return 0; 95 | } 96 | 97 | /** 98 | * Configures the current command. 99 | * 100 | * @return void 101 | */ 102 | protected function configure(): void 103 | { 104 | $this->setDescription('Migrate the database schema to a newer version.'); 105 | $this->addOption( 106 | 'mversion', 107 | null, 108 | InputOption::VALUE_OPTIONAL, 109 | 'If specified, only the given migration will be executed if necessary.' 110 | ); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Commands/Database/MigrationStatusCommand.php: -------------------------------------------------------------------------------- 1 | migrations = $migrations; 45 | 46 | parent::__construct(); 47 | } 48 | 49 | /** 50 | * Internal function to execute the command. 51 | * 52 | * @param InputInterface $input The input to inject into the command. 53 | * @param OutputInterface $output The output to inject into the command. 54 | * 55 | * @return integer The command exit code 56 | */ 57 | protected function doExecute(InputInterface $input, OutputInterface $output): int 58 | { 59 | $symfonyStyle = new SymfonyStyle($input, $output); 60 | 61 | $symfonyStyle->title('Database Migrations: Check Status'); 62 | 63 | $status = $this->migrations->checkStatus(); 64 | 65 | if (!$status->tableExists) { 66 | $symfonyStyle->comment('The migrations table does not exist, run the "database:migrate" command to set up the database.'); 67 | } elseif ($status->latest) { 68 | $symfonyStyle->success('Your database is up-to-date.'); 69 | } else { 70 | $symfonyStyle->comment(sprintf('Your database is not up-to-date. You are missing %d migration(s).', $status->missingMigrations)); 71 | 72 | $symfonyStyle->table( 73 | [ 74 | 'Current Version', 75 | 'Latest Version', 76 | ], 77 | [ 78 | [ 79 | $status->currentVersion, 80 | $status->latestVersion, 81 | ], 82 | ] 83 | ); 84 | 85 | $symfonyStyle->comment('To update, run the "database:migrate" command.'); 86 | } 87 | 88 | return 0; 89 | } 90 | 91 | /** 92 | * Configures the current command. 93 | * 94 | * @return void 95 | */ 96 | protected function configure(): void 97 | { 98 | $this->setDescription('Check the database migration status.'); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Commands/SnapshotCommand.php: -------------------------------------------------------------------------------- 1 | view = $view; 57 | $this->filesystem = $filesystem; 58 | 59 | parent::__construct(); 60 | } 61 | 62 | /** 63 | * Internal function to execute the command. 64 | * 65 | * @param InputInterface $input The input to inject into the command. 66 | * @param OutputInterface $output The output to inject into the command. 67 | * 68 | * @return integer The command exit code 69 | */ 70 | protected function doExecute(InputInterface $input, OutputInterface $output): int 71 | { 72 | $symfonyStyle = new SymfonyStyle($input, $output); 73 | 74 | $symfonyStyle->title('Creating Statistics Snapshot'); 75 | 76 | // We want the full raw data set for our snapshot 77 | $this->view->isAuthorizedRaw(true); 78 | 79 | $source = $input->getOption('source'); 80 | 81 | $filename = date('YmdHis'); 82 | 83 | if ($source) { 84 | if (!\in_array($source, StatisticsRepository::ALLOWED_SOURCES)) { 85 | throw new InvalidOptionException( 86 | \sprintf( 87 | 'Invalid source "%s" given, valid options are: %s', 88 | $source, 89 | implode(', ', StatisticsRepository::ALLOWED_SOURCES) 90 | ) 91 | ); 92 | } 93 | 94 | $this->view->setSource($source); 95 | 96 | $filename .= '_' . $source; 97 | } 98 | 99 | if (!$this->filesystem->write($filename, $this->view->render())) { 100 | $symfonyStyle->error('Failed writing snapshot to the filesystem.'); 101 | 102 | return 1; 103 | } 104 | 105 | $symfonyStyle->success('Snapshot recorded.'); 106 | 107 | return 0; 108 | } 109 | 110 | /** 111 | * Configures the current command. 112 | * 113 | * @return void 114 | */ 115 | protected function configure(): void 116 | { 117 | $this->setDescription('Takes a snapshot of the statistics data.'); 118 | $this->addOption( 119 | 'source', 120 | null, 121 | InputOption::VALUE_OPTIONAL, 122 | 'If given, filters the snapshot to a single source.' 123 | ); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Commands/SnapshotRecentlyUpdatedCommand.php: -------------------------------------------------------------------------------- 1 | view = $view; 57 | $this->filesystem = $filesystem; 58 | 59 | parent::__construct(); 60 | } 61 | 62 | /** 63 | * Internal function to execute the command. 64 | * 65 | * @param InputInterface $input The input to inject into the command. 66 | * @param OutputInterface $output The output to inject into the command. 67 | * 68 | * @return integer The command exit code 69 | */ 70 | protected function doExecute(InputInterface $input, OutputInterface $output): int 71 | { 72 | $symfonyStyle = new SymfonyStyle($input, $output); 73 | 74 | $symfonyStyle->title('Creating Statistics Snapshot'); 75 | 76 | // We want the full raw data set for our snapshot 77 | $this->view->isAuthorizedRaw(true); 78 | $this->view->isRecent(true); 79 | 80 | $source = $input->getOption('source'); 81 | 82 | $filename = date('YmdHis') . '_recent'; 83 | 84 | if ($source) { 85 | if (!\in_array($source, StatisticsRepository::ALLOWED_SOURCES)) { 86 | throw new InvalidOptionException( 87 | \sprintf( 88 | 'Invalid source "%s" given, valid options are: %s', 89 | $source, 90 | implode(', ', StatisticsRepository::ALLOWED_SOURCES) 91 | ) 92 | ); 93 | } 94 | 95 | $this->view->setSource($source); 96 | 97 | $filename .= '_' . $source; 98 | } 99 | 100 | if (!$this->filesystem->write($filename, $this->view->render())) { 101 | $symfonyStyle->error('Failed writing snapshot to the filesystem.'); 102 | 103 | return 1; 104 | } 105 | 106 | $symfonyStyle->success('Snapshot recorded.'); 107 | 108 | return 0; 109 | } 110 | 111 | /** 112 | * Configures the current command. 113 | * 114 | * @return void 115 | */ 116 | protected function configure(): void 117 | { 118 | $this->setDescription('Takes a snapshot of the recently updated statistics data.'); 119 | $this->addOption( 120 | 'source', 121 | null, 122 | InputOption::VALUE_OPTIONAL, 123 | 'If given, filters the snapshot to a single source.' 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Commands/Tags/AbstractTagCommand.php: -------------------------------------------------------------------------------- 1 | github = $github; 68 | $this->filesystem = $filesystem; 69 | 70 | parent::__construct(); 71 | } 72 | 73 | /** 74 | * Internal hook to initialise the command after the input has been bound and before the input is validated. 75 | * 76 | * @param InputInterface $input The input to inject into the command. 77 | * @param OutputInterface $output The output to inject into the command. 78 | * 79 | * @return void 80 | */ 81 | protected function initialise(InputInterface $input, OutputInterface $output): void 82 | { 83 | $this->io = new SymfonyStyle($input, $output); 84 | } 85 | 86 | /** 87 | * Get the tags for a repository 88 | * 89 | * @return array 90 | */ 91 | protected function getTags(): array 92 | { 93 | $tags = []; 94 | 95 | $this->io->comment('Fetching page 1 of tags.'); 96 | 97 | // Get the first page so we can process the headers to figure out how many times we need to do this 98 | $tags = array_merge($tags, $this->github->repositories->getTags($this->repoOwner, $this->repoName, 1)); 99 | 100 | $response = $this->github->repositories->getApiResponse(); 101 | 102 | if ($response->hasHeader('Link')) { 103 | preg_match('/(\?page=[0-9]+>; rel=\"last\")/', $response->getHeader('Link')[0], $matches); 104 | 105 | if ($matches && isset($matches[0])) { 106 | preg_match('/\d+/', $matches[0], $pages); 107 | 108 | $lastPage = $pages[0]; 109 | 110 | for ($page = 2; $page <= $lastPage; $page++) { 111 | $this->io->comment(sprintf('Fetching page %d of %d pages of tags.', $page, $lastPage)); 112 | 113 | $tags = array_merge($tags, $this->github->repositories->getTags($this->repoOwner, $this->repoName, $page)); 114 | } 115 | } 116 | } 117 | 118 | return $tags; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Commands/Tags/FetchJoomlaTagsCommand.php: -------------------------------------------------------------------------------- 1 | repoName = 'joomla-cms'; 43 | $this->repoOwner = 'joomla'; 44 | } 45 | 46 | /** 47 | * Internal function to execute the command. 48 | * 49 | * @param InputInterface $input The input to inject into the command. 50 | * @param OutputInterface $output The output to inject into the command. 51 | * 52 | * @return integer The command exit code 53 | */ 54 | protected function doExecute(InputInterface $input, OutputInterface $output): int 55 | { 56 | $this->io->title('Fetching Joomla Releases'); 57 | 58 | $versions = []; 59 | $highVersion = '0.0.0'; 60 | 61 | foreach ($this->getTags() as $tag) { 62 | $version = $this->validateVersionNumber($tag->name); 63 | 64 | // Only process if the tag name looks like a version number 65 | if ($version === false) { 66 | continue; 67 | } 68 | 69 | // Joomla only uses major.minor.patch so everything else is invalid 70 | $explodedVersion = explode('.', $version); 71 | 72 | if (\count($explodedVersion) != 3) { 73 | continue; 74 | } 75 | 76 | // Version collection is valid for the 3.x series and later 77 | if (version_compare($version, '3.0.0', '<')) { 78 | continue; 79 | } 80 | 81 | // We have a valid version number, great news... add it to our array if it isn't already present 82 | if (!\in_array($version, $versions)) { 83 | $versions[] = $version; 84 | 85 | // If this version is higher than our high version, replace it 86 | // TODO - When 4.0 is stable adjust this logic 87 | if (version_compare($version, '4.0', '<') && version_compare($version, $highVersion, '>')) { 88 | $highVersion = $version; 89 | } 90 | } 91 | } 92 | 93 | // If the high version is not the default then let's add some (arbitrary) allowed versions based on the repo's dev structure 94 | if ($highVersion !== '0.0.0') { 95 | $explodedVersion = explode('.', $highVersion); 96 | 97 | // Allow the next patch release after this one 98 | $nextPatch = $explodedVersion[2] + 1; 99 | $versions[] = $explodedVersion[0] . '.' . $explodedVersion[1] . '.' . $nextPatch; 100 | 101 | // And allow the next minor release after this one 102 | $nextMinor = $explodedVersion[1] + 1; 103 | $versions[] = $explodedVersion[0] . '.' . $nextMinor . '.0'; 104 | } 105 | 106 | if (!$this->filesystem->put('joomla.json', json_encode($versions))) { 107 | $this->io->error('Failed writing version data to the filesystem.'); 108 | 109 | return 1; 110 | } 111 | 112 | $this->io->success('Joomla! versions updated.'); 113 | 114 | return 0; 115 | } 116 | 117 | /** 118 | * Configures the current command. 119 | * 120 | * @return void 121 | */ 122 | protected function configure(): void 123 | { 124 | $this->setDescription('Parses the release tags for the Joomla! CMS GitHub repository.'); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Commands/Tags/FetchPhpTagsCommand.php: -------------------------------------------------------------------------------- 1 | repoName = 'php-src'; 43 | $this->repoOwner = 'php'; 44 | } 45 | 46 | /** 47 | * Internal function to execute the command. 48 | * 49 | * @param InputInterface $input The input to inject into the command. 50 | * @param OutputInterface $output The output to inject into the command. 51 | * 52 | * @return integer The command exit code 53 | */ 54 | protected function doExecute(InputInterface $input, OutputInterface $output): int 55 | { 56 | $this->io->title('Fetching PHP Releases'); 57 | 58 | $versions = []; 59 | $supportedBranches = [ 60 | '7.2' => '', 61 | '7.3' => '', 62 | '7.4' => '', 63 | ]; 64 | 65 | foreach ($this->getTags() as $tag) { 66 | // Replace 'php-' from the tag to get our version number; skip if the segment doesn't exist 67 | if (strpos($tag->name, 'php-') !== 0) { 68 | continue; 69 | } 70 | 71 | $version = substr($tag->name, 4); 72 | 73 | $version = $this->validateVersionNumber($version); 74 | 75 | // Only process if the tag name looks like a version number 76 | if ($version === false) { 77 | continue; 78 | } 79 | 80 | // We only track versions based on major.minor.patch so everything else is invalid 81 | $explodedVersion = explode('.', $version); 82 | 83 | if (\count($explodedVersion) != 3) { 84 | continue; 85 | } 86 | 87 | // Joomla collects stats for the 3.x branch and later, the minimum PHP version for 3.0.0 was 5.3.1 88 | if (version_compare($version, '5.3.1', '<')) { 89 | continue; 90 | } 91 | 92 | // We have a valid version number, great news... add it to our array if it isn't already present 93 | if (!\in_array($version, $versions)) { 94 | $versions[] = $version; 95 | 96 | // If this version is higher than our branch's high version, replace it 97 | $branch = substr($version, 0, 3); 98 | 99 | if (isset($supportedBranches[$branch]) && version_compare($version, $supportedBranches[$branch], '>')) { 100 | $supportedBranches[$branch] = $version; 101 | } 102 | } 103 | } 104 | 105 | // For each supported branch, also add the next patch release 106 | foreach ($supportedBranches as $branch => $version) { 107 | $explodedVersion = explode('.', $version); 108 | 109 | $nextPatch = $explodedVersion[2] + 1; 110 | $versions[] = $explodedVersion[0] . '.' . $explodedVersion[1] . '.' . $nextPatch; 111 | } 112 | 113 | // Use $branch from the previous loop to allow the next minor version (PHP's master branch) 114 | $explodedVersion = explode('.', $branch); 115 | 116 | $nextMinor = $explodedVersion[1] + 1; 117 | $nextRelease = $explodedVersion[0] . '.' . $nextMinor . '.0'; 118 | 119 | // There won't be a PHP 7.5, change next release to 8.0 if needed 120 | if ($nextRelease === '7.5.0') { 121 | $nextRelease = '8.0.0'; 122 | } 123 | 124 | if (!\in_array($nextRelease, $versions, true)) { 125 | $versions[] = $nextRelease; 126 | } 127 | 128 | if (!$this->filesystem->put('php.json', json_encode($versions))) { 129 | $this->io->error('Failed writing version data to the filesystem.'); 130 | 131 | return 1; 132 | } 133 | 134 | $this->io->success('PHP versions updated.'); 135 | 136 | return 0; 137 | } 138 | 139 | /** 140 | * Configures the current command. 141 | * 142 | * @return void 143 | */ 144 | protected function configure(): void 145 | { 146 | $this->setDescription('Parses the release tags for the PHP GitHub repository.'); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Commands/UpdateCommand.php: -------------------------------------------------------------------------------- 1 | title('Update Server'); 45 | $symfonyStyle->comment('Updating server to git HEAD'); 46 | 47 | if (!$this->getHelperSet()) { 48 | $symfonyStyle->error('The helper set has not been registered to the update command.'); 49 | 50 | return 1; 51 | } 52 | 53 | /** @var ProcessHelper $processHelper */ 54 | $processHelper = $this->getHelperSet()->get('process'); 55 | 56 | // Pull from remote repo 57 | try { 58 | $processHelper->mustRun($output, new Process(['git', 'pull'], APPROOT)); 59 | } catch (ProcessFailedException $e) { 60 | $this->getApplication()->getLogger()->error('Could not execute `git pull`', ['exception' => $e]); 61 | 62 | $symfonyStyle->error('Error running `git pull`: ' . $e->getMessage()); 63 | 64 | return 1; 65 | } 66 | 67 | $symfonyStyle->comment('Updating Composer resources'); 68 | 69 | // Run Composer install 70 | try { 71 | $processHelper->mustRun($output, new Process(['composer', 'install', '--no-dev', '-o', '-a'], APPROOT)); 72 | } catch (ProcessFailedException $e) { 73 | $this->getApplication()->getLogger()->error('Could not update Composer resources', ['exception' => $e]); 74 | 75 | $symfonyStyle->error('Error updating Composer resources: ' . $e->getMessage()); 76 | 77 | return 1; 78 | } 79 | 80 | $symfonyStyle->success('Update complete'); 81 | 82 | return 0; 83 | } 84 | 85 | /** 86 | * Configures the current command. 87 | * 88 | * @return void 89 | */ 90 | protected function configure(): void 91 | { 92 | $this->setDescription('Update the server to the current git HEAD'); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Controllers/DisplayStatisticsController.php: -------------------------------------------------------------------------------- 1 | view = $view; 38 | } 39 | 40 | /** 41 | * Execute the controller. 42 | * 43 | * @return boolean 44 | */ 45 | public function execute() 46 | { 47 | // Check if we are allowed to receive the raw data 48 | $authorizedRaw = $this->getInput()->server->getString('HTTP_JOOMLA_RAW', 'fail') === $this->getApplication()->get('stats.rawdata', false); 49 | 50 | // Check if a single data source is requested 51 | $source = $this->getInput()->getString('source', ''); 52 | 53 | // Check if a timeframe is requested 54 | $timeframe = (int) $this->getInput()->getInt('timeframe', 0); 55 | 56 | $this->view->isAuthorizedRaw($authorizedRaw); 57 | $this->view->setSource($source); 58 | $this->view->setTimeframe($timeframe); 59 | 60 | $this->getApplication()->setBody($this->view->render()); 61 | 62 | return true; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Database/Exception/CannotInitializeMigrationsException.php: -------------------------------------------------------------------------------- 1 | database = $database; 48 | $this->filesystem = $filesystem; 49 | } 50 | 51 | /** 52 | * Checks the migration status of the current installation 53 | * 54 | * @return MigrationsStatus 55 | */ 56 | public function checkStatus(): MigrationsStatus 57 | { 58 | $response = new MigrationsStatus(); 59 | 60 | try { 61 | // First get the list of applied migrations 62 | $appliedMigrations = $this->database->setQuery( 63 | $this->database->getQuery(true) 64 | ->select('version') 65 | ->from('#__migrations') 66 | )->loadColumn(); 67 | } catch (ExecutionFailureException | PrepareStatementFailureException $exception) { 68 | // On PDO we're checking "42S02, 1146, Table 'XXX.#__migrations' doesn't exist" 69 | if (strpos($exception->getMessage(), "migrations' doesn't exist") === false) { 70 | throw $exception; 71 | } 72 | 73 | $response->tableExists = false; 74 | 75 | return $response; 76 | } 77 | 78 | // Now get the list of all known migrations 79 | $knownMigrations = []; 80 | 81 | foreach ($this->filesystem->listContents() as $migrationFiles) { 82 | $knownMigrations[] = $migrationFiles['filename']; 83 | } 84 | 85 | // Don't rely on file system ordering. 86 | sort($knownMigrations); 87 | 88 | // Validate all migrations are applied; the count and latest versions should match 89 | if (\count($appliedMigrations) === \count($knownMigrations)) { 90 | $appliedValues = array_values($appliedMigrations); 91 | $knownValues = array_values($knownMigrations); 92 | 93 | $latestApplied = (int) end($appliedValues); 94 | $latestKnown = (int) end($knownValues); 95 | 96 | // Versions match, good to go 97 | if ($latestApplied === $latestKnown) { 98 | $response->latest = true; 99 | 100 | return $response; 101 | } 102 | } 103 | 104 | // The system is not on the latest version, get the relevant data 105 | $response->missingMigrations = \count($knownMigrations) - \count($appliedMigrations); 106 | $response->currentVersion = array_pop($appliedMigrations); 107 | $response->latestVersion = array_pop($knownMigrations); 108 | 109 | return $response; 110 | } 111 | 112 | /** 113 | * Migrate the database 114 | * 115 | * @param string|null $version Optional migration version to run 116 | * 117 | * @return void 118 | */ 119 | public function migrateDatabase(?string $version = null): void 120 | { 121 | try { 122 | // Determine the migrations to apply 123 | $appliedMigrations = $this->database->setQuery( 124 | $this->database->getQuery(true) 125 | ->select('version') 126 | ->from('#__migrations') 127 | )->loadColumn(); 128 | } catch (ExecutionFailureException | PrepareStatementFailureException $exception) { 129 | // If the table does not exist, we can still try to run migrations 130 | if (strpos($exception->getMessage(), "migrations' doesn't exist") === false) { 131 | throw $exception; 132 | } 133 | 134 | // If given a version, we can only execute it if it is the first migration, otherwise we've got other problems 135 | if ($version !== null && $version !== '') { 136 | $firstMigration = $this->filesystem->listContents()[0]; 137 | 138 | if ($firstMigration['filename'] !== $version) { 139 | throw new CannotInitializeMigrationsException( 140 | 'The migrations have not yet been initialized and the first migration has not been given as the version to run.' 141 | ); 142 | } 143 | } 144 | 145 | $appliedMigrations = []; 146 | } 147 | 148 | // If a version is specified, check if that migration is already applied and if not, run that one only 149 | if ($version !== null && $version !== '') { 150 | // If it's already applied, there's nothing to do here 151 | if (\in_array($version, $appliedMigrations)) { 152 | return; 153 | } 154 | 155 | $this->doMigration($version); 156 | 157 | return; 158 | } 159 | 160 | // We need to check the known migrations and filter out the applied ones to know what to do 161 | $knownMigrations = []; 162 | 163 | foreach ($this->filesystem->listContents() as $migrationFiles) { 164 | $knownMigrations[] = $migrationFiles['filename']; 165 | } 166 | 167 | foreach (array_diff($knownMigrations, $appliedMigrations) as $version) { 168 | $this->doMigration($version); 169 | } 170 | } 171 | 172 | /** 173 | * Perform the database migration for the specified version 174 | * 175 | * @param string $version Migration version to run 176 | * 177 | * @return void 178 | * 179 | * @throws UnknownMigrationException 180 | * @throws UnreadableFileException 181 | */ 182 | private function doMigration(string $version): void 183 | { 184 | $sqlFile = $version . '.sql'; 185 | 186 | if (!$this->filesystem->has($sqlFile)) { 187 | throw new UnknownMigrationException($sqlFile); 188 | } 189 | 190 | $queries = $this->filesystem->read($sqlFile); 191 | 192 | if ($queries === false) { 193 | throw new UnreadableFileException( 194 | sprintf( 195 | 'Could not read data from the %s SQL file, please update the database manually.', 196 | $sqlFile 197 | ) 198 | ); 199 | } 200 | 201 | foreach (DatabaseDriver::splitSql($queries) as $query) { 202 | $query = trim($query); 203 | 204 | if (!empty($query)) { 205 | $this->database->setQuery($query)->execute(); 206 | } 207 | } 208 | 209 | // Log the migration into the database 210 | $this->database->setQuery( 211 | $this->database->getQuery(true) 212 | ->insert('#__migrations') 213 | ->columns('version') 214 | ->values($version) 215 | )->execute(); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/Database/MigrationsStatus.php: -------------------------------------------------------------------------------- 1 | analytics = $analytics; 43 | } 44 | 45 | /** 46 | * Returns an array of events this subscriber will listen to. 47 | * 48 | * @return array 49 | */ 50 | public static function getSubscribedEvents(): array 51 | { 52 | return [ 53 | ApplicationEvents::BEFORE_EXECUTE => 'onBeforeExecute', 54 | ]; 55 | } 56 | 57 | /** 58 | * Logs the visit to analytics if able. 59 | * 60 | * @param ApplicationEvent $event Event object 61 | * 62 | * @return void 63 | */ 64 | public function onBeforeExecute(ApplicationEvent $event): void 65 | { 66 | $app = $event->getApplication(); 67 | 68 | if (!($app instanceof WebApplicationInterface)) { 69 | return; 70 | } 71 | 72 | // On a GET request to the live domain, submit analytics data 73 | if ( 74 | $app->getInput()->getMethod() !== 'GET' 75 | || strpos($app->getInput()->server->getString('HTTP_HOST', ''), 'developer.joomla.org') !== 0 76 | ) { 77 | return; 78 | } 79 | 80 | $this->analytics->setAsyncRequest(true) 81 | ->setProtocolVersion('1') 82 | ->setTrackingId('UA-544070-16') 83 | ->setClientId(Uuid::uuid4()->toString()) 84 | ->setDocumentPath($app->get('uri.base.path')) 85 | ->setIpOverride($app->getInput()->server->getString('REMOTE_ADDR', '127.0.0.1')) 86 | ->setUserAgentOverride($app->getInput()->server->getString('HTTP_USER_AGENT', 'JoomlaStats/1.0')); 87 | 88 | // Don't allow sending Analytics data to cause a failure 89 | try { 90 | $this->analytics->sendPageview(); 91 | } catch (\Exception $e) { 92 | // Log the error for reference 93 | $this->logger->error( 94 | 'Error sending analytics data.', 95 | ['exception' => $e] 96 | ); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/EventListener/ErrorSubscriber.php: -------------------------------------------------------------------------------- 1 | 'handleWebError', 41 | ConsoleEvents::APPLICATION_ERROR => 'handleConsoleError', 42 | ]; 43 | } 44 | 45 | /** 46 | * Handle console application errors. 47 | * 48 | * @param ConsoleApplicationErrorEvent $event Event object 49 | * 50 | * @return void 51 | */ 52 | public function handleConsoleError(ConsoleApplicationErrorEvent $event): void 53 | { 54 | $this->logger->error( 55 | sprintf('Uncaught Throwable of type %s caught.', \get_class($event->getError())), 56 | ['exception' => $event->getError()] 57 | ); 58 | 59 | (new SymfonyStyle($event->getApplication()->getConsoleInput(), $event->getApplication()->getConsoleOutput())) 60 | ->error(sprintf('Uncaught Throwable of type %s caught: %s', \get_class($event->getError()), $event->getError()->getMessage())); 61 | } 62 | 63 | /** 64 | * Handle web application errors. 65 | * 66 | * @param ApplicationErrorEvent $event Event object 67 | * 68 | * @return void 69 | */ 70 | public function handleWebError(ApplicationErrorEvent $event): void 71 | { 72 | $app = $event->getApplication(); 73 | 74 | switch (true) { 75 | case $event->getError() instanceof MethodNotAllowedException: 76 | // Log the error for reference 77 | $this->logger->error( 78 | sprintf('Route `%s` not supported by method `%s`', $app->get('uri.route'), $app->getInput()->getMethod()), 79 | ['exception' => $event->getError()] 80 | ); 81 | 82 | $this->prepareResponse($event); 83 | 84 | $app->setHeader('Allow', implode(', ', $event->getError()->getAllowedMethods())); 85 | 86 | break; 87 | 88 | case $event->getError() instanceof RouteNotFoundException: 89 | // Log the error for reference 90 | $this->logger->error( 91 | sprintf('Route `%s` not found', $app->get('uri.route')), 92 | ['exception' => $event->getError()] 93 | ); 94 | 95 | $this->prepareResponse($event); 96 | 97 | break; 98 | 99 | default: 100 | $this->logger->error( 101 | sprintf('Uncaught Throwable of type %s caught.', \get_class($event->getError())), 102 | ['exception' => $event->getError()] 103 | ); 104 | 105 | $this->prepareResponse($event); 106 | 107 | break; 108 | } 109 | } 110 | 111 | /** 112 | * Prepare the response for the event 113 | * 114 | * @param ApplicationErrorEvent $event Event object 115 | * 116 | * @return void 117 | */ 118 | private function prepareResponse(ApplicationErrorEvent $event): void 119 | { 120 | /** @var WebApplication $app */ 121 | $app = $event->getApplication(); 122 | 123 | $app->allowCache(false); 124 | 125 | $data = [ 126 | 'code' => $event->getError()->getCode(), 127 | 'message' => $event->getError()->getMessage(), 128 | 'error' => true, 129 | ]; 130 | 131 | $response = new JsonResponse($data); 132 | 133 | switch ($event->getError()->getCode()) { 134 | case 404: 135 | $response = $response->withStatus(404); 136 | 137 | break; 138 | 139 | case 405: 140 | $response = $response->withStatus(405); 141 | 142 | break; 143 | 144 | case 500: 145 | default: 146 | $response = $response->withStatus(500); 147 | 148 | break; 149 | } 150 | 151 | $app->setResponse($response); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/GitHub/GitHub.php: -------------------------------------------------------------------------------- 1 | $name)) { 36 | $this->$name = new $class($this->options, $this->client); 37 | } 38 | 39 | return $this->$name; 40 | } 41 | 42 | return parent::__get($name); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/GitHub/Package.php: -------------------------------------------------------------------------------- 1 | package . '\\' . ucfirst($name); 31 | 32 | if (class_exists($class)) { 33 | if (!isset($this->$name)) { 34 | $this->$name = new $class($this->options, $this->client); 35 | } 36 | 37 | return $this->$name; 38 | } 39 | 40 | return parent::__get($name); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/GitHub/Package/Repositories.php: -------------------------------------------------------------------------------- 1 | apiResponse; 35 | } 36 | 37 | /** 38 | * Get a list of tags on a repository. 39 | * 40 | * Note: This is different from the parent `getListTags` method as it adds support for the API's pagination. This extended method can be removed 41 | * if the upstream class gains this support. 42 | * 43 | * @param string $owner Repository owner. 44 | * @param string $repo Repository name. 45 | * @param integer $page The page number from which to get items. 46 | * 47 | * @return object 48 | */ 49 | public function getTags($owner, $repo, $page = 0) 50 | { 51 | // Build the request path. 52 | $path = '/repos/' . $owner . '/' . $repo . '/tags'; 53 | 54 | // Send the request. 55 | $this->apiResponse = $this->client->get($this->fetchUrl($path, $page)); 56 | 57 | return $this->processResponse($this->apiResponse); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | booted) { 52 | return; 53 | } 54 | 55 | $this->setContainer($this->buildContainer()); 56 | 57 | // Register deprecation logging via Monolog 58 | ErrorHandler::register($this->getContainer()->get(Logger::class), [E_DEPRECATED, E_USER_DEPRECATED], false, false); 59 | 60 | $this->booted = true; 61 | } 62 | 63 | /** 64 | * Check if the Kernel is booted 65 | * 66 | * @return boolean 67 | */ 68 | public function isBooted(): bool 69 | { 70 | return $this->booted; 71 | } 72 | 73 | /** 74 | * Run the kernel 75 | * 76 | * @return void 77 | */ 78 | public function run(): void 79 | { 80 | $this->boot(); 81 | 82 | if (!$this->getContainer()->has(AbstractApplication::class)) { 83 | throw new \RuntimeException('The application has not been registered with the container.'); 84 | } 85 | 86 | $this->getContainer()->get(AbstractApplication::class)->execute(); 87 | } 88 | 89 | /** 90 | * Build the service container 91 | * 92 | * @return Container 93 | */ 94 | protected function buildContainer(): Container 95 | { 96 | $config = $this->loadConfiguration(); 97 | 98 | $container = new Container(); 99 | $container->share('config', $config); 100 | 101 | $container->registerServiceProvider(new AnalyticsServiceProvider()) 102 | ->registerServiceProvider(new ConsoleServiceProvider()) 103 | ->registerServiceProvider(new DatabaseProvider()) 104 | ->registerServiceProvider(new DatabaseServiceProvider()) 105 | ->registerServiceProvider(new EventServiceProvider()) 106 | ->registerServiceProvider(new FlysystemServiceProvider()) 107 | ->registerServiceProvider(new GitHubServiceProvider()) 108 | ->registerServiceProvider(new MonologServiceProvider()) 109 | ->registerServiceProvider(new RepositoryServiceProvider()) 110 | ->registerServiceProvider(new WebApplicationServiceProvider()); 111 | 112 | $container->share( 113 | \InfluxDB2\Client::class, 114 | function (Container $container) { 115 | /** @var \Joomla\Registry\Registry $config */ 116 | $config = $container->get('config'); 117 | $options = (array) $config->get('influxdb'); 118 | 119 | return new \InfluxDB2\Client($options); 120 | } 121 | ); 122 | 123 | 124 | return $container; 125 | } 126 | 127 | /** 128 | * Load the application's configuration 129 | * 130 | * @return Registry 131 | */ 132 | private function loadConfiguration(): Registry 133 | { 134 | $registry = new Registry(); 135 | $registry->loadFile(APPROOT . '/etc/config.dist.json'); 136 | 137 | if (file_exists(APPROOT . '/etc/config.json')) { 138 | $registry->loadFile(APPROOT . '/etc/config.json'); 139 | } 140 | 141 | return $registry; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Kernel/ConsoleKernel.php: -------------------------------------------------------------------------------- 1 | alias(AbstractApplication::class, Application::class); 35 | 36 | // Alias the web application logger as the primary logger for the environment 37 | $container->alias('monolog', 'monolog.logger.cli') 38 | ->alias('logger', 'monolog.logger.cli') 39 | ->alias(Logger::class, 'monolog.logger.cli') 40 | ->alias(LoggerInterface::class, 'monolog.logger.cli'); 41 | 42 | // Set error reporting based on config 43 | $errorReporting = (int) $container->get('config')->get('errorReporting', 0); 44 | error_reporting($errorReporting); 45 | 46 | return $container; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Kernel/WebKernel.php: -------------------------------------------------------------------------------- 1 | alias(AbstractApplication::class, AbstractWebApplication::class); 35 | 36 | // Alias the web application logger as the primary logger for the environment 37 | $container->alias('monolog', 'monolog.logger.application') 38 | ->alias('logger', 'monolog.logger.application') 39 | ->alias(Logger::class, 'monolog.logger.application') 40 | ->alias(LoggerInterface::class, 'monolog.logger.application'); 41 | 42 | // Set error reporting based on config 43 | $errorReporting = (int) $container->get('config')->get('errorReporting', 0); 44 | error_reporting($errorReporting); 45 | 46 | return $container; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Providers/AnalyticsServiceProvider.php: -------------------------------------------------------------------------------- 1 | share(Analytics::class, [$this, 'getAnalyticsService']); 31 | } 32 | 33 | /** 34 | * Get the Analytics class service 35 | * 36 | * @param Container $container The DI container. 37 | * 38 | * @return Analytics 39 | */ 40 | public function getAnalyticsService(Container $container): Analytics 41 | { 42 | return new Analytics(true); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Providers/ConsoleServiceProvider.php: -------------------------------------------------------------------------------- 1 | share(Application::class, [$this, 'getConsoleApplicationService']); 50 | 51 | /* 52 | * Application Helpers and Dependencies 53 | */ 54 | 55 | $container->alias(ContainerLoader::class, LoaderInterface::class) 56 | ->share(LoaderInterface::class, [$this, 'getCommandLoaderService']); 57 | 58 | /* 59 | * Commands 60 | */ 61 | 62 | $container->share(DebugEventDispatcherCommand::class, [$this, 'getDebugEventDispatcherCommandService']); 63 | $container->share(DebugRouterCommand::class, [$this, 'getDebugRouterCommandService']); 64 | $container->share(MigrateCommand::class, [$this, 'getDatabaseMigrateCommandService']); 65 | $container->share(MigrationStatusCommand::class, [$this, 'getDatabaseMigrationStatusCommandService']); 66 | $container->share(FetchJoomlaTagsCommand::class, [$this, 'getFetchJoomlaTagsCommandService']); 67 | $container->share(FetchPhpTagsCommand::class, [$this, 'getFetchPhpTagsCommandService']); 68 | $container->share(SnapshotCommand::class, [$this, 'getSnapshotCommandService']); 69 | $container->share(SnapshotRecentlyUpdatedCommand::class, [$this, 'getSnapshotRecentlyUpdatedCommandService']); 70 | $container->share(UpdateCommand::class, [$this, 'getUpdateCommandService']); 71 | } 72 | 73 | /** 74 | * Get the LoaderInterface service 75 | * 76 | * @param Container $container The DI container. 77 | * 78 | * @return LoaderInterface 79 | */ 80 | public function getCommandLoaderService(Container $container): LoaderInterface 81 | { 82 | $mapping = [ 83 | DebugEventDispatcherCommand::getDefaultName() => DebugEventDispatcherCommand::class, 84 | DebugRouterCommand::getDefaultName() => DebugRouterCommand::class, 85 | MigrationStatusCommand::getDefaultName() => MigrationStatusCommand::class, 86 | MigrateCommand::getDefaultName() => MigrateCommand::class, 87 | FetchJoomlaTagsCommand::getDefaultName() => FetchJoomlaTagsCommand::class, 88 | FetchPhpTagsCommand::getDefaultName() => FetchPhpTagsCommand::class, 89 | SnapshotCommand::getDefaultName() => SnapshotCommand::class, 90 | SnapshotRecentlyUpdatedCommand::getDefaultName() => SnapshotRecentlyUpdatedCommand::class, 91 | UpdateCommand::getDefaultName() => UpdateCommand::class, 92 | ]; 93 | 94 | return new ContainerLoader($container, $mapping); 95 | } 96 | 97 | /** 98 | * Get the console Application service 99 | * 100 | * @param Container $container The DI container. 101 | * 102 | * @return Application 103 | */ 104 | public function getConsoleApplicationService(Container $container): Application 105 | { 106 | $application = new Application(new ArgvInput(), new ConsoleOutput(), $container->get('config')); 107 | 108 | $application->setCommandLoader($container->get(LoaderInterface::class)); 109 | $application->setDispatcher($container->get(DispatcherInterface::class)); 110 | $application->setLogger($container->get(LoggerInterface::class)); 111 | $application->setName('Joomla! Statistics Server'); 112 | 113 | return $application; 114 | } 115 | 116 | /** 117 | * Get the MigrateCommand service 118 | * 119 | * @param Container $container The DI container. 120 | * 121 | * @return MigrateCommand 122 | */ 123 | public function getDatabaseMigrateCommandService(Container $container): MigrateCommand 124 | { 125 | $command = new MigrateCommand($container->get(Migrations::class)); 126 | $command->setLogger($container->get(LoggerInterface::class)); 127 | 128 | return $command; 129 | } 130 | 131 | /** 132 | * Get the MigrationStatusCommand service 133 | * 134 | * @param Container $container The DI container. 135 | * 136 | * @return MigrationStatusCommand 137 | */ 138 | public function getDatabaseMigrationStatusCommandService(Container $container): MigrationStatusCommand 139 | { 140 | return new MigrationStatusCommand($container->get(Migrations::class)); 141 | } 142 | 143 | /** 144 | * Get the DebugEventDispatcherCommand service 145 | * 146 | * @param Container $container The DI container. 147 | * 148 | * @return DebugEventDispatcherCommand 149 | */ 150 | public function getDebugEventDispatcherCommandService(Container $container): DebugEventDispatcherCommand 151 | { 152 | return new DebugEventDispatcherCommand($container->get(DispatcherInterface::class)); 153 | } 154 | 155 | /** 156 | * Get the DebugRouterCommand service 157 | * 158 | * @param Container $container The DI container. 159 | * 160 | * @return DebugRouterCommand 161 | */ 162 | public function getDebugRouterCommandService(Container $container): DebugRouterCommand 163 | { 164 | return new DebugRouterCommand($container->get(Router::class)); 165 | } 166 | 167 | /** 168 | * Get the FetchJoomlaTagsCommand service 169 | * 170 | * @param Container $container The DI container. 171 | * 172 | * @return FetchJoomlaTagsCommand 173 | */ 174 | public function getFetchJoomlaTagsCommandService(Container $container): FetchJoomlaTagsCommand 175 | { 176 | return new FetchJoomlaTagsCommand($container->get(GitHub::class), $container->get('filesystem.versions')); 177 | } 178 | 179 | /** 180 | * Get the FetchPhpTagsCommand class service 181 | * 182 | * @param Container $container The DI container. 183 | * 184 | * @return FetchPhpTagsCommand 185 | */ 186 | public function getFetchPhpTagsCommandService(Container $container): FetchPhpTagsCommand 187 | { 188 | return new FetchPhpTagsCommand($container->get(GitHub::class), $container->get('filesystem.versions')); 189 | } 190 | 191 | /** 192 | * Get the SnapshotCommand service 193 | * 194 | * @param Container $container The DI container. 195 | * 196 | * @return SnapshotCommand 197 | */ 198 | public function getSnapshotCommandService(Container $container): SnapshotCommand 199 | { 200 | return new SnapshotCommand($container->get(StatsJsonView::class), $container->get('filesystem.snapshot')); 201 | } 202 | 203 | /** 204 | * Get the SnapshotRecentlyUpdatedCommand service 205 | * 206 | * @param Container $container The DI container. 207 | * 208 | * @return SnapshotRecentlyUpdatedCommand 209 | */ 210 | public function getSnapshotRecentlyUpdatedCommandService(Container $container): SnapshotRecentlyUpdatedCommand 211 | { 212 | return new SnapshotRecentlyUpdatedCommand($container->get(StatsJsonView::class), $container->get('filesystem.snapshot')); 213 | } 214 | 215 | /** 216 | * Get the UpdateCommand class service 217 | * 218 | * @param Container $container The DI container. 219 | * 220 | * @return UpdateCommand 221 | */ 222 | public function getUpdateCommandService(Container $container): UpdateCommand 223 | { 224 | return new UpdateCommand(); 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/Providers/DatabaseServiceProvider.php: -------------------------------------------------------------------------------- 1 | extend(DatabaseDriver::class, [$this, 'extendDatabaseDriverService']); 33 | 34 | $container->alias('db.monitor.logging', LoggingMonitor::class) 35 | ->share(LoggingMonitor::class, [$this, 'getDbMonitorLoggingService']); 36 | 37 | $container->alias('db.migrations', Migrations::class) 38 | ->share(Migrations::class, [$this, 'getDbMigrationsService']); 39 | } 40 | 41 | /** 42 | * Extends the database driver service 43 | * 44 | * @param DatabaseDriver $db The database driver to extend. 45 | * @param Container $container The DI container. 46 | * 47 | * @return DatabaseDriver 48 | */ 49 | public function extendDatabaseDriverService(DatabaseDriver $db, Container $container): DatabaseDriver 50 | { 51 | $db->setMonitor($container->get(LoggingMonitor::class)); 52 | 53 | return $db; 54 | } 55 | 56 | /** 57 | * Get the `db.migrations` service 58 | * 59 | * @param Container $container The DI container. 60 | * 61 | * @return Migrations 62 | */ 63 | public function getDbMigrationsService(Container $container): Migrations 64 | { 65 | return new Migrations( 66 | $container->get(DatabaseDriver::class), 67 | $container->get('filesystem.migrations') 68 | ); 69 | } 70 | 71 | /** 72 | * Get the `db.monitor.logging` service 73 | * 74 | * @param Container $container The DI container. 75 | * 76 | * @return LoggingMonitor 77 | */ 78 | public function getDbMonitorLoggingService(Container $container): LoggingMonitor 79 | { 80 | $monitor = new LoggingMonitor(); 81 | $monitor->setLogger($container->get('monolog.logger.database')); 82 | 83 | return $monitor; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Providers/EventServiceProvider.php: -------------------------------------------------------------------------------- 1 | alias(Dispatcher::class, DispatcherInterface::class) 36 | ->share(DispatcherInterface::class, [$this, 'getDispatcherService']); 37 | 38 | $container->share(AnalyticsSubscriber::class, [$this, 'getAnalyticsSubscriberService']) 39 | ->tag('event.subscriber', [AnalyticsSubscriber::class]); 40 | 41 | $container->share(ErrorSubscriber::class, [$this, 'getErrorSubscriberService']) 42 | ->tag('event.subscriber', [ErrorSubscriber::class]); 43 | } 44 | 45 | /** 46 | * Get the AnalyticsSubscriber service 47 | * 48 | * @param Container $container The DI container. 49 | * 50 | * @return AnalyticsSubscriber 51 | */ 52 | public function getAnalyticsSubscriberService(Container $container): AnalyticsSubscriber 53 | { 54 | $subscriber = new AnalyticsSubscriber($container->get(Analytics::class)); 55 | $subscriber->setLogger($container->get(LoggerInterface::class)); 56 | 57 | return $subscriber; 58 | } 59 | 60 | /** 61 | * Get the DispatcherInterface service 62 | * 63 | * @param Container $container The DI container. 64 | * 65 | * @return DispatcherInterface 66 | */ 67 | public function getDispatcherService(Container $container): DispatcherInterface 68 | { 69 | $dispatcher = new Dispatcher(); 70 | 71 | foreach ($container->getTagged('event.subscriber') as $subscriber) { 72 | $dispatcher->addSubscriber($subscriber); 73 | } 74 | 75 | return $dispatcher; 76 | } 77 | 78 | /** 79 | * Get the ErrorSubscriber service 80 | * 81 | * @param Container $container The DI container. 82 | * 83 | * @return ErrorSubscriber 84 | */ 85 | public function getErrorSubscriberService(Container $container): ErrorSubscriber 86 | { 87 | $subscriber = new ErrorSubscriber(); 88 | $subscriber->setLogger($container->get(LoggerInterface::class)); 89 | 90 | return $subscriber; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Providers/FlysystemServiceProvider.php: -------------------------------------------------------------------------------- 1 | share('filesystem.migrations', [$this, 'getMigrationsFilesystemService']); 32 | $container->share('filesystem.snapshot', [$this, 'getSnapshotFilesystemService']); 33 | $container->share('filesystem.versions', [$this, 'getVersionsFilesystemService']); 34 | } 35 | 36 | /** 37 | * Get the `filesystem.migrations` service 38 | * 39 | * @param Container $container The DI container. 40 | * 41 | * @return Filesystem 42 | */ 43 | public function getMigrationsFilesystemService(Container $container): Filesystem 44 | { 45 | return new Filesystem(new Local(APPROOT . '/etc/migrations')); 46 | } 47 | 48 | /** 49 | * Get the `filesystem.snapshot` service 50 | * 51 | * @param Container $container The DI container. 52 | * 53 | * @return Filesystem 54 | */ 55 | public function getSnapshotFilesystemService(Container $container): Filesystem 56 | { 57 | return new Filesystem(new Local(APPROOT . '/snapshots')); 58 | } 59 | 60 | /** 61 | * Get the `filesystem.versions` service 62 | * 63 | * @param Container $container The DI container. 64 | * 65 | * @return Filesystem 66 | */ 67 | public function getVersionsFilesystemService(Container $container): Filesystem 68 | { 69 | return new Filesystem(new Local(APPROOT . '/versions')); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Providers/GitHubServiceProvider.php: -------------------------------------------------------------------------------- 1 | alias('github', BaseGithub::class) 32 | ->alias(GitHub::class, BaseGithub::class) 33 | ->share(BaseGithub::class, [$this, 'getGithubService']); 34 | } 35 | 36 | /** 37 | * Get the `github` service 38 | * 39 | * @param Container $container The DI container. 40 | * 41 | * @return GitHub 42 | */ 43 | public function getGithubService(Container $container): GitHub 44 | { 45 | /** @var \Joomla\Registry\Registry $config */ 46 | $config = $container->get('config'); 47 | 48 | return new GitHub($config->extract('github')); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Providers/MonologServiceProvider.php: -------------------------------------------------------------------------------- 1 | share('monolog.processor.psr3', [$this, 'getMonologProcessorPsr3Service']); 35 | 36 | // Register the web processor 37 | $container->share('monolog.processor.web', [$this, 'getMonologProcessorWebService']); 38 | 39 | // Register the web application handler 40 | $container->share('monolog.handler.application', [$this, 'getMonologHandlerApplicationService']); 41 | 42 | // Register the database handler 43 | $container->share('monolog.handler.database', [$this, 'getMonologHandlerDatabaseService']); 44 | 45 | // Register the web application Logger 46 | $container->share('monolog.logger.application', [$this, 'getMonologLoggerApplicationService']); 47 | 48 | // Register the CLI application Logger 49 | $container->share('monolog.logger.cli', [$this, 'getMonologLoggerCliService']); 50 | 51 | // Register the database Logger 52 | $container->share('monolog.logger.database', [$this, 'getMonologLoggerDatabaseService']); 53 | } 54 | 55 | /** 56 | * Get the `monolog.processor.psr3` service 57 | * 58 | * @param Container $container The DI container. 59 | * 60 | * @return PsrLogMessageProcessor 61 | */ 62 | public function getMonologProcessorPsr3Service(Container $container): PsrLogMessageProcessor 63 | { 64 | return new PsrLogMessageProcessor(); 65 | } 66 | 67 | /** 68 | * Get the `monolog.processor.web` service 69 | * 70 | * @param Container $container The DI container. 71 | * 72 | * @return WebProcessor 73 | */ 74 | public function getMonologProcessorWebService(Container $container): WebProcessor 75 | { 76 | return new WebProcessor(); 77 | } 78 | 79 | /** 80 | * Get the `monolog.handler.application` service 81 | * 82 | * @param Container $container The DI container. 83 | * 84 | * @return StreamHandler 85 | */ 86 | public function getMonologHandlerApplicationService(Container $container): StreamHandler 87 | { 88 | /** @var \Joomla\Registry\Registry $config */ 89 | $config = $container->get('config'); 90 | 91 | $level = strtoupper($config->get('log.application', $config->get('log.level', 'error'))); 92 | 93 | return new StreamHandler(APPROOT . '/logs/stats.log', \constant('\\Monolog\\Logger::' . $level)); 94 | } 95 | 96 | /** 97 | * Get the `monolog.handler.database` service 98 | * 99 | * @param Container $container The DI container. 100 | * 101 | * @return StreamHandler 102 | */ 103 | public function getMonologHandlerDatabaseService(Container $container): StreamHandler 104 | { 105 | /** @var \Joomla\Registry\Registry $config */ 106 | $config = $container->get('config'); 107 | 108 | $level = strtoupper($config->get('log.database', $config->get('log.level', 'error'))); 109 | 110 | return new StreamHandler(APPROOT . '/logs/stats.log', \constant('\\Monolog\\Logger::' . $level)); 111 | } 112 | 113 | /** 114 | * Get the `monolog.logger.application` service 115 | * 116 | * @param Container $container The DI container. 117 | * 118 | * @return Logger 119 | */ 120 | public function getMonologLoggerApplicationService(Container $container): Logger 121 | { 122 | return new Logger( 123 | 'Application', 124 | [ 125 | $container->get('monolog.handler.application'), 126 | ], 127 | [ 128 | $container->get('monolog.processor.web'), 129 | ] 130 | ); 131 | } 132 | 133 | /** 134 | * Get the `monolog.logger.cli` service 135 | * 136 | * @param Container $container The DI container. 137 | * 138 | * @return Logger 139 | */ 140 | public function getMonologLoggerCliService(Container $container): Logger 141 | { 142 | return new Logger( 143 | 'Application', 144 | [ 145 | $container->get('monolog.handler.application'), 146 | ] 147 | ); 148 | } 149 | 150 | /** 151 | * Get the `monolog.logger.database` service 152 | * 153 | * @param Container $container The DI container. 154 | * 155 | * @return Logger 156 | */ 157 | public function getMonologLoggerDatabaseService(Container $container): Logger 158 | { 159 | return new Logger( 160 | 'Application', 161 | [ 162 | $container->get('monolog.handler.database'), 163 | ], 164 | [ 165 | $container->get('monolog.processor.psr3'), 166 | $container->get('monolog.processor.web'), 167 | ] 168 | ); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Providers/RepositoryServiceProvider.php: -------------------------------------------------------------------------------- 1 | share(StatisticsRepository::class, [$this, 'getStatisticsRepositoryService']); 34 | $container->share(InfluxdbRepository::class, [$this, 'getInfluxdbRepositoryService']); 35 | } 36 | 37 | /** 38 | * Get the StatisticsRepository service 39 | * 40 | * @param Container $container The DI container. 41 | * 42 | * @return StatisticsRepository 43 | */ 44 | public function getStatisticsRepositoryService(Container $container): StatisticsRepository 45 | { 46 | return new StatisticsRepository( 47 | $container->get(DatabaseInterface::class) 48 | ); 49 | } 50 | 51 | /** 52 | * Get the StatisticsRepository service 53 | * 54 | * @param Container $container The DI container. 55 | * 56 | * @return StatisticsRepository 57 | */ 58 | public function getInfluxdbRepositoryService(Container $container): InfluxdbRepository 59 | { 60 | return new InfluxdbRepository( 61 | $container->get(Client::class) 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Providers/WebApplicationServiceProvider.php: -------------------------------------------------------------------------------- 1 | alias(WebApplication::class, AbstractWebApplication::class) 46 | ->share(AbstractWebApplication::class, [$this, 'getWebApplicationService']); 47 | 48 | /* 49 | * Application Class Dependencies 50 | */ 51 | 52 | $container->share(Input::class, [$this, 'getInputService']); 53 | $container->share(Router::class, [$this, 'getRouterService']); 54 | 55 | $container->alias(ContainerControllerResolver::class, ControllerResolverInterface::class) 56 | ->share(ControllerResolverInterface::class, [$this, 'getControllerResolverService']); 57 | 58 | $container->share(WebClient::class, [$this, 'getWebClientService']); 59 | 60 | /* 61 | * MVC Layer 62 | */ 63 | 64 | // Controllers 65 | $container->share(DisplayStatisticsController::class, [$this, 'getDisplayStatisticsControllerService']); 66 | $container->share(SubmitDataController::class, [$this, 'getSubmitDataControllerService']); 67 | 68 | // Views 69 | $container->share(StatsJsonView::class, [$this, 'getStatsJsonViewService']); 70 | } 71 | 72 | /** 73 | * Get the controller resolver service 74 | * 75 | * @param Container $container The DI container. 76 | * 77 | * @return ControllerResolverInterface 78 | */ 79 | public function getControllerResolverService(Container $container): ControllerResolverInterface 80 | { 81 | return new ContainerControllerResolver($container); 82 | } 83 | 84 | /** 85 | * Get the DisplayControllerGet class service 86 | * 87 | * @param Container $container The DI container. 88 | * 89 | * @return DisplayStatisticsController 90 | */ 91 | public function getDisplayStatisticsControllerService(Container $container): DisplayStatisticsController 92 | { 93 | $controller = new DisplayStatisticsController( 94 | $container->get(StatsJsonView::class) 95 | ); 96 | 97 | $controller->setApplication($container->get(AbstractApplication::class)); 98 | $controller->setInput($container->get(Input::class)); 99 | 100 | return $controller; 101 | } 102 | 103 | /** 104 | * Get the Input class service 105 | * 106 | * @param Container $container The DI container. 107 | * 108 | * @return Input 109 | */ 110 | public function getInputService(Container $container): Input 111 | { 112 | return new Input($_REQUEST); 113 | } 114 | 115 | /** 116 | * Get the router service 117 | * 118 | * @param Container $container The DI container. 119 | * 120 | * @return Router 121 | */ 122 | public function getRouterService(Container $container): Router 123 | { 124 | $router = new Router(); 125 | 126 | $router->get( 127 | '/', 128 | DisplayStatisticsController::class 129 | ); 130 | 131 | $router->post( 132 | '/submit', 133 | SubmitDataController::class 134 | ); 135 | 136 | $router->get( 137 | '/:source', 138 | DisplayStatisticsController::class, 139 | [ 140 | 'source' => '(' . implode('|', StatisticsRepository::ALLOWED_SOURCES) . ')', 141 | ] 142 | ); 143 | 144 | return $router; 145 | } 146 | 147 | /** 148 | * Get the StatsJsonView class service 149 | * 150 | * @param Container $container The DI container. 151 | * 152 | * @return StatsJsonView 153 | */ 154 | public function getStatsJsonViewService(Container $container): StatsJsonView 155 | { 156 | return new StatsJsonView( 157 | $container->get(StatisticsRepository::class) 158 | ); 159 | } 160 | 161 | /** 162 | * Get the SubmitControllerCreate class service 163 | * 164 | * @param Container $container The DI container. 165 | * 166 | * @return SubmitDataController 167 | */ 168 | public function getSubmitDataControllerService(Container $container): SubmitDataController 169 | { 170 | $controller = new SubmitDataController( 171 | $container->get(StatisticsRepository::class), 172 | $container->get('filesystem.versions'), 173 | $container->get(InfluxdbRepository::class), 174 | ); 175 | 176 | $controller->setApplication($container->get(AbstractApplication::class)); 177 | $controller->setInput($container->get(Input::class)); 178 | 179 | return $controller; 180 | } 181 | 182 | /** 183 | * Get the web application service 184 | * 185 | * @param Container $container The DI container. 186 | * 187 | * @return WebApplication 188 | */ 189 | public function getWebApplicationService(Container $container): WebApplication 190 | { 191 | $application = new WebApplication( 192 | $container->get(ControllerResolverInterface::class), 193 | $container->get(Router::class), 194 | $container->get(Input::class), 195 | $container->get('config'), 196 | $container->get(WebClient::class), 197 | new JsonResponse([]) 198 | ); 199 | 200 | // Inject extra services 201 | $application->setDispatcher($container->get(DispatcherInterface::class)); 202 | $application->setLogger($container->get(LoggerInterface::class)); 203 | 204 | return $application; 205 | } 206 | 207 | /** 208 | * Get the web client service 209 | * 210 | * @param Container $container The DI container. 211 | * 212 | * @return WebClient 213 | */ 214 | public function getWebClientService(Container $container): WebClient 215 | { 216 | /** @var Input $input */ 217 | $input = $container->get(Input::class); 218 | $userAgent = $input->server->getString('HTTP_USER_AGENT', ''); 219 | $acceptEncoding = $input->server->getString('HTTP_ACCEPT_ENCODING', ''); 220 | $acceptLanguage = $input->server->getString('HTTP_ACCEPT_LANGUAGE', ''); 221 | 222 | return new WebClient($userAgent, $acceptEncoding, $acceptLanguage); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/Repositories/InfluxdbRepository.php: -------------------------------------------------------------------------------- 1 | db = $db; 39 | } 40 | 41 | /** 42 | * Saves the given data. 43 | * 44 | * @param \stdClass $data Data object to save. 45 | * 46 | * @return void 47 | */ 48 | public function save(\stdClass $data): void 49 | { 50 | $writeApi = $this->db->createWriteApi(); 51 | 52 | // Set the modified date of the record 53 | $timestamp = (new \DateTime('now', new \DateTimeZone('UTC')))->getTimestamp(); 54 | 55 | $point = Point::measurement('joomla') 56 | ->addTag('unique_id', $data->unique_id) 57 | ->time($timestamp); 58 | 59 | // Extract major and minor version 60 | if (!empty($data->php_version)) { 61 | preg_match('/^(\d+)\.(\d+)\./', $data->php_version, $phpVersion); 62 | $point->addField('php_version', $data->php_version); 63 | if (!empty($phpVersion)) { 64 | $point->addField('php_major', $phpVersion[1]) 65 | ->addField('php_minor', $phpVersion[1] . '.' . $phpVersion[2]); 66 | } 67 | } 68 | 69 | // Prepare CMS version 70 | if (!empty($data->cms_version)) { 71 | preg_match('/^(\d+)\.(\d+)\./', $data->cms_version, $cmsVersions); 72 | 73 | $point->addField('cms_version', $data->cms_version); 74 | if (!empty($cmsVersions)) { 75 | $point 76 | ->addField('cms_major', $cmsVersions[1]) 77 | ->addField('cms_minor', $cmsVersions[1] . '.' . $cmsVersions[2]); 78 | } 79 | } 80 | 81 | // Prepare Database versions 82 | if (!empty($data->db_version)) { 83 | preg_match('/^(\d+)\.(\d+)\./', $data->db_version, $dbVersions); 84 | 85 | $point->addField('db_version', $data->db_version); 86 | if (!empty($dbVersions)) { 87 | $point->addField('db_major', $dbVersions[1]) 88 | ->addField('db_minor', $dbVersions[1] . '.' . $dbVersions[2]); 89 | } 90 | } 91 | 92 | // Prepare Database Driver 93 | if (!empty($data->db_type)) { 94 | $dbServer = null; 95 | if ($data->db_type === 'postgresql') { 96 | $dbServer = 'PostgreSQL'; 97 | } elseif (str_contains($data->db_type, 'mysql')) { 98 | $dbServer = 'MySQL'; 99 | if (!empty($data->db_version)) { 100 | if ( 101 | version_compare($data->db_version, '10.0.0', '>=') 102 | // We know this is not 100% correct but more accurate than expecting MySQL with this version string 103 | || version_compare($data->db_version, '5.5.5', '=') 104 | ) { 105 | $dbServer = 'MariaDB'; 106 | } 107 | } 108 | } elseif (str_contains($data->db_type, 'mariadb')) { 109 | $dbServer = 'MariaDB'; 110 | } elseif (str_contains($data->db_type, 'sqlsrv')) { 111 | $dbServer = 'MSSQL'; 112 | } 113 | 114 | $point->addField('db_driver', $data->db_type); 115 | if (!empty($dbServer)) { 116 | $point->addField('db_server', $dbServer); 117 | } 118 | } 119 | 120 | // Prepare Operating System 121 | if (!empty($data->server_os)) { 122 | $os = explode(' ', $data->server_os, 2); 123 | 124 | $point->addField('server_string', $data->server_os); 125 | if (!empty($os[0])) { 126 | $point->addField('server_os', $os[0]); 127 | } 128 | } 129 | 130 | $writeApi->write($point, \InfluxDB2\Model\WritePrecision::S, 'cms'); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Repositories/StatisticsRepository.php: -------------------------------------------------------------------------------- 1 | db = $db; 43 | } 44 | 45 | /** 46 | * Loads the statistics data from the database. 47 | * 48 | * @param string $column A single column to filter on 49 | * 50 | * @return array An array containing the response data 51 | * 52 | * @throws \InvalidArgumentException 53 | */ 54 | public function getItems(string $column = ''): array 55 | { 56 | // Validate the requested column is actually in the table 57 | if ($column !== '') { 58 | // The column should exist in the table and be part of the API 59 | if (!\in_array($column, self::ALLOWED_SOURCES)) { 60 | throw new \InvalidArgumentException('An invalid data source was requested.', 404); 61 | } 62 | 63 | return $this->db->setQuery( 64 | $this->db->getQuery(true) 65 | ->select('*') 66 | ->from($this->db->quoteName('#__jstats_counter_' . $column)) 67 | )->loadAssocList(); 68 | } 69 | 70 | $return = []; 71 | 72 | foreach (self::ALLOWED_SOURCES as $column) { 73 | $return[$column] = $this->db->setQuery( 74 | $this->db->getQuery(true) 75 | ->select('*') 76 | ->from($this->db->quoteName('#__jstats_counter_' . $column)) 77 | )->loadAssocList(); 78 | } 79 | 80 | return $return; 81 | } 82 | 83 | /** 84 | * Loads the recently updated statistics data from the database. 85 | * 86 | * Recently updated is an arbitrary 90 days, submit a pull request for a different behavior. 87 | * 88 | * @return array An array containing the response data 89 | */ 90 | public function getRecentlyUpdatedItems(): array 91 | { 92 | $return = []; 93 | $db = $this->db; 94 | 95 | foreach (self::ALLOWED_SOURCES as $column) { 96 | if (($column !== 'cms_php_version') && ($column !== 'db_type_version')) { 97 | $return[$column] = $this->db->setQuery( 98 | $this->db->getQuery(true) 99 | ->select($column) 100 | ->select('COUNT(' . $column . ') AS count') 101 | ->from($this->db->quoteName('#__jstats')) 102 | ->where('modified BETWEEN DATE_SUB(NOW(), INTERVAL 90 DAY) AND NOW()') 103 | ->group($column) 104 | )->loadAssocList(); 105 | continue; 106 | } 107 | 108 | if ($column === 'cms_php_version') { 109 | $return['cms_php_version'] = $this->db->setQuery( 110 | $this->db->getQuery(true) 111 | ->select('CONCAT(' . $db->qn('cms_version') . ', ' . $db->q(' - ') . ', ' . $db->qn('php_version') . ') AS cms_php_version') 112 | ->select('COUNT(*) AS count') 113 | ->from($this->db->quoteName('#__jstats')) 114 | ->where('modified BETWEEN DATE_SUB(NOW(), INTERVAL 90 DAY) AND NOW()') 115 | ->group('CONCAT(' . $db->qn('cms_version') . ', ' . $db->q(' - ') . ', ' . $db->qn('php_version') . ')') 116 | )->loadAssocList(); 117 | continue; 118 | } 119 | 120 | if ($column === 'db_type_version') { 121 | $return['db_type_version'] = $this->db->setQuery( 122 | $this->db->getQuery(true) 123 | ->select('CONCAT(' . $db->qn('db_type') . ', ' . $db->q(' - ') . ', ' . $db->qn('db_version') . ') AS db_type_version') 124 | ->select('COUNT(*) AS count') 125 | ->from($this->db->quoteName('#__jstats')) 126 | ->where('modified BETWEEN DATE_SUB(NOW(), INTERVAL 90 DAY) AND NOW()') 127 | ->group('CONCAT(' . $db->qn('db_type') . ', ' . $db->q(' - ') . ', ' . $db->qn('db_version') . ')') 128 | )->loadAssocList(); 129 | continue; 130 | } 131 | } 132 | 133 | return $return; 134 | } 135 | 136 | /** 137 | * Loads the recently updated statistics data from the database. 138 | * 139 | * Updated within a timeframe, submit a pull request for a different behavior. 140 | * 141 | * @param int $timeframe The timeframe in days to consider 142 | * @param string $showColumn The column to return 143 | * 144 | * @return array An array containing the response data 145 | */ 146 | public function getTimeframeUpdatedItems(int $timeframe = 0, string $showColumn = ''): array 147 | { 148 | $return = []; 149 | $columns = self::ALLOWED_SOURCES; 150 | 151 | if ($showColumn !== '') { 152 | // The column should exist in the table and be part of the API 153 | if (!\in_array($showColumn, self::ALLOWED_SOURCES)) { 154 | throw new \InvalidArgumentException('An invalid data source was requested.', 404); 155 | } 156 | 157 | $columns = [$showColumn]; 158 | } 159 | 160 | foreach ($columns as $column) { 161 | if (\in_array($column, ['cms_php_version', 'db_type_version'])) { 162 | continue; 163 | } 164 | 165 | $return[$column] = $this->db->setQuery( 166 | $this->db->getQuery(true) 167 | ->select($column) 168 | ->select('COUNT(' . $column . ') AS count') 169 | ->from('(SELECT * FROM ' . $this->db->quoteName('#__jstats') 170 | . ' WHERE modified > DATE_SUB(NOW(), INTERVAL ' . $this->db->quote($timeframe) . ' DAY)) AS tmptable') 171 | ->group($column) 172 | )->loadAssocList(); 173 | } 174 | 175 | if ($showColumn !== '') { 176 | return $return[$showColumn]; 177 | } 178 | 179 | return $return; 180 | } 181 | /** 182 | * Saves the given data. 183 | * 184 | * @param \stdClass $data Data object to save. 185 | * 186 | * @return void 187 | */ 188 | public function save(\stdClass $data): void 189 | { 190 | // Set the modified date of the record 191 | $data->modified = (new \DateTime('now', new \DateTimeZone('UTC')))->format($this->db->getDateFormat()); 192 | 193 | // Check if a row exists for this unique ID and update the existing record if so 194 | $recordExists = $this->db->setQuery( 195 | $this->db->getQuery(true) 196 | ->select('unique_id') 197 | ->from('#__jstats') 198 | ->where('unique_id = :unique_id') 199 | ->bind(':unique_id', $data->unique_id, ParameterType::STRING) 200 | )->loadResult(); 201 | 202 | if ($recordExists) { 203 | $this->db->updateObject('#__jstats', $data, ['unique_id']); 204 | } else { 205 | $this->db->insertObject('#__jstats', $data, ['unique_id']); 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/WebApplication.php: -------------------------------------------------------------------------------- 1 | controllerResolver = $controllerResolver; 68 | $this->router = $router; 69 | 70 | // Call the constructor as late as possible (it runs `initialise`). 71 | parent::__construct($input, $config, $client, $response); 72 | } 73 | 74 | /** 75 | * Method to run the application routines. 76 | * 77 | * @return void 78 | * 79 | * @since __DEPLOY_VERSION__ 80 | */ 81 | protected function doExecute(): void 82 | { 83 | $route = $this->router->parseRoute($this->get('uri.route'), $this->input->getMethod()); 84 | 85 | // Add variables to the input if not already set 86 | foreach ($route->getRouteVariables() as $key => $value) { 87 | $this->input->def($key, $value); 88 | } 89 | 90 | \call_user_func($this->controllerResolver->resolve($route)); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/Commands/Database/MigrateCommandTest.php: -------------------------------------------------------------------------------- 1 | createMock(Migrations::class); 33 | $migrations->expects($this->once()) 34 | ->method('migrateDatabase'); 35 | 36 | $logger = new TestLogger; 37 | 38 | $input = new ArrayInput( 39 | [ 40 | 'command' => 'database:migrate', 41 | ] 42 | ); 43 | $output = new BufferedOutput; 44 | 45 | $application = new Application($input, $output); 46 | 47 | $command = new MigrateCommand($migrations); 48 | $command->setApplication($application); 49 | $command->setLogger($logger); 50 | 51 | $this->assertSame(0, $command->execute($input, $output)); 52 | 53 | $screenOutput = $output->fetch(); 54 | 55 | $this->assertStringContainsString('Database migrated to latest version.', $screenOutput); 56 | } 57 | 58 | /** 59 | * @testdox The command executes the given database migration 60 | */ 61 | public function testTheCommandExecutesTheGivenDatabaseMigration(): void 62 | { 63 | /** @var MockObject|Migrations $migrations */ 64 | $migrations = $this->createMock(Migrations::class); 65 | $migrations->expects($this->once()) 66 | ->method('migrateDatabase') 67 | ->with('abc123'); 68 | 69 | $logger = new TestLogger; 70 | 71 | $input = new ArrayInput( 72 | [ 73 | 'command' => 'database:migrate', 74 | '--mversion' => 'abc123', 75 | ] 76 | ); 77 | $output = new BufferedOutput; 78 | 79 | $application = new Application($input, $output); 80 | 81 | $command = new MigrateCommand($migrations); 82 | $command->setApplication($application); 83 | $command->setLogger($logger); 84 | 85 | $this->assertSame(0, $command->execute($input, $output)); 86 | 87 | $screenOutput = $output->fetch(); 88 | 89 | $this->assertStringContainsString('Database migrated to version "abc123".', $screenOutput); 90 | } 91 | 92 | /** 93 | * @testdox The command handles migration errors 94 | */ 95 | public function testTheCommandHandlesMigrationErrors(): void 96 | { 97 | /** @var MockObject|Migrations $migrations */ 98 | $migrations = $this->createMock(Migrations::class); 99 | $migrations->expects($this->once()) 100 | ->method('migrateDatabase') 101 | ->willThrowException(new FileNotFoundException('abc123.sql')); 102 | 103 | $logger = new TestLogger; 104 | 105 | $input = new ArrayInput( 106 | [ 107 | 'command' => 'database:migrate', 108 | ] 109 | ); 110 | $output = new BufferedOutput; 111 | 112 | $application = new Application($input, $output); 113 | 114 | $command = new MigrateCommand($migrations); 115 | $command->setApplication($application); 116 | $command->setLogger($logger); 117 | 118 | $this->assertSame(1, $command->execute($input, $output)); 119 | 120 | $screenOutput = $output->fetch(); 121 | 122 | $this->assertStringContainsString('Error migrating database: File not found at path: abc123.sql', $screenOutput); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/Commands/Database/MigrationStatusCommandTest.php: -------------------------------------------------------------------------------- 1 | tableExists = false; 32 | 33 | /** @var MockObject|Migrations $migrations */ 34 | $migrations = $this->createMock(Migrations::class); 35 | $migrations->expects($this->once()) 36 | ->method('checkStatus') 37 | ->willReturn($status); 38 | 39 | $input = new ArrayInput( 40 | [ 41 | 'command' => 'database:migrations:status', 42 | ] 43 | ); 44 | $output = new BufferedOutput; 45 | 46 | $application = new Application($input, $output); 47 | 48 | $command = new MigrationStatusCommand($migrations); 49 | $command->setApplication($application); 50 | 51 | $this->assertSame(0, $command->execute($input, $output)); 52 | 53 | $screenOutput = $output->fetch(); 54 | 55 | $this->assertStringContainsString('The migrations table does not exist', $screenOutput); 56 | } 57 | 58 | /** 59 | * @testdox The command shows the status when on the latest version 60 | */ 61 | public function testTheCommandShowsTheStatusWhenOnTheLatestVersion(): void 62 | { 63 | $status = new MigrationsStatus; 64 | $status->latest = true; 65 | 66 | /** @var MockObject|Migrations $migrations */ 67 | $migrations = $this->createMock(Migrations::class); 68 | $migrations->expects($this->once()) 69 | ->method('checkStatus') 70 | ->willReturn($status); 71 | 72 | $input = new ArrayInput( 73 | [ 74 | 'command' => 'database:migrations:status', 75 | ] 76 | ); 77 | $output = new BufferedOutput; 78 | 79 | $application = new Application($input, $output); 80 | 81 | $command = new MigrationStatusCommand($migrations); 82 | $command->setApplication($application); 83 | 84 | $this->assertSame(0, $command->execute($input, $output)); 85 | 86 | $screenOutput = $output->fetch(); 87 | 88 | $this->assertStringContainsString('Your database is up-to-date.', $screenOutput); 89 | } 90 | 91 | /** 92 | * @testdox The command shows the status when not on the latest version 93 | */ 94 | public function testTheCommandShowsTheStatusWhenNotOnTheLatestVersion(): void 95 | { 96 | $status = new MigrationsStatus; 97 | $status->currentVersion = '1'; 98 | $status->latest = false; 99 | $status->latestVersion = '2'; 100 | $status->missingMigrations = 1; 101 | 102 | /** @var MockObject|Migrations $migrations */ 103 | $migrations = $this->createMock(Migrations::class); 104 | $migrations->expects($this->once()) 105 | ->method('checkStatus') 106 | ->willReturn($status); 107 | 108 | $input = new ArrayInput( 109 | [ 110 | 'command' => 'database:migrations:status', 111 | ] 112 | ); 113 | $output = new BufferedOutput; 114 | 115 | $application = new Application($input, $output); 116 | 117 | $command = new MigrationStatusCommand($migrations); 118 | $command->setApplication($application); 119 | 120 | $this->assertSame(0, $command->execute($input, $output)); 121 | 122 | $screenOutput = $output->fetch(); 123 | 124 | $this->assertStringContainsString('Your database is not up-to-date. You are missing 1 migration(s).', $screenOutput); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/Commands/SnapshotCommandTest.php: -------------------------------------------------------------------------------- 1 | [ 32 | [ 33 | 'cms_version' => '3.5.0', 34 | 'count' => 3, 35 | ], 36 | ], 37 | 'php_version' => [ 38 | [ 39 | 'php_version' => PHP_VERSION, 40 | 'count' => 3, 41 | ], 42 | ], 43 | 'db_type' => [ 44 | [ 45 | 'db_type' => 'mysql', 46 | 'count' => 1, 47 | ], 48 | [ 49 | 'db_type' => 'postgresql', 50 | 'count' => 1, 51 | ], 52 | [ 53 | 'db_type' => 'sqlsrv', 54 | 'count' => 1, 55 | ], 56 | ], 57 | 'db_version' => [ 58 | [ 59 | 'db_version' => '5.6.25', 60 | 'count' => 1, 61 | ], 62 | [ 63 | 'db_version' => '9.4.0', 64 | 'count' => 1, 65 | ], 66 | [ 67 | 'db_version' => '10.50.2500', 68 | 'count' => 1, 69 | ], 70 | ], 71 | 'server_os' => [ 72 | [ 73 | 'server_os' => 'Darwin 14.1.0', 74 | 'count' => 2, 75 | ], 76 | [ 77 | 'server_os' => '', 78 | 'count' => 1, 79 | ], 80 | ], 81 | ]; 82 | 83 | /** 84 | * @testdox The command creates a full snapshot 85 | */ 86 | public function testTheCommandCreatesAFullSnapshot(): void 87 | { 88 | /** @var MockObject|StatsJsonView $view */ 89 | $view = $this->createMock(StatsJsonView::class); 90 | $view->expects($this->once()) 91 | ->method('isAuthorizedRaw') 92 | ->with(true); 93 | 94 | $view->expects($this->once()) 95 | ->method('render') 96 | ->willReturn(json_encode(self::STATS_DATA)); 97 | 98 | $adapter = new MemoryAdapter; 99 | 100 | $filesystem = new Filesystem($adapter); 101 | 102 | $input = new ArrayInput( 103 | [ 104 | 'command' => 'snapshot', 105 | ] 106 | ); 107 | $output = new BufferedOutput; 108 | 109 | $application = new Application($input, $output); 110 | 111 | $command = new SnapshotCommand($view, $filesystem); 112 | $command->setApplication($application); 113 | 114 | $this->assertSame(0, $command->execute($input, $output)); 115 | 116 | $screenOutput = $output->fetch(); 117 | 118 | $this->assertStringContainsString('Snapshot recorded.', $screenOutput); 119 | $this->assertCount(1, $filesystem->listContents()); 120 | } 121 | 122 | /** 123 | * @testdox The command creates a full snapshot for a single source 124 | */ 125 | public function testTheCommandCreatesAFullSnapshotForASingleSource(): void 126 | { 127 | $source = 'db_type'; 128 | 129 | /** @var MockObject|StatsJsonView $view */ 130 | $view = $this->createMock(StatsJsonView::class); 131 | $view->expects($this->once()) 132 | ->method('isAuthorizedRaw') 133 | ->with(true); 134 | 135 | $view->expects($this->once()) 136 | ->method('setSource') 137 | ->with($source); 138 | 139 | $view->expects($this->once()) 140 | ->method('render') 141 | ->willReturn(json_encode(self::STATS_DATA[$source])); 142 | 143 | $adapter = new MemoryAdapter; 144 | 145 | $filesystem = new Filesystem($adapter); 146 | 147 | $input = new ArrayInput( 148 | [ 149 | 'command' => 'snapshot', 150 | '--source' => $source, 151 | ] 152 | ); 153 | $output = new BufferedOutput; 154 | 155 | $application = new Application($input, $output); 156 | 157 | $command = new SnapshotCommand($view, $filesystem); 158 | $command->setApplication($application); 159 | 160 | $this->assertSame(0, $command->execute($input, $output)); 161 | 162 | $screenOutput = $output->fetch(); 163 | 164 | $this->assertStringContainsString('Snapshot recorded.', $screenOutput); 165 | $this->assertCount(1, $filesystem->listContents()); 166 | } 167 | 168 | /** 169 | * @testdox The command does not create a snapshot for an invalid source 170 | */ 171 | public function testTheCommandDoesNotCreateASnapshotForAnInvalidSource(): void 172 | { 173 | $this->expectException(InvalidOptionException::class); 174 | 175 | $source = 'bad'; 176 | 177 | /** @var MockObject|StatsJsonView $view */ 178 | $view = $this->createMock(StatsJsonView::class); 179 | $view->expects($this->once()) 180 | ->method('isAuthorizedRaw') 181 | ->with(true); 182 | 183 | $view->expects($this->never()) 184 | ->method('setSource'); 185 | 186 | $view->expects($this->never()) 187 | ->method('render'); 188 | 189 | $adapter = new MemoryAdapter; 190 | 191 | $filesystem = new Filesystem($adapter); 192 | 193 | $input = new ArrayInput( 194 | [ 195 | 'command' => 'snapshot', 196 | '--source' => $source, 197 | ] 198 | ); 199 | $output = new BufferedOutput; 200 | 201 | $application = new Application($input, $output); 202 | 203 | $command = new SnapshotCommand($view, $filesystem); 204 | $command->setApplication($application); 205 | 206 | $command->execute($input, $output); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /tests/Commands/SnapshotRecentlyUpdatedCommandTest.php: -------------------------------------------------------------------------------- 1 | [ 32 | [ 33 | 'cms_version' => '3.5.0', 34 | 'count' => 3, 35 | ], 36 | ], 37 | 'php_version' => [ 38 | [ 39 | 'php_version' => PHP_VERSION, 40 | 'count' => 3, 41 | ], 42 | ], 43 | 'db_type' => [ 44 | [ 45 | 'db_type' => 'mysql', 46 | 'count' => 1, 47 | ], 48 | [ 49 | 'db_type' => 'postgresql', 50 | 'count' => 1, 51 | ], 52 | [ 53 | 'db_type' => 'sqlsrv', 54 | 'count' => 1, 55 | ], 56 | ], 57 | 'db_version' => [ 58 | [ 59 | 'db_version' => '5.6.25', 60 | 'count' => 1, 61 | ], 62 | [ 63 | 'db_version' => '9.4.0', 64 | 'count' => 1, 65 | ], 66 | [ 67 | 'db_version' => '10.50.2500', 68 | 'count' => 1, 69 | ], 70 | ], 71 | 'server_os' => [ 72 | [ 73 | 'server_os' => 'Darwin 14.1.0', 74 | 'count' => 2, 75 | ], 76 | [ 77 | 'server_os' => '', 78 | 'count' => 1, 79 | ], 80 | ], 81 | ]; 82 | 83 | /** 84 | * @testdox The command creates a full snapshot 85 | */ 86 | public function testTheCommandCreatesAFullSnapshot(): void 87 | { 88 | /** @var MockObject|StatsJsonView $view */ 89 | $view = $this->createMock(StatsJsonView::class); 90 | $view->expects($this->once()) 91 | ->method('isAuthorizedRaw') 92 | ->with(true); 93 | 94 | $view->expects($this->once()) 95 | ->method('isRecent') 96 | ->with(true); 97 | 98 | $view->expects($this->once()) 99 | ->method('render') 100 | ->willReturn(json_encode(self::STATS_DATA)); 101 | 102 | $adapter = new MemoryAdapter; 103 | 104 | $filesystem = new Filesystem($adapter); 105 | 106 | $input = new ArrayInput( 107 | [ 108 | 'command' => 'snapshot:recently-updated', 109 | ] 110 | ); 111 | $output = new BufferedOutput; 112 | 113 | $application = new Application($input, $output); 114 | 115 | $command = new SnapshotRecentlyUpdatedCommand($view, $filesystem); 116 | $command->setApplication($application); 117 | 118 | $this->assertSame(0, $command->execute($input, $output)); 119 | 120 | $screenOutput = $output->fetch(); 121 | 122 | $this->assertStringContainsString('Snapshot recorded.', $screenOutput); 123 | $this->assertCount(1, $filesystem->listContents()); 124 | } 125 | 126 | /** 127 | * @testdox The command creates a full snapshot for a single source 128 | */ 129 | public function testTheCommandCreatesAFullSnapshotForASingleSource(): void 130 | { 131 | $source = 'db_type'; 132 | 133 | /** @var MockObject|StatsJsonView $view */ 134 | $view = $this->createMock(StatsJsonView::class); 135 | $view->expects($this->once()) 136 | ->method('isAuthorizedRaw') 137 | ->with(true); 138 | 139 | $view->expects($this->once()) 140 | ->method('isRecent') 141 | ->with(true); 142 | 143 | $view->expects($this->once()) 144 | ->method('setSource') 145 | ->with($source); 146 | 147 | $view->expects($this->once()) 148 | ->method('render') 149 | ->willReturn(json_encode(self::STATS_DATA[$source])); 150 | 151 | $adapter = new MemoryAdapter; 152 | 153 | $filesystem = new Filesystem($adapter); 154 | 155 | $input = new ArrayInput( 156 | [ 157 | 'command' => 'snapshot:recently-updated', 158 | '--source' => $source, 159 | ] 160 | ); 161 | $output = new BufferedOutput; 162 | 163 | $application = new Application($input, $output); 164 | 165 | $command = new SnapshotRecentlyUpdatedCommand($view, $filesystem); 166 | $command->setApplication($application); 167 | 168 | $this->assertSame(0, $command->execute($input, $output)); 169 | 170 | $screenOutput = $output->fetch(); 171 | 172 | $this->assertStringContainsString('Snapshot recorded.', $screenOutput); 173 | $this->assertCount(1, $filesystem->listContents()); 174 | } 175 | 176 | /** 177 | * @testdox The command does not create a snapshot for an invalid source 178 | */ 179 | public function testTheCommandDoesNotCreateASnapshotForAnInvalidSource(): void 180 | { 181 | $this->expectException(InvalidOptionException::class); 182 | 183 | $source = 'bad'; 184 | 185 | /** @var MockObject|StatsJsonView $view */ 186 | $view = $this->createMock(StatsJsonView::class); 187 | $view->expects($this->once()) 188 | ->method('isAuthorizedRaw') 189 | ->with(true); 190 | 191 | $view->expects($this->once()) 192 | ->method('isRecent') 193 | ->with(true); 194 | 195 | $view->expects($this->never()) 196 | ->method('setSource'); 197 | 198 | $view->expects($this->never()) 199 | ->method('render'); 200 | 201 | $adapter = new MemoryAdapter; 202 | 203 | $filesystem = new Filesystem($adapter); 204 | 205 | $input = new ArrayInput( 206 | [ 207 | 'command' => 'snapshot:recently-updated', 208 | '--source' => $source, 209 | ] 210 | ); 211 | $output = new BufferedOutput; 212 | 213 | $application = new Application($input, $output); 214 | 215 | $command = new SnapshotRecentlyUpdatedCommand($view, $filesystem); 216 | $command->setApplication($application); 217 | 218 | $command->execute($input, $output); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /tests/Commands/Tags/FetchJoomlaTagsCommandTest.php: -------------------------------------------------------------------------------- 1 | '3.9.0', 46 | ], 47 | (object) [ 48 | 'name' => '3.9.1', 49 | ], 50 | (object) [ 51 | 'name' => '3.9.2', 52 | ], 53 | (object) [ 54 | 'name' => '3.9.3', 55 | ], 56 | (object) [ 57 | 'name' => '3.9.4', 58 | ], 59 | ]; 60 | } 61 | }; 62 | 63 | $github = new class($githubRepositories) extends GitHub 64 | { 65 | private $mockedRepositories; 66 | 67 | public function __construct(Repositories $repositories) 68 | { 69 | parent::__construct(); 70 | 71 | $this->mockedRepositories = $repositories; 72 | } 73 | 74 | public function __get($name) 75 | { 76 | if ($name === 'repositories') 77 | { 78 | return $this->mockedRepositories; 79 | } 80 | 81 | return parent::__get($name); 82 | } 83 | }; 84 | 85 | $adapter = new MemoryAdapter; 86 | 87 | $filesystem = new Filesystem($adapter); 88 | 89 | $input = new ArrayInput( 90 | [ 91 | 'command' => 'tags:joomla', 92 | ] 93 | ); 94 | $output = new BufferedOutput; 95 | 96 | $application = new Application($input, $output); 97 | 98 | $command = new FetchJoomlaTagsCommand($github, $filesystem); 99 | $command->setApplication($application); 100 | 101 | $this->assertSame(0, $command->execute($input, $output)); 102 | 103 | $screenOutput = $output->fetch(); 104 | 105 | $this->assertStringContainsString('Joomla! versions updated.', $screenOutput); 106 | 107 | $versions = json_decode($filesystem->read('joomla.json'), true); 108 | 109 | $this->assertContains('3.9.5', $versions, 'The command should add the next patch release to the allowed version list'); 110 | $this->assertContains('3.10.0', $versions, 'The command should add the next minor release to the allowed version list'); 111 | } 112 | 113 | /** 114 | * @testdox The command fetches the release tags from the Joomla! repository with a paginated response 115 | */ 116 | public function testTheCommandFetchesTheReleaseTagsFromTheJoomlaRepositoryWithAPaginatedResponse(): void 117 | { 118 | $response = new Response; 119 | 120 | $githubRepositories = new class extends Repositories 121 | { 122 | private $execution = 0; 123 | 124 | public function getApiResponse() 125 | { 126 | switch ($this->execution) 127 | { 128 | case 1: 129 | $response = new Response; 130 | $response = $response->withHeader('Link', '; rel="next", ; rel="last"'); 131 | 132 | return $response; 133 | 134 | default: 135 | return new Response; 136 | } 137 | } 138 | 139 | public function getTags($owner, $repo, $page = 0) 140 | { 141 | $this->execution++; 142 | 143 | switch ($this->execution) 144 | { 145 | case 1: 146 | return [ 147 | (object) [ 148 | 'name' => '3.7.0', 149 | ], 150 | (object) [ 151 | 'name' => '3.7.1', 152 | ], 153 | (object) [ 154 | 'name' => '3.7.2', 155 | ], 156 | (object) [ 157 | 'name' => '3.7.3', 158 | ], 159 | (object) [ 160 | 'name' => '3.7.4', 161 | ], 162 | (object) [ 163 | 'name' => '3.7.5', 164 | ], 165 | ]; 166 | 167 | case 2: 168 | return [ 169 | (object) [ 170 | 'name' => '3.8.0', 171 | ], 172 | (object) [ 173 | 'name' => '3.8.1', 174 | ], 175 | (object) [ 176 | 'name' => '3.8.2', 177 | ], 178 | (object) [ 179 | 'name' => '3.8.3', 180 | ], 181 | (object) [ 182 | 'name' => '3.8.4', 183 | ], 184 | (object) [ 185 | 'name' => '3.8.5', 186 | ], 187 | (object) [ 188 | 'name' => '3.8.6', 189 | ], 190 | (object) [ 191 | 'name' => '3.8.7', 192 | ], 193 | (object) [ 194 | 'name' => '3.8.8', 195 | ], 196 | (object) [ 197 | 'name' => '3.8.9', 198 | ], 199 | (object) [ 200 | 'name' => '3.8.10', 201 | ], 202 | (object) [ 203 | 'name' => '3.8.11', 204 | ], 205 | (object) [ 206 | 'name' => '3.8.12', 207 | ], 208 | (object) [ 209 | 'name' => '3.8.13', 210 | ], 211 | ]; 212 | 213 | case 3: 214 | return [ 215 | (object) [ 216 | 'name' => '3.9.0', 217 | ], 218 | (object) [ 219 | 'name' => '3.9.1', 220 | ], 221 | (object) [ 222 | 'name' => '3.9.2', 223 | ], 224 | (object) [ 225 | 'name' => '3.9.3', 226 | ], 227 | (object) [ 228 | 'name' => '3.9.4', 229 | ], 230 | ]; 231 | } 232 | } 233 | }; 234 | 235 | $github = new class($githubRepositories) extends GitHub 236 | { 237 | private $mockedRepositories; 238 | 239 | public function __construct(Repositories $repositories) 240 | { 241 | parent::__construct(); 242 | 243 | $this->mockedRepositories = $repositories; 244 | } 245 | 246 | public function __get($name) 247 | { 248 | if ($name === 'repositories') 249 | { 250 | return $this->mockedRepositories; 251 | } 252 | 253 | return parent::__get($name); 254 | } 255 | }; 256 | 257 | $adapter = new MemoryAdapter; 258 | 259 | $filesystem = new Filesystem($adapter); 260 | 261 | $input = new ArrayInput( 262 | [ 263 | 'command' => 'tags:joomla', 264 | ] 265 | ); 266 | $output = new BufferedOutput; 267 | 268 | $application = new Application($input, $output); 269 | 270 | $command = new FetchJoomlaTagsCommand($github, $filesystem); 271 | $command->setApplication($application); 272 | 273 | $this->assertSame(0, $command->execute($input, $output)); 274 | 275 | $screenOutput = $output->fetch(); 276 | 277 | $this->assertStringContainsString('Fetching page 2 of 3 pages of tags.', $screenOutput); 278 | $this->assertStringContainsString('Joomla! versions updated.', $screenOutput); 279 | 280 | $versions = json_decode($filesystem->read('joomla.json'), true); 281 | 282 | $this->assertContains('3.9.5', $versions, 'The command should add the next patch release to the allowed version list'); 283 | $this->assertContains('3.10.0', $versions, 'The command should add the next minor release to the allowed version list'); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /tests/Commands/Tags/FetchPhpTagsCommandTest.php: -------------------------------------------------------------------------------- 1 | 'php-7.2.0', 46 | ], 47 | (object) [ 48 | 'name' => 'php-7.2.1', 49 | ], 50 | (object) [ 51 | 'name' => 'php-7.2.2', 52 | ], 53 | (object) [ 54 | 'name' => 'php-7.2.3', 55 | ], 56 | (object) [ 57 | 'name' => 'php-7.2.4', 58 | ], 59 | (object) [ 60 | 'name' => 'php-7.3.0', 61 | ], 62 | (object) [ 63 | 'name' => 'php-7.3.1', 64 | ], 65 | (object) [ 66 | 'name' => 'php-7.3.2', 67 | ], 68 | (object) [ 69 | 'name' => 'php-7.3.3', 70 | ], 71 | (object) [ 72 | 'name' => 'php-7.3.4', 73 | ], 74 | (object) [ 75 | 'name' => 'php-7.4.0', 76 | ], 77 | (object) [ 78 | 'name' => 'php-7.4.1', 79 | ], 80 | (object) [ 81 | 'name' => 'php-7.4.2', 82 | ], 83 | (object) [ 84 | 'name' => 'php-7.4.3', 85 | ], 86 | (object) [ 87 | 'name' => 'php-7.4.4', 88 | ], 89 | ]; 90 | } 91 | }; 92 | 93 | $github = new class($githubRepositories) extends GitHub 94 | { 95 | private $mockedRepositories; 96 | 97 | public function __construct(Repositories $repositories) 98 | { 99 | parent::__construct(); 100 | 101 | $this->mockedRepositories = $repositories; 102 | } 103 | 104 | public function __get($name) 105 | { 106 | if ($name === 'repositories') 107 | { 108 | return $this->mockedRepositories; 109 | } 110 | 111 | return parent::__get($name); 112 | } 113 | }; 114 | 115 | $adapter = new MemoryAdapter; 116 | 117 | $filesystem = new Filesystem($adapter); 118 | 119 | $input = new ArrayInput( 120 | [ 121 | 'command' => 'tags:php', 122 | ] 123 | ); 124 | $output = new BufferedOutput; 125 | 126 | $application = new Application($input, $output); 127 | 128 | $command = new FetchPhpTagsCommand($github, $filesystem); 129 | $command->setApplication($application); 130 | 131 | $this->assertSame(0, $command->execute($input, $output)); 132 | 133 | $screenOutput = $output->fetch(); 134 | 135 | $this->assertStringContainsString('PHP versions updated.', $screenOutput); 136 | 137 | $versions = json_decode($filesystem->read('php.json'), true); 138 | 139 | $this->assertContains('7.2.5', $versions, 'The command should add the next patch release for the 7.2 branch to the allowed version list'); 140 | $this->assertContains('7.3.5', $versions, 'The command should add the next patch release for the 7.3 branch to the allowed version list'); 141 | $this->assertContains('7.4.5', $versions, 'The command should add the next patch release for the 7.4 branch to the allowed version list'); 142 | $this->assertContains('8.0.0', $versions, 'The command should add the next major release to the allowed version list'); 143 | } 144 | 145 | /** 146 | * @testdox The command fetches the release tags from the PHP repository with a paginated response 147 | */ 148 | public function testTheCommandFetchesTheReleaseTagsFromThePhpRepositoryWithAPaginatedResponse(): void 149 | { 150 | $response = new Response; 151 | 152 | $githubRepositories = new class extends Repositories 153 | { 154 | private $execution = 0; 155 | 156 | public function getApiResponse() 157 | { 158 | switch ($this->execution) 159 | { 160 | case 1: 161 | $response = new Response; 162 | $response = $response->withHeader('Link', '; rel="next", ; rel="last"'); 163 | 164 | return $response; 165 | 166 | default: 167 | return new Response; 168 | } 169 | } 170 | 171 | public function getTags($owner, $repo, $page = 0) 172 | { 173 | $this->execution++; 174 | 175 | switch ($this->execution) 176 | { 177 | case 1: 178 | return [ 179 | (object) [ 180 | 'name' => 'php-7.2.0', 181 | ], 182 | (object) [ 183 | 'name' => 'php-7.2.1', 184 | ], 185 | (object) [ 186 | 'name' => 'php-7.2.2', 187 | ], 188 | (object) [ 189 | 'name' => 'php-7.2.3', 190 | ], 191 | (object) [ 192 | 'name' => 'php-7.2.4', 193 | ], 194 | ]; 195 | 196 | case 2: 197 | return [ 198 | (object) [ 199 | 'name' => 'php-7.3.0', 200 | ], 201 | (object) [ 202 | 'name' => 'php-7.3.1', 203 | ], 204 | (object) [ 205 | 'name' => 'php-7.3.2', 206 | ], 207 | (object) [ 208 | 'name' => 'php-7.3.3', 209 | ], 210 | (object) [ 211 | 'name' => 'php-7.3.4', 212 | ], 213 | ]; 214 | 215 | case 3: 216 | return [ 217 | (object) [ 218 | 'name' => 'php-7.4.0', 219 | ], 220 | (object) [ 221 | 'name' => 'php-7.4.1', 222 | ], 223 | (object) [ 224 | 'name' => 'php-7.4.2', 225 | ], 226 | (object) [ 227 | 'name' => 'php-7.4.3', 228 | ], 229 | (object) [ 230 | 'name' => 'php-7.4.4', 231 | ], 232 | ]; 233 | } 234 | } 235 | }; 236 | 237 | $github = new class($githubRepositories) extends GitHub 238 | { 239 | private $mockedRepositories; 240 | 241 | public function __construct(Repositories $repositories) 242 | { 243 | parent::__construct(); 244 | 245 | $this->mockedRepositories = $repositories; 246 | } 247 | 248 | public function __get($name) 249 | { 250 | if ($name === 'repositories') 251 | { 252 | return $this->mockedRepositories; 253 | } 254 | 255 | return parent::__get($name); 256 | } 257 | }; 258 | 259 | $adapter = new MemoryAdapter; 260 | 261 | $filesystem = new Filesystem($adapter); 262 | 263 | $input = new ArrayInput( 264 | [ 265 | 'command' => 'tags:php', 266 | ] 267 | ); 268 | $output = new BufferedOutput; 269 | 270 | $application = new Application($input, $output); 271 | 272 | $command = new FetchPhpTagsCommand($github, $filesystem); 273 | $command->setApplication($application); 274 | 275 | $this->assertSame(0, $command->execute($input, $output)); 276 | 277 | $screenOutput = $output->fetch(); 278 | 279 | $this->assertStringContainsString('Fetching page 2 of 3 pages of tags.', $screenOutput); 280 | $this->assertStringContainsString('PHP versions updated.', $screenOutput); 281 | 282 | $versions = json_decode($filesystem->read('php.json'), true); 283 | 284 | $this->assertContains('7.2.5', $versions, 'The command should add the next patch release for the 7.2 branch to the allowed version list'); 285 | $this->assertContains('7.3.5', $versions, 'The command should add the next patch release for the 7.3 branch to the allowed version list'); 286 | $this->assertContains('7.4.5', $versions, 'The command should add the next patch release for the 7.4 branch to the allowed version list'); 287 | $this->assertContains('8.0.0', $versions, 'The command should add the next major release to the allowed version list'); 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /tests/Controllers/DisplayStatisticsControllerTest.php: -------------------------------------------------------------------------------- 1 | runMigrations(); 43 | } 44 | 45 | /** 46 | * This method is called before each test. 47 | * 48 | * @return void 49 | */ 50 | protected function setUp(): void 51 | { 52 | parent::setUp(); 53 | 54 | static::$dbManager->loadExampleData(); 55 | 56 | $this->kernel = new class(static::$connection) extends WebKernel 57 | { 58 | private $database; 59 | 60 | public function __construct(DatabaseInterface $database) 61 | { 62 | $this->database = $database; 63 | } 64 | 65 | protected function buildContainer(): Container 66 | { 67 | $container = parent::buildContainer(); 68 | 69 | // Overload the database service with the test database 70 | $container->share(DatabaseDriver::class, $this->database); 71 | 72 | return $container; 73 | } 74 | 75 | public function getContainer() 76 | { 77 | return parent::getContainer(); 78 | } 79 | }; 80 | } 81 | 82 | /** 83 | * Tears down the fixture, for example, close a network connection. 84 | * This method is called after a test is executed. 85 | * 86 | * @return void 87 | */ 88 | protected function tearDown(): void 89 | { 90 | static::$dbManager->clearTables(); 91 | 92 | parent::tearDown(); 93 | } 94 | 95 | /** 96 | * @testdox Sanitized statistics are returned 97 | */ 98 | public function testSanitizedStatisticsAreReturned(): void 99 | { 100 | $this->kernel->boot(); 101 | 102 | /** @var DisplayStatisticsController $controller */ 103 | $controller = $this->kernel->getContainer()->get(DisplayStatisticsController::class); 104 | 105 | $this->assertTrue($controller->execute()); 106 | 107 | /** @var WebApplication $application */ 108 | $application = $this->kernel->getContainer()->get(AbstractApplication::class); 109 | 110 | /** @var JsonResponse $response */ 111 | $response = $application->getResponse(); 112 | 113 | $this->assertSame(200, $response->getStatusCode()); 114 | 115 | $responseBody = json_decode($application->getBody(), true); 116 | 117 | $this->assertArrayHasKey('5.5', $responseBody['data']['php_version'], 'The response should contain the PHP version data grouped by branch.'); 118 | $this->assertArrayNotHasKey('5.5.38', $responseBody['data']['php_version'], 'The response should contain the PHP version data grouped by branch.'); 119 | } 120 | 121 | /** 122 | * @testdox Unsanitized statistics are returned 123 | */ 124 | public function testUnsanitizedStatisticsAreReturned(): void 125 | { 126 | $this->kernel->boot(); 127 | 128 | /** @var WebApplication $application */ 129 | $application = $this->kernel->getContainer()->get(AbstractApplication::class); 130 | 131 | // Fake the request data and params 132 | $application->set('stats.rawdata', 'testing'); 133 | $application->input->server->set('HTTP_JOOMLA_RAW', 'testing'); 134 | 135 | /** @var DisplayStatisticsController $controller */ 136 | $controller = $this->kernel->getContainer()->get(DisplayStatisticsController::class); 137 | 138 | $this->assertTrue($controller->execute()); 139 | 140 | /** @var JsonResponse $response */ 141 | $response = $application->getResponse(); 142 | 143 | $this->assertSame(200, $response->getStatusCode()); 144 | 145 | $responseBody = json_decode($application->getBody(), true); 146 | 147 | foreach ($responseBody['data']['php_version'] as $info) 148 | { 149 | if ($info['name'] === '5.5') 150 | { 151 | $this->fail('The response should contain the raw PHP version data.'); 152 | } 153 | } 154 | } 155 | 156 | /** 157 | * @testdox Statistics for a single source are returned 158 | */ 159 | public function testStatisticsForASingleSourceAreReturned(): void 160 | { 161 | $this->kernel->boot(); 162 | 163 | /** @var WebApplication $application */ 164 | $application = $this->kernel->getContainer()->get(AbstractApplication::class); 165 | 166 | // Fake the request data 167 | $application->input->set('source', 'php_version'); 168 | 169 | /** @var DisplayStatisticsController $controller */ 170 | $controller = $this->kernel->getContainer()->get(DisplayStatisticsController::class); 171 | 172 | $this->assertTrue($controller->execute()); 173 | 174 | /** @var JsonResponse $response */ 175 | $response = $application->getResponse(); 176 | 177 | $this->assertSame(200, $response->getStatusCode()); 178 | 179 | $responseBody = json_decode($application->getBody(), true); 180 | 181 | $this->assertArrayHasKey('php_version', $responseBody['data'], 'The response should only contain the PHP version data.'); 182 | $this->assertArrayNotHasKey('cms_version', $responseBody['data'], 'The response should only contain the PHP version data.'); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /tests/Database/MigrationsTest.php: -------------------------------------------------------------------------------- 1 | migrations = new Migrations( 39 | static::$connection, 40 | new Filesystem(new Local(APPROOT . '/etc/migrations')) 41 | ); 42 | } 43 | 44 | /** 45 | * @testdox The migration status is checked without the table created 46 | */ 47 | public function testTheMigrationStatusIsCheckedWithoutTheTableCreated(): void 48 | { 49 | $status = $this->migrations->checkStatus(); 50 | 51 | $this->assertFalse($status->tableExists); 52 | } 53 | 54 | /** 55 | * @testdox The migration status is checked after executing the first migration 56 | */ 57 | public function testTheMigrationStatusIsCheckedAfterExecutingTheFirstMigration(): void 58 | { 59 | $this->migrations->migrateDatabase('20160618001'); 60 | 61 | $status = $this->migrations->checkStatus(); 62 | 63 | $this->assertTrue($status->tableExists); 64 | $this->assertSame('20160618001', $status->currentVersion); 65 | $this->assertGreaterThanOrEqual(1, $status->missingMigrations); 66 | } 67 | 68 | /** 69 | * @testdox The migration status is checked after executing all migrations 70 | */ 71 | public function testTheMigrationStatusIsCheckedAfterExecutingAllMigrations(): void 72 | { 73 | $this->migrations->migrateDatabase(); 74 | 75 | $status = $this->migrations->checkStatus(); 76 | 77 | $this->assertTrue($status->tableExists); 78 | $this->assertTrue($status->latest); 79 | $this->assertSame(0, $status->missingMigrations); 80 | } 81 | 82 | /** 83 | * @testdox Migrations fail with an unknown migration 84 | */ 85 | public function testMigrationsFailWithAnUnknownMigration(): void 86 | { 87 | $this->migrations->migrateDatabase(); 88 | 89 | $this->expectException(UnknownMigrationException::class); 90 | 91 | $this->migrations->migrateDatabase('will-never-exist'); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/DatabaseManager.php: -------------------------------------------------------------------------------- 1 | getConnection(); 30 | 31 | $modifiedTimestamp = (new \DateTime('now', new \DateTimeZone('UTC')))->format($db->getDateFormat()); 32 | 33 | $data = [ 34 | [ 35 | 'php_version' => '5.5.38', 36 | 'db_type' => 'mysqli', 37 | 'db_version' => '5.6.41', 38 | 'cms_version' => '3.9.4', 39 | 'server_os' => 'Darwin 14.1.0', 40 | 'unique_id' => 'a1b2c3d4', 41 | 'modified' => $modifiedTimestamp, 42 | ], 43 | [ 44 | 'php_version' => '5.6.39', 45 | 'db_type' => 'mysql', 46 | 'db_version' => '5.7.26', 47 | 'cms_version' => '3.9.2', 48 | 'server_os' => 'Windows NT 10.0', 49 | 'unique_id' => 'a2b3c4d5', 50 | 'modified' => $modifiedTimestamp, 51 | ], 52 | [ 53 | 'php_version' => '7.0.33', 54 | 'db_type' => 'pdomysql', 55 | 'db_version' => '8.0.14', 56 | 'cms_version' => '3.8.13', 57 | 'server_os' => 'Linux 4.14.68', 58 | 'unique_id' => 'a3b4c5d6', 59 | 'modified' => $modifiedTimestamp, 60 | ], 61 | [ 62 | 'php_version' => '7.1.27', 63 | 'db_type' => 'pgsql', 64 | 'db_version' => '9.6.12', 65 | 'cms_version' => '3.7.5', 66 | 'server_os' => 'FreeBSD 12.0-STABLE', 67 | 'unique_id' => 'a4b5c6d7', 68 | 'modified' => $modifiedTimestamp, 69 | ], 70 | [ 71 | 'php_version' => '7.2.16', 72 | 'db_type' => 'postgresql', 73 | 'db_version' => '9.2.24', 74 | 'cms_version' => '3.6.5', 75 | 'server_os' => 'OpenBSD 6.4', 76 | 'unique_id' => 'a5b6c7d8', 77 | 'modified' => $modifiedTimestamp, 78 | ], 79 | ]; 80 | 81 | // Seed the main table first 82 | foreach ($data as $row) 83 | { 84 | $rowAsObject = (object) $row; 85 | 86 | $db->insertObject('#__jstats', $rowAsObject, ['unique_id']); 87 | } 88 | 89 | // Run the queries to seed the counter tables 90 | foreach (DatabaseDriver::splitSql(file_get_contents(APPROOT . '/etc/unatantum.sql')) as $query) 91 | { 92 | $query = trim($query); 93 | 94 | if (!empty($query)) 95 | { 96 | $db->setQuery($query)->execute(); 97 | } 98 | } 99 | } 100 | 101 | /** 102 | * Run the migrations to build the application database 103 | * 104 | * @return void 105 | */ 106 | public function runMigrations(): void 107 | { 108 | $db = $this->getConnection(); 109 | 110 | (new Migrations($db, new Filesystem(new Local(APPROOT . '/etc/migrations'))))->migrateDatabase(); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/DatabaseTestCase.php: -------------------------------------------------------------------------------- 1 | getMockServer(); 40 | } 41 | 42 | return parent::__get($name); 43 | } 44 | 45 | private function getMockServer(): Input 46 | { 47 | if ($this->mockServer === null) 48 | { 49 | $this->mockServer = new static( 50 | [ 51 | 'HTTP_HOST' => 'developer.joomla.org', 52 | 'HTTP_USER_AGENT' => 'JoomlaStatsTest/1.0', 53 | 'REMOTE_ADDR' => '127.0.0.1', 54 | 'REQUEST_METHOD' => 'GET', 55 | ] 56 | ); 57 | } 58 | 59 | return $this->mockServer; 60 | } 61 | }; 62 | 63 | $application = $this->createMock(AbstractWebApplication::class); 64 | 65 | $application->expects($this->atLeastOnce()) 66 | ->method('getInput') 67 | ->willReturn($mockInput); 68 | 69 | $application->expects($this->once()) 70 | ->method('get') 71 | ->with('uri.base.path') 72 | ->willReturn('/'); 73 | 74 | $analytics = new class extends Analytics 75 | { 76 | private $didSend = false; 77 | 78 | public function isSent(): bool 79 | { 80 | return $this->didSend === true; 81 | } 82 | 83 | public function sendPageview() 84 | { 85 | $this->didSend = true; 86 | 87 | return new NullAnalyticsResponse; 88 | } 89 | }; 90 | 91 | $event = new ApplicationEvent(ApplicationEvents::BEFORE_EXECUTE, $application); 92 | 93 | $logger = new TestLogger; 94 | 95 | $subscriber = new AnalyticsSubscriber($analytics); 96 | $subscriber->setLogger($logger); 97 | $subscriber->onBeforeExecute($event); 98 | 99 | $this->assertTrue($analytics->isSent()); 100 | } 101 | 102 | /** 103 | * @testdox Analytics are not recorded for POST requests to the live site 104 | */ 105 | public function testAnalyticsAreNotRecordedForPostRequestsToTheLiveSite(): void 106 | { 107 | $mockInput = new class extends Input 108 | { 109 | private $mockServer; 110 | 111 | public function __get($name) 112 | { 113 | if ($name === 'server') 114 | { 115 | return $this->getMockServer(); 116 | } 117 | 118 | return parent::__get($name); 119 | } 120 | 121 | private function getMockServer(): Input 122 | { 123 | if ($this->mockServer === null) 124 | { 125 | $this->mockServer = new static( 126 | [ 127 | 'HTTP_HOST' => 'developer.joomla.org', 128 | 'HTTP_USER_AGENT' => 'JoomlaStatsTest/1.0', 129 | 'REMOTE_ADDR' => '127.0.0.1', 130 | 'REQUEST_METHOD' => 'POST', 131 | ] 132 | ); 133 | } 134 | 135 | return $this->mockServer; 136 | } 137 | }; 138 | 139 | $application = $this->createMock(AbstractWebApplication::class); 140 | 141 | $application->expects($this->atLeastOnce()) 142 | ->method('getInput') 143 | ->willReturn($mockInput); 144 | 145 | $application->expects($this->never()) 146 | ->method('get'); 147 | 148 | $analytics = new class extends Analytics 149 | { 150 | private $didSend = false; 151 | 152 | public function isSent(): bool 153 | { 154 | return $this->didSend === true; 155 | } 156 | 157 | public function sendPageview() 158 | { 159 | $this->didSend = true; 160 | 161 | return new NullAnalyticsResponse; 162 | } 163 | }; 164 | 165 | $event = new ApplicationEvent(ApplicationEvents::BEFORE_EXECUTE, $application); 166 | 167 | $logger = new TestLogger; 168 | 169 | $subscriber = new AnalyticsSubscriber($analytics); 170 | $subscriber->setLogger($logger); 171 | $subscriber->onBeforeExecute($event); 172 | 173 | $this->assertFalse($analytics->isSent()); 174 | } 175 | 176 | /** 177 | * @testdox An error while sending analytics is handled 178 | */ 179 | public function testAnErrorWhileSendingAnalyticsIsHandled(): void 180 | { 181 | $mockInput = new class extends Input 182 | { 183 | private $mockServer; 184 | 185 | public function __get($name) 186 | { 187 | if ($name === 'server') 188 | { 189 | return $this->getMockServer(); 190 | } 191 | 192 | return parent::__get($name); 193 | } 194 | 195 | private function getMockServer(): Input 196 | { 197 | if ($this->mockServer === null) 198 | { 199 | $this->mockServer = new static( 200 | [ 201 | 'HTTP_HOST' => 'developer.joomla.org', 202 | 'HTTP_USER_AGENT' => 'JoomlaStatsTest/1.0', 203 | 'REMOTE_ADDR' => '127.0.0.1', 204 | 'REQUEST_METHOD' => 'GET', 205 | ] 206 | ); 207 | } 208 | 209 | return $this->mockServer; 210 | } 211 | }; 212 | 213 | $application = $this->createMock(AbstractWebApplication::class); 214 | 215 | $application->expects($this->atLeastOnce()) 216 | ->method('getInput') 217 | ->willReturn($mockInput); 218 | 219 | $application->expects($this->once()) 220 | ->method('get') 221 | ->with('uri.base.path') 222 | ->willReturn('/'); 223 | 224 | $analytics = new class extends Analytics 225 | { 226 | private $didSend = false; 227 | 228 | public function isSent(): bool 229 | { 230 | return $this->didSend === true; 231 | } 232 | 233 | public function sendPageview(): void 234 | { 235 | throw new \Exception('Testing error handling'); 236 | } 237 | }; 238 | 239 | $event = new ApplicationEvent(ApplicationEvents::BEFORE_EXECUTE, $application); 240 | 241 | $logger = new TestLogger; 242 | 243 | $subscriber = new AnalyticsSubscriber($analytics); 244 | $subscriber->setLogger($logger); 245 | $subscriber->onBeforeExecute($event); 246 | 247 | $this->assertTrue($logger->hasErrorThatContains('Error sending analytics data.')); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /tests/EventListener/ErrorSubscriberTest.php: -------------------------------------------------------------------------------- 1 | createMock(ConsoleApplication::class); 39 | 40 | $application->expects($this->once()) 41 | ->method('getConsoleInput') 42 | ->willReturn($this->createMock(InputInterface::class)); 43 | 44 | $application->expects($this->once()) 45 | ->method('getConsoleOutput') 46 | ->willReturn($output); 47 | 48 | $event = new ConsoleApplicationErrorEvent($error, $application); 49 | 50 | $logger = new TestLogger; 51 | 52 | $subscriber = new ErrorSubscriber; 53 | $subscriber->setLogger($logger); 54 | $subscriber->handleConsoleError($event); 55 | 56 | $this->assertTrue( 57 | $logger->hasErrorThatContains('Uncaught Throwable of type Exception caught.'), 58 | 'An error from the console application should be logged.' 59 | ); 60 | 61 | $screenOutput = $output->fetch(); 62 | 63 | $this->assertStringContainsString( 64 | 'Uncaught Throwable of type Exception caught: Testing', 65 | $screenOutput, 66 | 'An error from the console application should be output.' 67 | ); 68 | } 69 | 70 | /** 71 | * @testdox Method Not Allowed Errors are handled for the web application 72 | */ 73 | public function testMethodNotAllowedErrorsAreHandledForTheWebApplication(): void 74 | { 75 | $error = new MethodNotAllowedException(['POST']); 76 | 77 | $mockInput = new class extends Input 78 | { 79 | private $mockServer; 80 | 81 | public function __get($name) 82 | { 83 | if ($name === 'server') 84 | { 85 | return $this->getMockServer(); 86 | } 87 | 88 | return parent::__get($name); 89 | } 90 | 91 | private function getMockServer(): Input 92 | { 93 | if ($this->mockServer === null) 94 | { 95 | $this->mockServer = new static( 96 | [ 97 | 'HTTP_HOST' => 'developer.joomla.org', 98 | 'HTTP_USER_AGENT' => 'JoomlaStatsTest/1.0', 99 | 'REMOTE_ADDR' => '127.0.0.1', 100 | 'REQUEST_METHOD' => 'GET', 101 | ] 102 | ); 103 | } 104 | 105 | return $this->mockServer; 106 | } 107 | }; 108 | 109 | $application = $this->createMock(AbstractWebApplication::class); 110 | 111 | $application->expects($this->atLeastOnce()) 112 | ->method('getInput') 113 | ->willReturn($mockInput); 114 | 115 | $application->expects($this->once()) 116 | ->method('get') 117 | ->with('uri.route') 118 | ->willReturn('/hello'); 119 | 120 | $application->expects($this->once()) 121 | ->method('allowCache') 122 | ->with(false) 123 | ->willReturn(false); 124 | 125 | $application->expects($this->once()) 126 | ->method('setResponse'); 127 | 128 | $application->expects($this->once()) 129 | ->method('setHeader'); 130 | 131 | $event = new ApplicationErrorEvent($error, $application); 132 | 133 | $logger = new TestLogger; 134 | 135 | $subscriber = new ErrorSubscriber; 136 | $subscriber->setLogger($logger); 137 | $subscriber->handleWebError($event); 138 | 139 | $this->assertTrue( 140 | $logger->hasErrorThatContains('Route `/hello` not supported by method `GET`'), 141 | 'An error from the web application should be logged.' 142 | ); 143 | } 144 | 145 | /** 146 | * @testdox Route Not Found Errors are handled for the web application 147 | */ 148 | public function testRouteNotFoundErrorsAreHandledForTheWebApplication(): void 149 | { 150 | $error = new RouteNotFoundException('Testing', 404); 151 | 152 | $application = $this->createMock(AbstractWebApplication::class); 153 | 154 | $application->expects($this->once()) 155 | ->method('get') 156 | ->with('uri.route') 157 | ->willReturn('/hello'); 158 | 159 | $application->expects($this->once()) 160 | ->method('allowCache') 161 | ->with(false) 162 | ->willReturn(false); 163 | 164 | $application->expects($this->once()) 165 | ->method('setResponse'); 166 | 167 | $event = new ApplicationErrorEvent($error, $application); 168 | 169 | $logger = new TestLogger; 170 | 171 | $subscriber = new ErrorSubscriber; 172 | $subscriber->setLogger($logger); 173 | $subscriber->handleWebError($event); 174 | 175 | $this->assertTrue( 176 | $logger->hasErrorThatContains('Route `/hello` not found'), 177 | 'An error from the web application should be logged.' 178 | ); 179 | } 180 | 181 | /** 182 | * @testdox Uncaught Exceptions are handled for the web application 183 | */ 184 | public function testUncaughtExceptionsAreHandledForTheWebApplication(): void 185 | { 186 | $error = new \Exception('Testing'); 187 | 188 | $application = $this->createMock(AbstractWebApplication::class); 189 | 190 | $application->expects($this->once()) 191 | ->method('allowCache') 192 | ->with(false) 193 | ->willReturn(false); 194 | 195 | $application->expects($this->once()) 196 | ->method('setResponse'); 197 | 198 | $event = new ApplicationErrorEvent($error, $application); 199 | 200 | $logger = new TestLogger; 201 | 202 | $subscriber = new ErrorSubscriber; 203 | $subscriber->setLogger($logger); 204 | $subscriber->handleWebError($event); 205 | 206 | $this->assertTrue( 207 | $logger->hasErrorThatContains('Uncaught Throwable of type Exception caught.'), 208 | 'An error from the web application should be logged.' 209 | ); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /tests/Kernel/ConsoleKernelTest.php: -------------------------------------------------------------------------------- 1 | boot(); 36 | 37 | $this->assertTrue($kernel->isBooted()); 38 | 39 | $container = $kernel->getContainer(); 40 | 41 | $this->assertSame( 42 | $container->get(LoggerInterface::class), 43 | $container->get('monolog.logger.cli'), 44 | 'The logger should be aliased to the correct service.' 45 | ); 46 | 47 | $this->assertInstanceOf( 48 | Application::class, 49 | $container->get(AbstractApplication::class), 50 | 'The AbstractApplication should be aliased to the correct subclass.' 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Kernel/WebKernelTest.php: -------------------------------------------------------------------------------- 1 | boot(); 36 | 37 | $this->assertTrue($kernel->isBooted()); 38 | 39 | $container = $kernel->getContainer(); 40 | 41 | $this->assertSame( 42 | $container->get(LoggerInterface::class), 43 | $container->get('monolog.logger.application'), 44 | 'The logger should be aliased to the correct service.' 45 | ); 46 | 47 | $this->assertInstanceOf( 48 | WebApplication::class, 49 | $container->get(AbstractApplication::class), 50 | 'The AbstractApplication should be aliased to the correct subclass.' 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/KernelTest.php: -------------------------------------------------------------------------------- 1 | executed = true; 35 | } 36 | 37 | public function isExecuted(): bool 38 | { 39 | return $this->executed === true; 40 | } 41 | }; 42 | 43 | $kernel = new class($application) extends Kernel 44 | { 45 | private $application; 46 | 47 | public function __construct(AbstractApplication $application) 48 | { 49 | $this->application = $application; 50 | } 51 | 52 | protected function buildContainer(): Container 53 | { 54 | $container = parent::buildContainer(); 55 | 56 | $container->share(AbstractApplication::class, $this->application, true); 57 | 58 | $container->alias('monolog', 'monolog.logger.application') 59 | ->alias('logger', 'monolog.logger.application') 60 | ->alias(Logger::class, 'monolog.logger.application') 61 | ->alias(LoggerInterface::class, 'monolog.logger.application'); 62 | 63 | return $container; 64 | } 65 | }; 66 | 67 | $kernel->run(); 68 | 69 | $this->assertTrue($application->isExecuted()); 70 | } 71 | 72 | /** 73 | * @testdox The Kernel is not run when an application is not registered 74 | */ 75 | public function testTheKernelIsNotRunWhenAnApplicationIsNotRegistered(): void 76 | { 77 | $kernel = new class extends Kernel 78 | { 79 | protected function buildContainer(): Container 80 | { 81 | $container = parent::buildContainer(); 82 | 83 | $container->alias('monolog', 'monolog.logger.application') 84 | ->alias('logger', 'monolog.logger.application') 85 | ->alias(Logger::class, 'monolog.logger.application') 86 | ->alias(LoggerInterface::class, 'monolog.logger.application'); 87 | 88 | return $container; 89 | } 90 | }; 91 | 92 | $this->expectException(\RuntimeException::class); 93 | $this->expectExceptionMessage('The application has not been registered with the container.'); 94 | 95 | $kernel->run(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/Repositories/StatisticsRepositoryTest.php: -------------------------------------------------------------------------------- 1 | runMigrations(); 37 | } 38 | 39 | /** 40 | * This method is called before each test. 41 | * 42 | * @return void 43 | */ 44 | protected function setUp(): void 45 | { 46 | parent::setUp(); 47 | 48 | static::$dbManager->loadExampleData(); 49 | 50 | $this->repository = new StatisticsRepository(static::$connection); 51 | } 52 | 53 | /** 54 | * Tears down the fixture, for example, close a network connection. 55 | * This method is called after a test is executed. 56 | * 57 | * @return void 58 | */ 59 | protected function tearDown(): void 60 | { 61 | static::$dbManager->clearTables(); 62 | 63 | parent::tearDown(); 64 | } 65 | 66 | /** 67 | * @testdox The data for all tables is returned 68 | */ 69 | public function testTheDataForAllTablesIsReturned(): void 70 | { 71 | $data = $this->repository->getItems(); 72 | 73 | foreach (StatisticsRepository::ALLOWED_SOURCES as $source) 74 | { 75 | $this->assertArrayHasKey($source, $data, sprintf('Missing data for "%s" source.', $source)); 76 | } 77 | } 78 | 79 | /** 80 | * @testdox The data for a single table is returned 81 | */ 82 | public function testTheDataForASingleTableIsReturned(): void 83 | { 84 | $data = $this->repository->getItems('cms_version'); 85 | 86 | $this->assertNotEmpty($data); 87 | } 88 | 89 | /** 90 | * @testdox The data is not fetched for an unknown source 91 | */ 92 | public function testTheDataIsNotFetchedForAnUnknownSource(): void 93 | { 94 | $this->expectException(\InvalidArgumentException::class); 95 | $this->expectExceptionMessage('An invalid data source was requested.'); 96 | $this->expectExceptionCode(404); 97 | 98 | $data = $this->repository->getItems('does_not_exist'); 99 | } 100 | 101 | /** 102 | * @testdox The recently updated data for all tables is returned 103 | */ 104 | public function testTheRecentlyUpdatedDataForAllTablesIsReturned(): void 105 | { 106 | $data = $this->repository->getRecentlyUpdatedItems(); 107 | 108 | foreach (StatisticsRepository::ALLOWED_SOURCES as $source) 109 | { 110 | $this->assertArrayHasKey($source, $data, sprintf('Missing data for "%s" source.', $source)); 111 | } 112 | } 113 | 114 | /** 115 | * @testdox A new row is saved to the database 116 | */ 117 | public function testANewRowIsSavedToTheDatabase(): void 118 | { 119 | $db = static::$connection; 120 | 121 | $id = 'unique-999'; 122 | 123 | $row = (object) [ 124 | 'php_version' => PHP_VERSION, 125 | 'db_type' => static::$connection->getName(), 126 | 'db_version' => static::$connection->getVersion(), 127 | 'cms_version' => '3.9.0', 128 | 'server_os' => php_uname('s') . ' ' . php_uname('r'), 129 | 'unique_id' => $id, 130 | ]; 131 | 132 | $this->repository->save($row); 133 | 134 | $rowFromDatabase = $db->setQuery( 135 | $db->getQuery(true) 136 | ->select('*') 137 | ->from('#__jstats') 138 | ->where('unique_id = :unique_id') 139 | ->bind(':unique_id', $id, ParameterType::STRING) 140 | )->loadObject(); 141 | 142 | $this->assertNotNull($rowFromDatabase, 'The newly inserted row could not be queried.'); 143 | 144 | $this->assertEquals($rowFromDatabase, $row, 'The database did not return the data that was inserted.'); 145 | $this->assertObjectHasAttribute('modified', $rowFromDatabase, 'The modified timestamp should be included in the query result.'); 146 | } 147 | 148 | /** 149 | * @testdox An existing row is updated in the database 150 | */ 151 | public function testAnExistingRowIsUpdatedInTheDatabase(): void 152 | { 153 | $db = static::$connection; 154 | 155 | $id = 'a1b2c3d4'; 156 | 157 | $row = (object) [ 158 | 'php_version' => PHP_VERSION, 159 | 'db_type' => static::$connection->getName(), 160 | 'db_version' => static::$connection->getVersion(), 161 | 'cms_version' => '3.9.0', 162 | 'server_os' => php_uname('s') . ' ' . php_uname('r'), 163 | 'unique_id' => $id, 164 | ]; 165 | 166 | $this->repository->save($row); 167 | 168 | $rowFromDatabase = $db->setQuery( 169 | $db->getQuery(true) 170 | ->select('*') 171 | ->from('#__jstats') 172 | ->where('unique_id = :unique_id') 173 | ->bind(':unique_id', $id, ParameterType::STRING) 174 | )->loadObject(); 175 | 176 | $this->assertNotNull($rowFromDatabase, 'The updated row could not be queried.'); 177 | 178 | $this->assertEquals($rowFromDatabase, $row, 'The database did not return the data that was updated.'); 179 | $this->assertObjectHasAttribute('modified', $rowFromDatabase, 'The modified timestamp should be included in the query result.'); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 7 | # Allow CORS 8 | Header always set Access-Control-Allow-Origin "*" 9 | Header always set Access-Control-Allow-Methods "GET" 10 | Header always set Access-Control-Allow-Headers "*" 11 | 12 | ##################### Security Header ##################### 13 | -------------------------------------------------------------------------------- /www/index.php: -------------------------------------------------------------------------------- 1 | 500, 21 | 'message' => 'Composer is not set up properly, please run "composer install".', 22 | 'error' => true, 23 | ] 24 | ); 25 | 26 | exit; 27 | } 28 | 29 | require APPROOT . '/vendor/autoload.php'; 30 | 31 | try { 32 | (new \Joomla\StatsServer\Kernel\WebKernel())->run(); 33 | } catch (\Throwable $throwable) { 34 | error_log($throwable); 35 | 36 | if (!headers_sent()) { 37 | header('HTTP/1.1 500 Internal Server Error', null, 500); 38 | header('Content-Type: application/json; charset=utf-8'); 39 | } 40 | 41 | echo \json_encode( 42 | [ 43 | 'code' => $throwable->getCode(), 44 | 'message' => 'An error occurred while executing the application: ' . $throwable->getMessage(), 45 | 'error' => true, 46 | ] 47 | ); 48 | 49 | exit; 50 | } 51 | --------------------------------------------------------------------------------