diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..abb4411 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# PHP files always use LF, which the coding-style check enforces +*.php text eol=lf + +# Keep development files out of the Composer package +/.github export-ignore +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/ROADMAP.md export-ignore diff --git a/.github/logo.svg b/.github/logo.svg new file mode 100644 index 0000000..a5721e7 --- /dev/null +++ b/.github/logo.svg @@ -0,0 +1,132 @@ + + HestiaCP PHP API + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6221c79 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,69 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + quality: + name: Coding style and static analysis + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + with: + composer-options: --prefer-dist + + - name: Check coding style + run: vendor/bin/php-cs-fixer check --diff + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress --error-format=github + + phpunit: + name: PHP ${{ matrix.php }} (${{ matrix.dependencies }} deps) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4', '8.5'] + dependencies: [highest] + include: + - php: '8.2' + dependencies: lowest + + steps: + - uses: actions/checkout@v5 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: pcov + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + with: + dependency-versions: ${{ matrix.dependencies }} + composer-options: --prefer-dist + + - name: Run tests + run: vendor/bin/phpunit --coverage-clover=coverage.xml + + - name: Upload coverage + if: matrix.php == '8.4' && matrix.dependencies == 'highest' + uses: codecov/codecov-action@v5 + with: + files: coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 57462ef..fc012d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ composer.lock test.php /vendor -/tests/log \ No newline at end of file +/tests/log +/.phpunit.cache +coverage.xml +/.phpstan.cache +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..06e6c15 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,28 @@ +in([__DIR__ . '/src', __DIR__ . '/tests']); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setIndent("\t") + ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache') + ->setRules([ + '@PSR12' => true, + // Opening braces stay on the same line, with a blank line after a class opening brace + 'braces_position' => [ + 'classes_opening_brace' => 'same_line', + 'functions_opening_brace' => 'same_line', + 'anonymous_classes_opening_brace' => 'same_line', + 'anonymous_functions_opening_brace' => 'same_line', + 'control_structures_opening_brace' => 'same_line', + ], + 'no_blank_lines_after_class_opening' => false, + 'array_syntax' => ['syntax' => 'short'], + 'no_unused_imports' => true, + 'single_quote' => true, + 'no_trailing_comma_in_singleline' => true, + 'single_line_empty_body' => true, + 'declare_strict_types' => true, + ]) + ->setFinder($finder); diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ecf36ce..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: php - -php: - - 7.3.24 - -before_script: - - travis_retry composer self-update - - travis_retry composer install --no-interaction --no-suggest - -script: - - vendor/bin/phpunit --coverage-clover=coverage.xml --whitelist src ./tests - -after_success: - - bash <(curl -s https://codecov.io/bash) \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eecce97 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,85 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +This will be released as 2.0.0. It brings the library up to date with HestiaCP 1.10 and contains breaking changes, marked **BC break** below. + +### Added + +- Database module (`Client::getModuleDatabase()`): `add()`, `delete()`, `deleteDatabases()`, `listDatabase()` and `listDatabases()`. +- Access-key authentication (HestiaCP 1.6+). Named constructors `Credentials::accessKey($key, $secret)`, `Credentials::legacyHash($hash)` and `Credentials::password($user, $pass)`. `ACCESS_KEY:SECRET_KEY` can also be passed as a single string to `Client::simpleFactory()`. +- `verify` client option: `true` (default), `false`, or a path to a CA bundle. +- Missing exit codes in `ResponseCode` (`E_BACKUP`, `E_API_DISABLED`, `E_FORBIDDEN`, and others), with a generic message for unknown codes. +- `Add\LetsEncryptDomain`: `mail` and `keyAlgo` arguments (`ecdsa-256`, `ecdsa-384` or `rsa`). `keyAlgo` is sent only when set, because HestiaCP 1.10.5 doesn't support it yet. +- `Add\User`: optional `name` and `lastname` arguments. +- `Delete\UserBackupExclusions`: optional `system` argument. +- Tests for the client and command processing, using Guzzle's `MockHandler`. +- GitHub Actions CI on PHP 8.2 to 8.5, plus a lowest-dependencies run on PHP 8.2. +- PHPStan (level 6) and a PHP-CS-Fixer coding-style check, run in CI. `composer check` runs them with the tests. + +### Changed + +- **BC break:** requires PHP 8.2 or later. PHP 8.1 reached end of life on 31 December 2025. +- **BC break:** list and detail methods return plain associative arrays instead of `Nette\Utils\ArrayHash`. Replace `$item->FIELD` with `$item['FIELD']`. +- **BC break:** exceptions moved from `neto737\HestiaCP\*` to `neto737\HestiaCP\Exceptions\*`, one class per file. Autoloading switched from `classmap` to PSR-4. +- **BC break:** TLS certificates are verified by default. For a self-signed certificate, pass `'verify' => false` or a CA bundle path. +- **BC break:** the hostname must include an `http://` or `https://` scheme. A port, path, query, fragment or user info in it throws `InvalidArgumentException`. A trailing slash is removed, and the scheme and host are lowercased. +- **BC break:** the 4th argument of `Webs::addDomainLetsEncrypt()` is renamed from `$restart` to `$mail`, which is how HestiaCP has always read it. Positional calls behave the same; named arguments must use the new name. +- **BC break:** `getTimeout()` is part of `ICommand`. Classes that implement `ICommand` directly must add it. +- Errors are read from HestiaCP's HTTP status and `Hestia-Exit-Code` header instead of Guzzle exceptions. Exit codes 9 and 10 throw `AuthorizationException`; other non-zero codes throw `ProcessException`; network and timeout failures throw `ClientException` with the original exception as `previous`. +- **BC break:** `Backups::backup()` and `Users::backup()` return `bool` instead of `string`. The backup scripts print a plain-text log, not JSON, so the old return value could not be parsed. +- `Databases::listDatabases()` returns every database instead of only the first one. +- `Add\User` sends a null package as `default`, so the arguments after it keep their positions. +- `Credentials` getters return `?string`. +- Guzzle requirement is `^7.15.2 || ^8.2`. PHPUnit is `^11.5 || ^12.0`. +- `minimum-stability` is `stable` instead of `dev`. +- Implicit nullable parameters (`string $x = null`) replaced with `?string`. +- Every file declares `strict_types=1`. Properties have native types instead of `@var` docblocks. +- **BC break:** `ICommand::process()` returns `mixed`. `ListCommand::process()` returns `array`. The setters in `Client`, `Credentials` and `Host` return `static`. `Module::$client` is typed `Client` and `ResponseCode::$messages` is typed `array`. Custom classes that implement or override these must declare compatible types. +- Documentation links point to `hestiacp.com/docs`. + +### Removed + +- **BC break:** `nette/utils` dependency. +- **BC break:** `ICommand::processException()` and `Command::processException()`, the unused `Command::throwCodeException()`, and the misspelled `ResponseCode::E_FORBIDEN` alias (use `E_FORBIDDEN`). +- VestaCP-era JSON workarounds in `Lists\MailDomainDkim` and `Lists\MailDomainDkimDns`. HestiaCP outputs valid JSON for both. +- Travis CI configuration. + +### Fixed + +- `TestAuthorization` sent an empty command, which current HestiaCP rejects. It now runs `v-list-sys-info` and returns `false` on an authentication failure. +- `Backup\User` and `Backup\Users` failed because they expected JSON. `Backup\Users` also sent a `json` argument the script doesn't take. +- PHP 8.1 deprecation warnings from passing `null` to `preg_match()` and `preg_replace()`. + +## [1.2.0] - 2021-10-13 + +### Added + +- Backup module (`Client::getModuleBackup()`): create, delete and list backups, and list and delete backup exclusions. +- `Users::backup()` to back up all users. + +## [1.1.0] - 2021-09-10 + +### Added + +- API key authentication: pass the key as the only credential to `Client::simpleFactory()` or `Credentials`. + +## [1.0.1] - 2021-07-01 + +### Fixed + +- `Unsuspend\MailAccount` called `v-suspend-mail-account` instead of `v-unsuspend-mail-account`. + +## [1.0.0] - 2021-06-30 + +First release, with the user, web and mail modules. + +[Unreleased]: https://github.com/neto737/HestiaCP-API/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/neto737/HestiaCP-API/compare/v1.1.0...v1.2.0 +[1.1.0]: https://github.com/neto737/HestiaCP-API/compare/v1.0.1...v1.1.0 +[1.0.1]: https://github.com/neto737/HestiaCP-API/compare/v1.0.0...v1.0.1 +[1.0.0]: https://github.com/neto737/HestiaCP-API/releases/tag/v1.0.0 diff --git a/LICENSE b/LICENSE index ec887e2..2e606fb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 PROFI CLOUD SERVICES s.r.o. +Copyright (c) 2021-2026 Neto (neto737) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b3fe0be..1c4ba75 100644 --- a/README.md +++ b/README.md @@ -1,235 +1,147 @@ +

+ HestiaCP PHP API logo +

-# HestiaCP PHP API +

HestiaCP PHP API

- -[![Name](https://badgen.net/packagist/name/neto737/hestiacp-api)](//packagist.org/packages/neto737/hestiacp-api) [![Latest Stable Version](https://badgen.net/packagist/v/neto737/hestiacp-api/latest)](https://packagist.org/packages/neto737/hestiacp-api) [![Total Downloads](https://badgen.net/packagist/dt/neto737/hestiacp-api)](https://packagist.org/packages/neto737/hestiacp-api) [![License](https://badgen.net/packagist/license/neto737/hestiacp-api)](https://packagist.org/packages/neto737/hestiacp-api) [![Build Status](https://www.travis-ci.com/neto737/HestiaCP-API.svg?branch=main)](https://www.travis-ci.com/github/neto737/HestiaCP-API) [![codecov](https://codecov.io/gh/neto737/HestiaCP-API/branch/main/graph/badge.svg?token=F93I5JQXY6)](https://codecov.io/gh/neto737/HestiaCP-API) +

+ A modern PHP client for the HestiaCP API.
+ Manage users, web domains, mail, databases and backups on a HestiaCP server from your PHP code. +

- +

+ Latest Stable Version + Total Downloads + PHP 8.2 or later + Tests + Coverage + License +

-## How to use +--- -1) Installation +## Features -```sh - -$ composer require neto737/hestiacp-api - -``` +- **Every HestiaCP login method:** access keys (HestiaCP 1.6+), legacy API keys, and admin username and password. +- **Ready-made modules** for users, web domains, mail, databases and backups. +- **Errors as exceptions:** each failure is a typed exception that carries HestiaCP's exit code, so you can tell "already exists" apart from "bad credentials". +- **TLS verification on by default,** with an option for self-signed certificates. +- **Plain PHP arrays** as results. The only dependency is Guzzle. +- **Lower-level access:** send any of the bundled command classes directly, or write your own. +- Tested on PHP 8.2 to 8.5 and checked against HestiaCP 1.10. -2) Create Client +## Requirements -a) Easy way +- PHP 8.2 or later +- HestiaCP with the API enabled, and the IP address of the calling machine allowed in the server settings -```php - -use neto737\HestiaCP\Client; - -// Easy way to create Client - -// Using API Key -$client = Client::simpleFactory('https://someHost', 'API_Key'); - -// Using username and password -$client = Client::simpleFactory('https://someHost', 'someUser', 'somePass'); +## Installation +```sh +composer require neto737/hestiacp-api ``` -b) For some reasons (more hosts, etc) you may need create objects alone +## Quick start ```php - use neto737\HestiaCP\Client; -use neto737\HestiaCP\Authorization\Credentials; -use neto737\HestiaCP\Authorization\Host; - -// You can choose to use an API Key or username and password -// API Key -$credentials = new Credentials('API_Key'); +// Access key (HestiaCP 1.6+), written as ACCESS_KEY:SECRET_KEY +$client = Client::simpleFactory('https://panel.example.com', 'ACCESS_KEY:SECRET_KEY'); -// Username and Password -$credentials = new Credentials('someUser', 'somePassword'); - -$host = new Host('https://someHost', $credentials); - -$client = new Client($host); - -``` - -3) Usage - -```php - -// verify login +// Check that the credentials work $client->testAuthorization(); // bool -``` - -You can simply send one of prepared commands (or you can write own command - must implements `\neto737\HestiaCP\Command\ICommand` ) - -```php - -$command = new SomeCommand(); - -$response = $client->send($command); - - -echo $response->getResponseText(); - -``` - -Or you can use prepared modules - -a) user module - -```php - -$userModule = $client->getModuleUser(); - - -$userModule->list(); // returns all users with data - -$userModule->detail('admin'); // returns selected user with data - -$userModule->changePassword('admin', 'otherPa$$word'); - -$userModule->add('other_user', 'pa$$word', 'some@email.com'); - -$userModule->suspend('other_user'); - -$userModule->unsuspend('other_user'); - -$userModule->delete('other_user'); - -``` - -b) web module - -```php - -$webModule = $client->getModuleWeb('admin'); // web module needs user - - -$webModule->listDomains(); - -$webModule->addDomain('domain.com'); - -$webModule->addDomainLetsEncrypt('domain.com', 'www.domain.com'); // needs longer timeout - -$webModule->deleteDomainLetsEncrypt('domain.com'); - -$webModule->addDomainFtp('domain.com', 'test', 'pa$$word'); +// List all users +$users = $client->getModuleUser()->list(); // ['admin' => ['NAME' => '...', 'CONTACT' => '...', ...], ...] -$webModule->changeDomainFtpPassword('domain.com', 'admin_test', 'otherPa$$word'); - -$webModule->changeDomainFtpPath('domain.com', 'admin_test', 'path/other'); - -$webModule->deleteDomainFtp('domain.com', 'admin_test'); - -$webModule->suspendDomain('domain.com'); - -$webModule->unsuspendDomain('domain.com'); - -$webModule->deleteDomain('domain.com'); +// Add a web domain for the admin user and give it a Let's Encrypt certificate +$web = $client->getModuleWeb('admin'); +$web->addDomain('example.com'); +$web->addDomainLetsEncrypt('example.com', 'www.example.com'); +// Create a mailbox +$mail = $client->getModuleMail('admin'); +$mail->addDomain('example.com'); +$mail->addAccount('example.com', 'info', 'S3cure-Pa$$word'); ``` -c) mail module - -```php - -$mailModule = $client->getModuleMail('admin'); // mail module needs user - - -$mailModule->listDomains(); // returns mail domains from selected user - -$mailModule->addDomain('domain.com'); // add domain - -$mailModule->listAccounts('domain.com'); // returns accounts from selected user and domain - -$mailModule->listDomainDkim('domain.com'); - -$mailModule->listDomainDkimDns('domain.com'); - -$mailModule->addAccount('domain.com', 'info', 'pa$$word'); // add info@domain.com account - -$mailModule->changeAccountPassword('domain.com', 'info', 'otherPa$$word'); // change info@domain.com password - -$mailModule->suspendAccount('domain.com', 'info'); // suspend info@domain.com account - -$mailModule->unsuspendAccount('domain.com', 'info'); // unsuspend info@domain.com account +> [!NOTE] +> TLS certificates are verified by default. If your panel still uses the self-signed certificate created at install, see [TLS certificates](docs/authentication.md#tls-certificates). -$mailModule->deleteAccount('domain.com', 'info'); +## Modules -$mailModule->suspendDomain('domain.com'); +| Module | Get it with | What it covers | +| --- | --- | --- | +| [Users](docs/users.md) | `getModuleUser()` | List, show, add and delete users. Change passwords, suspend and unsuspend, back up all users. | +| [Web domains](docs/web.md) | `getModuleWeb($user)` | Add, delete, suspend and unsuspend domains. Let's Encrypt certificates and FTP accounts. | +| [Mail](docs/mail.md) | `getModuleMail($user)` | Mail domains and accounts, passwords, suspending, DKIM keys and DNS records. | +| [Databases](docs/databases.md) | `getModuleDatabase($user)` | Create, list and delete databases. | +| [Backups](docs/backups.md) | `getModuleBackup($user)` | Create, list and delete backups. List and delete backup exclusions. | -$mailModule->unsuspendDomain('domain.com'); +Need a command that isn't wrapped in a module? See [Sending commands directly](docs/commands.md). -$mailModule->deleteDomain('domain.com'); - -``` - -d) db module +## Error handling ```php - -$dbModule = $client->getModuleDatabase('admin'); - - -$dbModule->add('database', 'dbuser', 'dbpass'); - -$dbModule->delete('admin_database'); - -$dbModule->deleteDatabases(); - -$dbModule->listDatabase('database'); - -$dbModule->listDatabases(); - - -// todo - -// ... etc - +use neto737\HestiaCP\Command\ResponseCode; +use neto737\HestiaCP\Exceptions\AuthorizationException; +use neto737\HestiaCP\Exceptions\ClientException; +use neto737\HestiaCP\Exceptions\ProcessException; + +try { + $client->getModuleUser()->add('jane', 'S3cure-Pa$$word', 'jane@example.com'); +} catch (AuthorizationException $e) { + // wrong credentials, or not allowed to run the command +} catch (ProcessException $e) { + if ($e->getCode() === ResponseCode::E_EXISTS) { + // the user already exists + } +} catch (ClientException $e) { + // connection problem: timeout, DNS, TLS, ... +} ``` -e) cron module +All exception types and exit codes are listed in [Error handling](docs/errors.md). -```php +## Documentation -$cronModule = $client->getModuleCron(); +- [Authentication and client setup](docs/authentication.md): access keys, legacy API keys, passwords, hostname rules, TLS and timeouts +- [Error handling](docs/errors.md): exception types and HestiaCP exit codes +- Modules: [Users](docs/users.md) · [Web domains](docs/web.md) · [Mail](docs/mail.md) · [Databases](docs/databases.md) · [Backups](docs/backups.md) +- [Sending commands directly](docs/commands.md): using the command classes, and writing your own +**Upgrading from v1?** Read [UPGRADING.md](UPGRADING.md). All changes are listed in [CHANGELOG.md](CHANGELOG.md). -// todo +## Contributing -// ... etc +Bug reports and pull requests are welcome. To run the checks: +```sh +composer install +composer check # coding style, static analysis and tests ``` -f) backup module - -```php - -$backupModule = $client->getModuleBackup('admin'); // backup module needs user - -$backupModule->backup(); // create a new backup - -$backupModule->delete('admin.2021-10-13_18-12-53.tar'); // delete an user backup +Each check can also run on its own: `composer test` (PHPUnit), `composer analyse` (PHPStan) and `composer cs` (PHP-CS-Fixer). `composer cs-fix` fixes the coding style. The code uses tabs for indentation and LF line endings. -$backupModule->deleteExclusions(); // delete all backup exclusions +The tests use Guzzle's mock handler, so they don't need a HestiaCP server. -$backupModule->listBackups(); // returns the backups list +## Credits -$backupModule->listBackup('admin.2021-10-13_18-12-53.tar'); // returns backup parameters list +This library started as a fork of [ProfiCloS/vestacp-api](https://github.com/ProfiCloS/vestacp-api) by PROFI CLOUD SERVICES s.r.o., a client for VestaCP, and was adapted and extended for HestiaCP. -$backupModule->listBackupExclusions(); // returns the backup exclusions list +This is a community project. It is not affiliated with or endorsed by the HestiaCP project. The Hestia logo used in this project's logo is copyright [hestiacp.com](https://hestiacp.com) and is included unmodified. -``` +## Support the project :heart: -## Donate :heart: +If this library saves you time, you can support its development via [Liberapay](https://liberapay.com/neto737), [PayPal](https://paypal.me/neto737) or crypto: ``` BTC: bc1q89ntljt5lk7g9z68f5cjs83qfm2xme7g4hkur7 ETH: 0xeef9220639F14E7A0FD825AAAd0574e5a8aD7A4B LTC: ltc1q508qfkd09vyya6c5zkfx4r248pf3ezj9ngjdr2 ``` + +## License + +Released under the [MIT License](LICENSE). diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..dafe93e --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,128 @@ +# Upgrading + +## From v1 to v2 + +v2 brings the library up to date with HestiaCP 1.10. Most code keeps working, but check each section below. The full list of changes is in [CHANGELOG.md](CHANGELOG.md). + +### PHP 8.2 or later + +v2 requires PHP 8.2. v1 ran on PHP 7.3+. + +The `nette/utils` dependency is gone. If your own code uses Nette Utils, require it in your `composer.json` yourself. + +### List and detail methods return arrays + +List and detail methods (`Users::list()`, `Webs::listDomains()`, `Backups::listBackups()` and so on) now return plain associative arrays instead of `Nette\Utils\ArrayHash` objects. + +```php +// v1 +$admin = $client->getModuleUser()->detail('admin'); +echo $admin->CONTACT; + +// v2 +echo $admin['CONTACT']; +``` + +Search your code for `->` on results of these methods and replace it with array access. `foreach` over the results works as before. + +### Exceptions moved to their own namespace + +The exception classes moved from `neto737\HestiaCP` to `neto737\HestiaCP\Exceptions`. The class names are unchanged. + +```php +// v1 +use neto737\HestiaCP\ProcessException; + +// v2 +use neto737\HestiaCP\Exceptions\ProcessException; +``` + +This applies to `Exception`, `InvalidArgumentException`, `RuntimeException`, `ProcessException`, `ClientException`, `InvalidResponseException` and `AuthorizationException`. A `catch` with the old name doesn't fail with an error: it simply never matches, so the exception goes uncaught. Search for every `use` and `catch` of these classes. + +### Exceptions carry the HestiaCP exit code + +In v1, a failed command threw a `ProcessException` with Guzzle's HTTP error message and code `0`, and a server error could surface as a `ClientException`. In v2: + +- Exit codes 9 and 10 (wrong credentials, access denied) throw `AuthorizationException`. +- Every other failure throws `ProcessException`, with the HestiaCP exit code as `getCode()` and a readable message. +- `ClientException` means only that the request didn't complete (timeout, DNS, TLS, connection refused). + +If you parsed exception messages, compare `$e->getCode()` with the `ResponseCode` constants instead. See [Error handling](docs/errors.md). + +### TLS certificates are verified + +v1 never checked the server's TLS certificate. v2 checks it by default, so a panel with a self-signed certificate now fails with a `ClientException`. + +Either install a valid certificate (for example with Let's Encrypt), or set the `verify` option: + +```php +$client = Client::simpleFactory('https://panel.example.com', 'ACCESS_KEY:SECRET_KEY', null, [ + 'verify' => '/path/to/ca-bundle.pem', // or false to turn verification off +]); +``` + +See [TLS certificates](docs/authentication.md#tls-certificates). + +### The hostname needs a scheme + +The hostname must start with `https://` or `http://` and contain only the host. A hostname without a scheme, or with a port, path, query or fragment, now throws `InvalidArgumentException`. + +v1 passed the hostname through without checking it. + +```php +// v2 rejects these +Client::simpleFactory('panel.example.com', ...); +Client::simpleFactory('https://panel.example.com:8083/', ...); + +// use this instead +Client::simpleFactory('https://panel.example.com', ...); +new Host('https://panel.example.com', $credentials, 8083); // port as a separate argument +``` + +See [Hostname](docs/authentication.md#hostname). + +### `addDomainLetsEncrypt()`: the 4th argument is renamed + +The 4th argument of `Webs::addDomainLetsEncrypt()` was called `$restart`, but HestiaCP has always read it as the MAIL flag: `true` also requested a certificate for the mail domain. v2 renames it to `$mail` to match. Code that passes it by position behaves the same as before. + +```php +// v1 and v2: certificate for the web and mail domain +$web->addDomainLetsEncrypt('example.com', 'www.example.com', true); + +// named arguments must use the new name +$web->addDomainLetsEncrypt('example.com', mail: true); +``` + +If you passed `true` expecting a service restart, pass `false` instead, unless you also want the mail certificate. + +### Backups return `bool` + +`Backups::backup()` and `Users::backup()` return `true` when the backup has finished, instead of a string. In v1 these calls failed anyway, because the library tried to parse the backup log as JSON. + +### `ResponseCode::E_FORBIDEN` removed + +Use the correctly spelled `ResponseCode::E_FORBIDDEN`. + +### Custom commands + +Only relevant if you wrote your own command classes. + +- `getTimeout(): ?float` is now part of `ICommand`. Classes that extend `Command`, `ProcessCommand` or `ListCommand` already have it. Classes that implement `ICommand` directly must add it; return `null` to use the client timeout. +- `processException()` was removed from `ICommand` and `Command`. The client no longer calls it, because HTTP errors no longer throw in Guzzle. Delete any implementation of it. +- `Command::throwCodeException()` was removed. It wasn't used. +- `ICommand::process()` now returns `mixed`. Classes that implement `ICommand` directly must declare a return type, either `mixed` or a narrower one such as `bool` or `array`. `ListCommand::process()` now returns `array`, so subclasses that override it must return `array` too. +- The library is now fully typed. If you extend a library class and override a typed method or redeclare a typed property, use a compatible type: setters in `Client`, `Credentials` and `Host` return `static`, and `Module::$client` is `Client`. + +See [Sending commands directly](docs/commands.md). + +### Optional: switch to access keys + +Not required, but recommended. HestiaCP 1.6+ supports access keys, and password login works only for the admin/root user. v1 code that passed `ACCESS_KEY:SECRET_KEY` as a single string keeps working. v2 also has named constructors: + +```php +use neto737\HestiaCP\Authorization\Credentials; + +$credentials = Credentials::accessKey('ACCESS_KEY', 'SECRET_KEY'); +``` + +See [Authentication](docs/authentication.md). diff --git a/composer.json b/composer.json index 73b0f45..0853854 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,38 @@ { "name": "neto737/hestiacp-api", - "description": "HestiaCP API SDK written in PHP", + "description": "PHP client for the HestiaCP API: users, web domains, mail, databases and backups", + "type": "library", + "keywords": [ + "hestiacp", + "hestia", + "hestia-control-panel", + "control-panel", + "web-hosting", + "api", + "api-client", + "sdk" + ], "require": { - "php": ">=7.3", - "guzzlehttp/guzzle": "^7.4", - "nette/utils": "^3.2" + "php": "^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", + "psr/http-message": "^1.1 || ^2.0" }, "homepage": "https://github.com/neto737/HestiaCP-API", + "support": { + "issues": "https://github.com/neto737/HestiaCP-API/issues", + "source": "https://github.com/neto737/HestiaCP-API", + "docs": "https://github.com/neto737/HestiaCP-API/tree/main/docs" + }, + "funding": [ + { + "type": "liberapay", + "url": "https://liberapay.com/neto737" + }, + { + "type": "custom", + "url": "https://paypal.me/neto737" + } + ], "license": "MIT", "authors": [ { @@ -14,11 +40,37 @@ "email": "neto737@live.com" } ], - "minimum-stability": "dev", + "minimum-stability": "stable", "autoload": { - "classmap": ["src/"] + "psr-4": { + "neto737\\HestiaCP\\": "src/HestiaCP/" + } }, "require-dev": { - "phpunit/phpunit": "^9" + "friendsofphp/php-cs-fixer": "^3.86", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^11.5 || ^12.0" + }, + "config": { + "sort-packages": true + }, + "scripts": { + "test": "phpunit", + "analyse": "phpstan analyse", + "cs": "php-cs-fixer check --diff", + "cs-fix": "php-cs-fixer fix", + "check": [ + "@cs", + "@analyse", + "@test" + ] + }, + "scripts-descriptions": { + "test": "Run the PHPUnit test suite", + "analyse": "Run PHPStan static analysis", + "cs": "Check the coding style", + "cs-fix": "Fix the coding style", + "check": "Run the coding-style check, static analysis and tests" } } diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..4d8dd05 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,100 @@ +# Authentication and client setup + +## Before you start + +The API has to be enabled in the HestiaCP server settings, and the IP address of the machine that calls it has to be allowed. Otherwise every request fails. + +## Credentials + +HestiaCP accepts three kinds of credentials. + +| Kind | HestiaCP version | Notes | +| --- | --- | --- | +| Access key + secret key | 1.6+ | Recommended. Create them in the panel under the user's access keys. | +| Legacy API key | all | The old single `hash` key. | +| Username + password | all | Only accepted for the root/admin user. | + +## Creating a client + +### The short way + +`Client::simpleFactory()` takes the hostname and the credentials as strings: + +```php +use neto737\HestiaCP\Client; + +// Access key, written as ACCESS_KEY:SECRET_KEY +$client = Client::simpleFactory('https://panel.example.com', 'ACCESS_KEY:SECRET_KEY'); + +// Legacy API key +$client = Client::simpleFactory('https://panel.example.com', 'API_KEY'); + +// Username and password (admin/root user only) +$client = Client::simpleFactory('https://panel.example.com', 'admin', 'somePassword'); +``` + +### Building the objects yourself + +Use this when you need a different port, or want to reuse the same credentials for several hosts: + +```php +use neto737\HestiaCP\Client; +use neto737\HestiaCP\Authorization\Credentials; +use neto737\HestiaCP\Authorization\Host; + +$credentials = Credentials::accessKey('ACCESS_KEY', 'SECRET_KEY'); +// or Credentials::legacyHash('API_KEY'); +// or Credentials::password('admin', 'somePassword'); + +$host = new Host('https://panel.example.com', $credentials); // port defaults to 8083 +$host = new Host('https://panel.example.com', $credentials, 443); // a different port + +$client = new Client($host); +``` + +## Hostname + +The hostname must start with `https://` (or `http://`) and contain only the host: + +- `https://panel.example.com` +- `https://192.0.2.10` +- `https://[2001:db8::1]` (IPv6 addresses need the brackets) + +A trailing slash is removed, and the scheme and host are lowercased. The port is a separate argument of `Host`, not part of the hostname. A hostname with a port, path, query, fragment or user info, or without a scheme, throws `\neto737\HestiaCP\Exceptions\InvalidArgumentException`. + +## Client options + +Options are passed as the last argument of `Client::simpleFactory()` or `new Client()`: + +```php +$client = Client::simpleFactory('https://panel.example.com', 'ACCESS_KEY:SECRET_KEY', null, [ + 'timeout' => 30, + 'verify' => '/path/to/ca-bundle.pem', +]); + +$client = new Client($host, ['timeout' => 30]); +``` + +| Option | Default | Description | +| --- | --- | --- | +| `timeout` | `10` | Request timeout in seconds. `0` means no timeout. | +| `verify` | `true` | Verify the TLS certificate. `false` turns verification off; a string is the path to a CA bundle. | +| `handler` | Guzzle default | A custom Guzzle handler stack, for middleware or tests. | + +Some commands need more time and raise the timeout themselves: adding a Let's Encrypt certificate waits at least 90 seconds, and backups have no timeout. A command never uses a shorter timeout than the client's. + +## TLS certificates + +TLS certificates are verified by default. If your panel still uses the self-signed certificate that HestiaCP creates at install, either: + +- install a valid certificate (for example with Let's Encrypt), or +- pass the path to a CA bundle that contains your certificate as `verify`, or +- pass `'verify' => false` to turn verification off. Only do this on a network you trust, because it makes the connection open to interception. + +## Checking the credentials + +```php +$client->testAuthorization(); // true, or false on an authentication failure +``` + +It runs `v-list-sys-info`, so the credentials must be allowed to run that command. diff --git a/docs/backups.md b/docs/backups.md new file mode 100644 index 0000000..11f4870 --- /dev/null +++ b/docs/backups.md @@ -0,0 +1,44 @@ +# Backups + +```php +$backup = $client->getModuleBackup('admin'); +``` + +The backup module works on the backups of one user, given when you get the module. To back up every user at once, use `Users::backup()` in the [user module](users.md). + +## Creating a backup + +```php +$backup->backup(); // true when the backup has finished + +$backup->backup(false); // don't send the user a notification +``` + +The call waits until the backup is done, with no timeout, so it can take a long time on a large account. + +## Listing + +```php +$backup->listBackups(); // ['admin.2021-10-13_18-12-53.tar' => [...], ...] + +$backup->listBackup('admin.2021-10-13_18-12-53.tar'); // the fields of one backup +``` + +## Deleting + +```php +$backup->delete('admin.2021-10-13_18-12-53.tar'); +``` + +## Exclusions + +Exclusions are the parts of the account left out of backups. + +```php +$backup->listBackupExclusions(); + +$backup->deleteExclusions(); // delete all exclusions +$backup->deleteExclusions('WEB'); // delete one type: WEB, DNS, MAIL, DB, CRON or USER +``` + +All methods except the `list*()` methods return `true` on success and throw an exception on failure (see [Error handling](errors.md)). diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..c1b3789 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,110 @@ +# Sending commands directly + +The modules are a convenience layer. Each method creates a command object and passes it to `Client::send()`. You can do the same yourself, with the command classes in `neto737\HestiaCP\Command\*`, or with a command class of your own. + +## Using a command class + +```php +use neto737\HestiaCP\Command\Lists\Users; +use neto737\HestiaCP\Command\Change\UserPassword; + +$users = $client->send(new Users()); // array + +$client->send(new UserPassword('admin', 'otherPa$$word')); // true +``` + +`send()` returns whatever the command's `process()` method returns. The raw response body is kept on the command object: + +```php +$command = new Users(); +$client->send($command); + +echo $command->getResponseText(); // the JSON returned by HestiaCP +echo $command->getStatusCode(); // the HTTP status code +``` + +## Writing your own command + +Extend one of the two base classes: + +- `ProcessCommand` for commands that change something. `process()` returns `true` on exit code 0 and throws an exception otherwise. +- `ListCommand` for commands that return JSON. `process()` returns the decoded array. + +Implement `getName()` (the HestiaCP script name) and `getRequestParams()` (the script's arguments, in order): + +```php +use neto737\HestiaCP\Command\ProcessCommand; + +class AddCronJob extends ProcessCommand { + + public function __construct( + private string $user, + private string $min, + private string $hour, + private string $day, + private string $month, + private string $weekday, + private string $command + ) {} + + public function getName(): string { + return 'v-add-cron-job'; + } + + public function getRequestParams(): array { + return [ + 'arg1' => $this->user, + 'arg2' => $this->min, + 'arg3' => $this->hour, + 'arg4' => $this->day, + 'arg5' => $this->month, + 'arg6' => $this->weekday, + 'arg7' => $this->command, + ]; + } +} + +$client->send(new AddCronJob('admin', '0', '3', '*', '*', '*', '/usr/bin/php /home/admin/cron.php')); +``` + +```php +use neto737\HestiaCP\Command\ListCommand; + +class ListCronJobs extends ListCommand { + + public function __construct(private string $user) {} + + public function getName(): string { + return 'v-list-cron-jobs'; + } + + public function getRequestParams(): array { + return [ + 'arg1' => $this->user, + 'arg2' => 'json', + ]; + } +} + +$jobs = $client->send(new ListCronJobs('admin')); +``` + +Most HestiaCP list scripts print JSON only when you pass `json` as their format argument. + +### Timeouts + +To give a slow command more time than the client default, override `getTimeout()`: + +```php +public function getTimeout(): ?float { + return 120; // seconds; 0 means no timeout, null uses the client timeout +} +``` + +A command can raise the timeout but never lower it below the client's. + +### Implementing `ICommand` directly + +You can implement `\neto737\HestiaCP\Command\ICommand` without extending the base classes. You then have to implement every method yourself, including `setLastResponse()`, `needReturnCode()`, `process()` and `getTimeout()`. Extending `ProcessCommand` or `ListCommand` is simpler, and they already handle HestiaCP's exit codes and error responses. + +The full list of HestiaCP commands and their arguments is in the [HestiaCP CLI reference](https://hestiacp.com/docs/reference/cli). diff --git a/docs/databases.md b/docs/databases.md new file mode 100644 index 0000000..206bb68 --- /dev/null +++ b/docs/databases.md @@ -0,0 +1,39 @@ +# Databases + +```php +$db = $client->getModuleDatabase('admin'); +``` + +The database module works on the databases of one user, given when you get the module. + +## Adding + +```php +$db->add('database', 'dbuser', 'dbpass'); // creates admin_database with the user admin_dbuser + +$db->add('database', 'dbuser', 'dbpass', 'pgsql'); // PostgreSQL, if it is installed on the server +``` + +The type defaults to `mysql`. + +## Listing + +```php +$db->listDatabases(); // ['admin_database' => ['DATABASE' => '...', 'DBUSER' => '...', ...], ...] + +$db->listDatabase('admin_database'); // the fields of one database +``` + +## Deleting + +```php +$db->delete('admin_database'); // one database + +$db->deleteDatabases(); // every database of the user +``` + +## Name prefixes + +HestiaCP prefixes the database name and the database user with the owner's username. `add()` takes the names without the prefix (`database`); the other methods take the full name (`admin_database`). + +All methods except the `list*()` methods return `true` on success and throw an exception on failure (see [Error handling](errors.md)). diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..56a67d0 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,68 @@ +# Error handling + +Every failure is thrown as an exception. All of them extend `\neto737\HestiaCP\Exceptions\Exception`. + +```php +use neto737\HestiaCP\Command\ResponseCode; +use neto737\HestiaCP\Exceptions\AuthorizationException; +use neto737\HestiaCP\Exceptions\ClientException; +use neto737\HestiaCP\Exceptions\ProcessException; + +try { + $client->getModuleUser()->add('other_user', 'pa$$word', 'some@email.com'); +} catch (AuthorizationException $e) { + // bad credentials, or not allowed to run the command (exit code 9 or 10) +} catch (ProcessException $e) { + if ($e->getCode() === ResponseCode::E_EXISTS) { + // the user already exists + } +} catch (ClientException $e) { + // connection problem (timeout, DNS, TLS, ...) +} +``` + +Catch `AuthorizationException` before `ProcessException`, because it is a subclass. + +## Exception types + +All classes are in the `neto737\HestiaCP\Exceptions` namespace. + +| Exception | Extends | Thrown when | +| --- | --- | --- | +| `AuthorizationException` | `ProcessException` | The credentials are wrong (exit code 9) or the user may not run the command (exit code 10). | +| `InvalidResponseException` | `ProcessException` | The response isn't what HestiaCP sends, for example when the hostname points to another server. | +| `ProcessException` | `RuntimeException` | HestiaCP ran the command and it failed with any other exit code, or a list command returned invalid JSON. | +| `ClientException` | `RuntimeException` | The request didn't complete: timeout, DNS failure, TLS error, refused connection. The original Guzzle exception is available with `getPrevious()`. | +| `InvalidArgumentException` | `Exception` | An argument is invalid before anything is sent, for example a malformed hostname. | + +## Exit codes + +For `ProcessException` and its subclasses, `getCode()` returns the HestiaCP exit code. The codes are constants on `\neto737\HestiaCP\Command\ResponseCode`: + +| Code | Constant | Meaning | +| --- | --- | --- | +| 1 | `E_ARGS` | Not enough arguments provided | +| 2 | `E_INVALID` | Object or argument is not valid | +| 3 | `E_NOTEXIST` | Object doesn't exist | +| 4 | `E_EXISTS` | Object already exists | +| 5 | `E_SUSPENDED` | Object is suspended | +| 6 | `E_UNSUSPENDED` | Object is already unsuspended | +| 7 | `E_INUSE` | Object can't be deleted because another object uses it | +| 8 | `E_LIMIT` | Hosting package limits reached | +| 9 | `E_PASSWORD` | Wrong password | +| 10 | `E_FORBIDDEN` | Object cannot be accessed by the user | +| 11 | `E_DISABLED` | Subsystem is disabled | +| 12 | `E_PARSING` | Configuration is broken | +| 13 | `E_DISK` | Not enough disk space | +| 14 | `E_LA` | Server is too busy | +| 15 | `E_CONNECT` | Connection failed, host is unreachable | +| 16 | `E_FTP` | FTP server is not responding | +| 17 | `E_DB` | Database server is not responding | +| 18 | `E_RRD` | RRDtool failed to update the database | +| 19 | `E_UPDATE` | Update operation failed | +| 20 | `E_RESTART` | Service restart failed | +| 21 | `E_BACKUP` / `E_API_DISABLED` | Backup failed, or API access is disabled for this user | + +HestiaCP uses 21 for two things: `E_BACKUP` in its scripts and `E_API_DISABLED` in the API. Both constants have the value 21. + +An unknown code gets the message `Unknown error (exit code N)`. `ResponseCode::getMessage($code)` returns the message for any code. diff --git a/docs/mail.md b/docs/mail.md new file mode 100644 index 0000000..105fe10 --- /dev/null +++ b/docs/mail.md @@ -0,0 +1,52 @@ +# Mail + +```php +$mail = $client->getModuleMail('admin'); +``` + +The mail module works on the mail domains of one user, given when you get the module. + +## Domains + +```php +$mail->listDomains(); // ['example.com' => [...], ...] + +$mail->addDomain('example.com'); + +$mail->suspendDomain('example.com'); +$mail->unsuspendDomain('example.com'); + +$mail->deleteDomain('example.com'); +``` + +`addDomain()` turns on antispam, antivirus and DKIM by default. Pass `false` to turn any of them off: + +```php +$mail->addDomain('example.com', true, true, false); // antispam, antivirus, no DKIM +``` + +## DKIM + +```php +$mail->listDomainDkim('example.com'); // the domain's DKIM key +$mail->listDomainDkimDns('example.com'); // the DNS record to publish +``` + +## Accounts + +```php +$mail->listAccounts('example.com'); // ['info' => [...], ...] + +$mail->addAccount('example.com', 'info', 'pa$$word'); // creates info@example.com + +$mail->changeAccountPassword('example.com', 'info', 'otherPa$$word'); + +$mail->suspendAccount('example.com', 'info'); +$mail->unsuspendAccount('example.com', 'info'); + +$mail->deleteAccount('example.com', 'info'); +``` + +Accounts are given by the part before the `@`. + +All methods except the `list*()` methods return `true` on success and throw an exception on failure (see [Error handling](errors.md)). diff --git a/docs/users.md b/docs/users.md new file mode 100644 index 0000000..23cf32c --- /dev/null +++ b/docs/users.md @@ -0,0 +1,56 @@ +# Users + +```php +$users = $client->getModuleUser(); +``` + +The user module works on all users, so it takes no username. + +## Listing + +```php +$users->list(); // ['admin' => ['NAME' => '...', 'CONTACT' => '...', ...], ...] + +$admin = $users->detail('admin'); // ['NAME' => '...', 'CONTACT' => '...', ...] +echo $admin['CONTACT']; +``` + +`list()` returns every user, keyed by username. `detail()` returns the fields of one user. + +## Adding + +```php +$users->add('other_user', 'pa$$word', 'some@email.com'); + +// with a package, first name and last name +$users->add('other_user', 'pa$$word', 'some@email.com', 'default', 'John', 'Doe'); +``` + +If the package is `null`, the user gets the `default` package. + +## Changing the password + +```php +$users->changePassword('other_user', 'otherPa$$word'); +``` + +## Suspending and deleting + +```php +$users->suspend('other_user'); +$users->unsuspend('other_user'); + +$users->delete('other_user'); +``` + +`suspend()` and `unsuspend()` take an optional second argument, `$restart` (default `false`), that restarts the affected services. + +## Backing up all users + +```php +$users->backup(); // true when the backup has finished +``` + +This backs up every user on the server and waits until it's done, with no timeout. To back up one user, use the [backup module](backups.md). + +All methods except `list()` and `detail()` return `true` on success and throw an exception on failure (see [Error handling](errors.md)). diff --git a/docs/web.md b/docs/web.md new file mode 100644 index 0000000..dd85c5d --- /dev/null +++ b/docs/web.md @@ -0,0 +1,68 @@ +# Web domains + +```php +$web = $client->getModuleWeb('admin'); +``` + +The web module works on the domains of one user, given when you get the module. + +## Domains + +```php +$web->listDomains(); // ['example.com' => ['IP' => '...', 'ALIAS' => '...', ...], ...] + +$web->addDomain('example.com'); + +$web->suspendDomain('example.com'); +$web->unsuspendDomain('example.com'); + +$web->deleteDomain('example.com'); +``` + +`addDomain()` also takes these optional arguments: + +```php +$web->addDomain( + 'example.com', + '192.0.2.10', // IP address (default: chosen by HestiaCP) + 'www.example.com', // aliases, comma-separated + 'jpg,png,css,js', // file extensions served by the proxy + true // restart the web server +); +``` + +`suspendDomain()` and `unsuspendDomain()` take an optional `$restart` argument (default `false`). + +## Let's Encrypt + +```php +$web->addDomainLetsEncrypt('example.com', 'www.example.com'); + +// certificate for the mail domain as well +$web->addDomainLetsEncrypt('example.com', null, true); + +// key algorithm: ecdsa-256 (default), ecdsa-384 or rsa +$web->addDomainLetsEncrypt('example.com', 'www.example.com', false, 'rsa'); + +$web->deleteDomainLetsEncrypt('example.com'); +``` + +Getting a certificate is slow, so this command uses a timeout of at least 90 seconds. + +The key algorithm argument needs a HestiaCP version newer than 1.10.5. Leave it `null` on older servers, and it won't be sent. + +## FTP accounts + +```php +$web->addDomainFtp('example.com', 'test', 'pa$$word'); +$web->addDomainFtp('example.com', 'test', 'pa$$word', 'path/inside/domain'); // with a path + +$web->changeDomainFtpPassword('example.com', 'admin_test', 'otherPa$$word'); +$web->changeDomainFtpPath('example.com', 'admin_test', 'path/other'); + +$web->deleteDomainFtp('example.com', 'admin_test'); +``` + +HestiaCP prefixes the FTP username with the owner's username. `addDomainFtp()` takes the name without the prefix (`test`); the other methods take the full name (`admin_test`). + +All methods except `listDomains()` return `true` on success and throw an exception on failure (see [Error handling](errors.md)). diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..bf58262 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,10 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + - vendor/phpstan/phpstan-phpunit/rules.neon + +parameters: + level: 6 + paths: + - src + - tests + tmpDir: .phpstan.cache diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..4bde472 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/src/HestiaCP/Authorization/Credentials.php b/src/HestiaCP/Authorization/Credentials.php index 3feb43a..3646b19 100644 --- a/src/HestiaCP/Authorization/Credentials.php +++ b/src/HestiaCP/Authorization/Credentials.php @@ -1,23 +1,26 @@ setApiKey($user); } else { @@ -26,40 +29,88 @@ public function __construct(string $user, string $password = null) { } } - public function setApiKey(string $key) { + /** + * Access key authentication (HestiaCP 1.6+) + */ + public static function accessKey(string $accessKey, string $secretKey): self { + return (new self(''))->setAccessKey($accessKey, $secretKey); + } + + /** + * Legacy API key, sent as `hash` + */ + public static function legacyHash(string $hash): self { + return new self($hash); + } + + /** + * Username and password (HestiaCP only accepts the admin/root user) + */ + public static function password(string $user, string $password): self { + return new self($user, $password); + } + + public function setAccessKey(string $accessKey, string $secretKey): static { + $this->access_key = $accessKey; + $this->secret_key = $secretKey; + $this->api_key = null; + return $this; + } + + public function getAccessKey(): ?string { + return $this->access_key; + } + + public function getSecretKey(): ?string { + return $this->secret_key; + } + + public function setApiKey(string $key): static { $this->api_key = $key; + $this->access_key = null; + $this->secret_key = null; return $this; } - public function getApiKey(): string { + public function getApiKey(): ?string { return $this->api_key; } - public function setUser(string $user) { + public function setUser(string $user): static { $this->user = $user; return $this; } - public function getUser(): string { + public function getUser(): ?string { return $this->user; } - public function setPassword(string $password) { + public function setPassword(string $password): static { $this->password = $password; return $this; } - public function getPassword(): string { + public function getPassword(): ?string { return $this->password; } + /** + * @return array + */ public function getRequestParams(): array { - if ($this->api_key) { + if ($this->access_key !== null) { + return [ + 'access_key' => $this->getAccessKey(), + 'secret_key' => $this->getSecretKey() + ]; + } + + if ($this->api_key !== null) { return [ 'hash' => $this->getApiKey() ]; } - + return [ 'user' => $this->getUser(), 'password' => $this->getPassword() diff --git a/src/HestiaCP/Authorization/Host.php b/src/HestiaCP/Authorization/Host.php index f636b0c..d55c529 100644 --- a/src/HestiaCP/Authorization/Host.php +++ b/src/HestiaCP/Authorization/Host.php @@ -1,26 +1,39 @@ setHostname($hostname); $this->setCredentials($credentials); $this->setPort($port); } - public function setHostname(string $hostname) { - $this->hostname = $hostname; + /** + * The hostname must start with http:// or https://. A trailing slash is removed + * and the scheme and host are lowercased. The port is set separately (see setPort()). + * + * @throws InvalidArgumentException + */ + public function setHostname(string $hostname): static { + $this->hostname = self::normalizeHostname($hostname); return $this; } @@ -28,7 +41,7 @@ public function getHostname(): string { return $this->hostname; } - public function setCredentials(Credentials $credentials) { + public function setCredentials(Credentials $credentials): static { $this->credentials = $credentials; return $this; } @@ -37,7 +50,7 @@ public function getCredentials(): Credentials { return $this->credentials; } - public function setPort(int $port) { + public function setPort(int $port): static { $this->port = $port; return $this; } @@ -49,4 +62,37 @@ public function getPort(): int { public function buildUri(): string { return $this->getHostname() . ':' . $this->getPort() . '/api/'; } + + /** + * @throws InvalidArgumentException + */ + private static function normalizeHostname(string $hostname): string { + $hostname = trim($hostname); + + $authority = preg_replace('~^[a-z][a-z0-9+.-]*://~i', '', $hostname); + if (filter_var(rtrim($authority, '/'), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + throw new InvalidArgumentException("Invalid hostname '$hostname', an IPv6 address must be in brackets, e.g. 'https://[2001:db8::1]'"); + } + + $parts = parse_url($hostname); + + if ($parts === false || !isset($parts['scheme'], $parts['host']) || $parts['host'] === '') { + throw new InvalidArgumentException("Invalid hostname '$hostname', expected e.g. 'https://panel.example.com'"); + } + + $scheme = strtolower($parts['scheme']); + if ($scheme !== 'http' && $scheme !== 'https') { + throw new InvalidArgumentException("Invalid hostname '$hostname', the scheme must be http or https"); + } + + if (isset($parts['port'])) { + throw new InvalidArgumentException("Invalid hostname '$hostname', set the port with the port argument instead"); + } + + if (isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment']) || trim($parts['path'] ?? '', '/') !== '') { + throw new InvalidArgumentException("Invalid hostname '$hostname', it must contain only the scheme and host"); + } + + return $scheme . '://' . strtolower($parts['host']); + } } diff --git a/src/HestiaCP/Client.php b/src/HestiaCP/Client.php index e080417..3d43172 100644 --- a/src/HestiaCP/Client.php +++ b/src/HestiaCP/Client.php @@ -1,55 +1,70 @@ , array> */ + private array $modules = []; /** * Client constructor. + * + * Options: + * - timeout (float) Request timeout in seconds, 0 = no timeout (default 10) + * - verify (bool|string) Verify the TLS certificate, or a path to a CA bundle (default true). + * Set to false for a server with a self-signed certificate. + * - handler (callable) Custom Guzzle handler stack (middleware, testing) + * * @param Host $host - * @param array|null $options + * @param ClientOptions|null $options * @throws ClientException */ - public function __construct(Host $host, array $options = null) { + public function __construct(Host $host, ?array $options = null) { $this->setHost($host); $this->prepareCommunication($options); } /** - * @param string $hostname Hostname - * @param string $user Username or API Key - * @param mixed $password Password (must be null if using API Key) + * @param string $hostname Scheme and host, e.g. https://panel.example.com + * @param string $user Username or API Key + * @param string|null $password Password (must be null if using API Key) + * @param ClientOptions|null $options See Client::__construct() * @return Client * @throws ClientException + * @throws InvalidArgumentException */ - public static function simpleFactory(string $hostname, string $user, string $password = null): Client { + public static function simpleFactory(string $hostname, string $user, ?string $password = null, ?array $options = null): Client { $credentials = new Credentials($user, $password); $host = new Host($hostname, $credentials); - return new self($host); + return new self($host, $options); } - public function setHost(Host $host) { + public function setHost(Host $host): static { $this->host = $host; return $this; } @@ -74,13 +89,9 @@ public function testAuthorization(): bool { * @throws ClientException * @throws ProcessException */ - public function send(ICommand $command) { + public function send(ICommand $command): mixed { $host = $this->getHost(); try { - $timeout = $this->guzzleClient->getConfig('timeout'); - if ($command instanceof LetsEncryptDomain) { - $timeout = 90; - } $response = $this->guzzleClient->post($host->buildUri(), [ 'form_params' => array_merge( $host->getCredentials()->getRequestParams(), @@ -90,29 +101,46 @@ public function send(ICommand $command) { 'cmd' => $command->getName() ] ), - 'verify' => false, - 'timeout' => $timeout + 'timeout' => $this->getTimeout($command) ]); $command->setLastResponse($response); return $command->process(); } catch (ProcessException $e) { throw $e; - } catch (\GuzzleHttp\Exception\ClientException $e) { - return $command->processException($e); + } catch (\GuzzleHttp\Exception\GuzzleException $e) { + throw new ClientException('Connection error (' . $e->getMessage() . ')', 0, $e); } catch (\Exception $e) { - throw new ClientException('Fatal processing error (' . $e->getMessage() . ')'); + throw new ClientException('Fatal processing error (' . $e->getMessage() . ')', 0, $e); + } + } + + /** + * A command may need a longer timeout than the client default, but never a shorter one. + */ + private function getTimeout(ICommand $command): float { + $timeout = (float) $this->guzzleClient->getConfig('timeout'); + $commandTimeout = $command->getTimeout(); + + if ($commandTimeout === null || $timeout == 0) { + return $timeout; } + + return $commandTimeout == 0 ? 0 : max($timeout, $commandTimeout); } /** - * @param array|null $options + * @param ClientOptions|null $options * @return Client * @throws ClientException */ - private function prepareCommunication(array $options = null): Client { + private function prepareCommunication(?array $options = null): Client { try { $this->guzzleClient = new \GuzzleHttp\Client([ - 'timeout' => $options['timeout'] ?? 10.0 + 'timeout' => $options['timeout'] ?? 10.0, + 'verify' => $options['verify'] ?? true, + 'handler' => $options['handler'] ?? null, + // HestiaCP reports failures with HTTP error codes, the commands handle them + 'http_errors' => false ]); } catch (\Exception $e) { throw new ClientException('Bad Client configuration (' . $e->getMessage() . ')'); @@ -121,16 +149,24 @@ private function prepareCommunication(array $options = null): Client { return $this; } - private function loadModule($moduleName, $param = null) { - if (!isset($this->modules[$moduleName])) { + /** + * @template T of Module + * @param class-string $moduleName + * @return T + */ + private function loadModule(string $moduleName, ?string $param = null): Module { + // Modules bound to a user are cached per user, otherwise one user's module would be reused for another + $key = $param ?? ''; + + if (!isset($this->modules[$moduleName][$key])) { if ($param !== null) { - $this->modules[$moduleName] = new $moduleName($this, $param); + $this->modules[$moduleName][$key] = new $moduleName($this, $param); } else { - $this->modules[$moduleName] = new $moduleName($this); + $this->modules[$moduleName][$key] = new $moduleName($this); } } - return $this->modules[$moduleName]; + return $this->modules[$moduleName][$key]; } public function getModuleUser(): Users { diff --git a/src/HestiaCP/Command/Add/Database.php b/src/HestiaCP/Command/Add/Database.php index 2945fb1..cf2463f 100644 --- a/src/HestiaCP/Command/Add/Database.php +++ b/src/HestiaCP/Command/Add/Database.php @@ -1,32 +1,29 @@ user = $user; $this->database = $database; $this->dbuser = $dbuser; $this->dbpass = $dbpass; - $this->type = $type; + $this->type = $type; } public function getName(): string { @@ -39,7 +36,7 @@ public function getRequestParams(): array { self::ARG_2 => $this->database, self::ARG_3 => $this->dbuser, self::ARG_4 => $this->dbpass, - self::ARG_5 => $this->type + self::ARG_5 => $this->type ]; } } diff --git a/src/HestiaCP/Command/Add/LetsEncryptDomain.php b/src/HestiaCP/Command/Add/LetsEncryptDomain.php index 8ffea33..0af7121 100644 --- a/src/HestiaCP/Command/Add/LetsEncryptDomain.php +++ b/src/HestiaCP/Command/Add/LetsEncryptDomain.php @@ -1,40 +1,71 @@ user = $user; $this->domain = $domain; $this->aliases = $aliases; - $this->restart = $restart; + $this->mail = $mail; + $this->keyAlgo = $keyAlgo; } public function getName(): string { return 'v-add-letsencrypt-domain'; } + public function getTimeout(): ?float { + return 90; + } + public function getRequestParams(): array { - return [ + $params = [ self::ARG_1 => $this->user, self::ARG_2 => $this->domain, self::ARG_3 => $this->aliases ?: '', - self::ARG_4 => $this->convertBool($this->restart), + self::ARG_4 => $this->convertBool($this->mail), ]; + + if ($this->keyAlgo !== null) { + $params[self::ARG_5] = $this->keyAlgo; + } + + return $params; } } diff --git a/src/HestiaCP/Command/Add/MailAccount.php b/src/HestiaCP/Command/Add/MailAccount.php index b029093..54b3b23 100644 --- a/src/HestiaCP/Command/Add/MailAccount.php +++ b/src/HestiaCP/Command/Add/MailAccount.php @@ -1,22 +1,20 @@ user = $user; diff --git a/src/HestiaCP/Command/Add/MailDomain.php b/src/HestiaCP/Command/Add/MailDomain.php index eaccb15..222ef11 100644 --- a/src/HestiaCP/Command/Add/MailDomain.php +++ b/src/HestiaCP/Command/Add/MailDomain.php @@ -1,25 +1,22 @@ user = $user; diff --git a/src/HestiaCP/Command/Add/User.php b/src/HestiaCP/Command/Add/User.php index cd153c2..bd5acb9 100644 --- a/src/HestiaCP/Command/Add/User.php +++ b/src/HestiaCP/Command/Add/User.php @@ -1,28 +1,32 @@ user = $user; $this->password = $password; $this->email = $email; $this->package = $package; + $this->name = $name; + $this->lastname = $lastname; } public function getName(): string { @@ -30,11 +34,22 @@ public function getName(): string { } public function getRequestParams(): array { - return [ + // HestiaCP passes the arguments by position, so every argument before the last one set has to be sent + $params = [ self::ARG_1 => $this->user, self::ARG_2 => $this->password, self::ARG_3 => $this->email, - self::ARG_4 => $this->package + self::ARG_4 => $this->package ?? 'default' ]; + + if ($this->name !== null || $this->lastname !== null) { + $params[self::ARG_5] = $this->name ?? ''; + } + + if ($this->lastname !== null) { + $params[self::ARG_6] = $this->lastname; + } + + return $params; } } diff --git a/src/HestiaCP/Command/Add/WebDomain.php b/src/HestiaCP/Command/Add/WebDomain.php index f9ee595..b6ce460 100644 --- a/src/HestiaCP/Command/Add/WebDomain.php +++ b/src/HestiaCP/Command/Add/WebDomain.php @@ -1,30 +1,26 @@ user = $user; $this->domain = $domain; $this->ip = $ip; diff --git a/src/HestiaCP/Command/Add/WebDomainFtp.php b/src/HestiaCP/Command/Add/WebDomainFtp.php index c9531a7..800396a 100644 --- a/src/HestiaCP/Command/Add/WebDomainFtp.php +++ b/src/HestiaCP/Command/Add/WebDomainFtp.php @@ -1,27 +1,24 @@ user = $user; $this->domain = $domain; $this->ftpUser = $ftpUser; diff --git a/src/HestiaCP/Command/Backup/User.php b/src/HestiaCP/Command/Backup/User.php index 93b5d23..00bcdc4 100644 --- a/src/HestiaCP/Command/Backup/User.php +++ b/src/HestiaCP/Command/Backup/User.php @@ -1,30 +1,35 @@ user = $user; + $this->notify = $notify; + } - public function __construct(string $user, bool $notify = true) { - $this->user = $user; - $this->notify = $notify; - } + public function getName(): string { + return 'v-backup-user'; + } - public function getName(): string { - return 'v-backup-user'; - } + public function getTimeout(): ?float { + // the backup runs synchronously and can take a long time + return 0; + } - public function getRequestParams(): array { - return [ - self::ARG_1 => $this->user, - self::ARG_2 => $this->convertBool($this->notify) - ]; - } + public function getRequestParams(): array { + return [ + self::ARG_1 => $this->user, + self::ARG_2 => $this->convertBool($this->notify) + ]; + } } diff --git a/src/HestiaCP/Command/Backup/Users.php b/src/HestiaCP/Command/Backup/Users.php index 9ea8644..70f5736 100644 --- a/src/HestiaCP/Command/Backup/Users.php +++ b/src/HestiaCP/Command/Backup/Users.php @@ -1,18 +1,23 @@ self::FORMAT_JSON - ]; + return []; } } diff --git a/src/HestiaCP/Command/Change/MailAccountPassword.php b/src/HestiaCP/Command/Change/MailAccountPassword.php index 4f11ee2..9776181 100644 --- a/src/HestiaCP/Command/Change/MailAccountPassword.php +++ b/src/HestiaCP/Command/Change/MailAccountPassword.php @@ -1,22 +1,20 @@ user = $user; diff --git a/src/HestiaCP/Command/Change/UserPassword.php b/src/HestiaCP/Command/Change/UserPassword.php index 67a832b..87ef5e4 100644 --- a/src/HestiaCP/Command/Change/UserPassword.php +++ b/src/HestiaCP/Command/Change/UserPassword.php @@ -1,16 +1,16 @@ user = $user; diff --git a/src/HestiaCP/Command/Change/WebDomainFtpPassword.php b/src/HestiaCP/Command/Change/WebDomainFtpPassword.php index 33ee6fb..de31b28 100644 --- a/src/HestiaCP/Command/Change/WebDomainFtpPassword.php +++ b/src/HestiaCP/Command/Change/WebDomainFtpPassword.php @@ -1,22 +1,20 @@ user = $user; diff --git a/src/HestiaCP/Command/Change/WebDomainFtpPath.php b/src/HestiaCP/Command/Change/WebDomainFtpPath.php index a9d2e32..b20b917 100644 --- a/src/HestiaCP/Command/Change/WebDomainFtpPath.php +++ b/src/HestiaCP/Command/Change/WebDomainFtpPath.php @@ -1,22 +1,20 @@ user = $user; diff --git a/src/HestiaCP/Command/Command.php b/src/HestiaCP/Command/Command.php index 39088c6..1b2e2bd 100644 --- a/src/HestiaCP/Command/Command.php +++ b/src/HestiaCP/Command/Command.php @@ -1,11 +1,12 @@ getMessage()); - } + private ?int $lastStatusCode = null; + + private ?int $lastExitCode = null; public function setLastResponse(ResponseInterface $response): void { - $this->lastResponse = $response->getBody()->getContents(); + $this->lastResponse = (string) $response->getBody(); + $this->lastStatusCode = $response->getStatusCode(); + + $exitCode = $response->getHeaderLine('Hestia-Exit-Code'); + $this->lastExitCode = preg_match('~^\d+$~', $exitCode) ? (int) $exitCode : null; } public function getResponseText(): ?string { return $this->lastResponse; } + public function getStatusCode(): ?int { + return $this->lastStatusCode; + } + + /** + * Request timeout in seconds for this command (0 = no timeout), or null to use the client timeout. + */ + public function getTimeout(): ?float { + return null; + } + /** * @return int * @throws InvalidResponseException */ public function getResponseCode(): int { - $responseText = $this->getResponseText(); + if ($this->lastExitCode !== null) { + return $this->lastExitCode; + } + + $responseText = trim((string) $this->getResponseText()); - if (!preg_match('~^[\d]+$~', $responseText)) { + if (!preg_match('~^\d+$~', $responseText)) { throw new InvalidResponseException('Response is not code. Is hostname really HestiaCP?'); } @@ -53,21 +69,42 @@ public function getResponseCode(): int { /** * @throws AuthorizationException + * @throws ProcessException */ public function defaultProcess(): void { - $responseText = $this->getResponseText(); + $responseText = (string) $this->getResponseText(); + + if (strpos($responseText, 'Error: authentication failed') === 0) { + throw new AuthorizationException('Authorization failed! Bad user or password', ResponseCode::E_PASSWORD); + } - if ($responseText === 'Error: authentication failed') { - throw new AuthorizationException('Authorization failed! Bad user or password'); + // HestiaCP sends the command exit code in this header and an HTTP error status when it fails + if ($this->lastExitCode !== null) { + if ($this->lastExitCode !== ResponseCode::OK) { + throw $this->codeException($this->lastExitCode); + } + } elseif ($this->lastStatusCode !== null && $this->lastStatusCode >= 400) { + throw new InvalidResponseException('Unexpected HTTP status ' . $this->lastStatusCode . '. Is hostname really HestiaCP?'); } } /** - * @param $code - * @throws ProcessException + * Builds the exception for a non-zero HestiaCP exit code. */ - protected function throwCodeException($code): void { - throw new ProcessException(ResponseCode::$messages[$code]); + protected function codeException(int $code): ProcessException { + $message = ResponseCode::getMessage($code); + + // with returncode=no the body holds HestiaCP's own error message + $detail = trim((string) $this->getResponseText()); + if ($detail !== '' && !preg_match('~^\d+$~', $detail)) { + $message .= ' (' . $detail . ')'; + } + + if ($code === ResponseCode::E_PASSWORD || $code === ResponseCode::E_FORBIDDEN) { + return new AuthorizationException($message, $code); + } + + return new ProcessException($message, $code); } /** diff --git a/src/HestiaCP/Command/Delete/Database.php b/src/HestiaCP/Command/Delete/Database.php index c3cd63b..eb893a3 100644 --- a/src/HestiaCP/Command/Delete/Database.php +++ b/src/HestiaCP/Command/Delete/Database.php @@ -1,16 +1,16 @@ user = $user; diff --git a/src/HestiaCP/Command/Delete/Databases.php b/src/HestiaCP/Command/Delete/Databases.php index c4ba465..45ab345 100644 --- a/src/HestiaCP/Command/Delete/Databases.php +++ b/src/HestiaCP/Command/Delete/Databases.php @@ -1,13 +1,14 @@ user = $user; diff --git a/src/HestiaCP/Command/Delete/LetsEncryptDomain.php b/src/HestiaCP/Command/Delete/LetsEncryptDomain.php index a3b8139..99f16d4 100644 --- a/src/HestiaCP/Command/Delete/LetsEncryptDomain.php +++ b/src/HestiaCP/Command/Delete/LetsEncryptDomain.php @@ -1,19 +1,18 @@ user = $user; diff --git a/src/HestiaCP/Command/Delete/MailAccount.php b/src/HestiaCP/Command/Delete/MailAccount.php index af653ea..70b97d6 100644 --- a/src/HestiaCP/Command/Delete/MailAccount.php +++ b/src/HestiaCP/Command/Delete/MailAccount.php @@ -1,19 +1,18 @@ user = $user; diff --git a/src/HestiaCP/Command/Delete/MailDomain.php b/src/HestiaCP/Command/Delete/MailDomain.php index c0990b1..e57843e 100644 --- a/src/HestiaCP/Command/Delete/MailDomain.php +++ b/src/HestiaCP/Command/Delete/MailDomain.php @@ -1,16 +1,16 @@ user = $user; diff --git a/src/HestiaCP/Command/Delete/User.php b/src/HestiaCP/Command/Delete/User.php index 1980592..702db22 100644 --- a/src/HestiaCP/Command/Delete/User.php +++ b/src/HestiaCP/Command/Delete/User.php @@ -1,13 +1,14 @@ user = $user; diff --git a/src/HestiaCP/Command/Delete/UserBackup.php b/src/HestiaCP/Command/Delete/UserBackup.php index b1dac8d..c1bf5b5 100644 --- a/src/HestiaCP/Command/Delete/UserBackup.php +++ b/src/HestiaCP/Command/Delete/UserBackup.php @@ -1,20 +1,20 @@ user = $user; - $this->backup = $backup; + $this->backup = $backup; } public function getName(): string { @@ -24,7 +24,7 @@ public function getName(): string { public function getRequestParams(): array { return [ self::ARG_1 => $this->user, - self::ARG_2 => $this->backup + self::ARG_2 => $this->backup ]; } } diff --git a/src/HestiaCP/Command/Delete/UserBackupExclusions.php b/src/HestiaCP/Command/Delete/UserBackupExclusions.php index 81e20c6..fd15145 100644 --- a/src/HestiaCP/Command/Delete/UserBackupExclusions.php +++ b/src/HestiaCP/Command/Delete/UserBackupExclusions.php @@ -1,22 +1,24 @@ user = $user; + $this->system = $system; } public function getName(): string { @@ -24,8 +26,14 @@ public function getName(): string { } public function getRequestParams(): array { - return [ + $params = [ self::ARG_1 => $this->user ]; + + if ($this->system !== null) { + $params[self::ARG_2] = $this->system; + } + + return $params; } } diff --git a/src/HestiaCP/Command/Delete/WebDomain.php b/src/HestiaCP/Command/Delete/WebDomain.php index 6e21fbc..dc04cb3 100644 --- a/src/HestiaCP/Command/Delete/WebDomain.php +++ b/src/HestiaCP/Command/Delete/WebDomain.php @@ -1,16 +1,16 @@ user = $user; diff --git a/src/HestiaCP/Command/Delete/WebDomainFtp.php b/src/HestiaCP/Command/Delete/WebDomainFtp.php index 029e1a7..22b1765 100644 --- a/src/HestiaCP/Command/Delete/WebDomainFtp.php +++ b/src/HestiaCP/Command/Delete/WebDomainFtp.php @@ -1,19 +1,18 @@ user = $user; diff --git a/src/HestiaCP/Command/ICommand.php b/src/HestiaCP/Command/ICommand.php index a8924ab..f3f13c3 100644 --- a/src/HestiaCP/Command/ICommand.php +++ b/src/HestiaCP/Command/ICommand.php @@ -1,8 +1,9 @@ + */ public function getRequestParams(): array; } diff --git a/src/HestiaCP/Command/ListCommand.php b/src/HestiaCP/Command/ListCommand.php index 732c46e..2807950 100644 --- a/src/HestiaCP/Command/ListCommand.php +++ b/src/HestiaCP/Command/ListCommand.php @@ -1,12 +1,12 @@ * @throws ProcessException * @throws AuthorizationException */ - public function process() { + public function process(): array { parent::defaultProcess(); - $responseJson = $this->getResponseText(); + $responseJson = trim((string) $this->getResponseText()); + + // HestiaCP answers with an empty body (HTTP 204) when a command prints nothing + if ($responseJson === '') { + return []; + } + try { - $response = Json::decode($responseJson); - } catch (JsonException $e) { + $response = json_decode($responseJson, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new ProcessException($responseJson, 0, $e); + } + + if (!is_array($response)) { throw new ProcessException($responseJson); } - // convert response to array - return $this->convertList((array)$response); + return $response; } - protected function convertList(array $list): array { - foreach ($list as $key => $item) { - $list[$key] = ArrayHash::from((array) $item); - } + /** + * Returns the only item of a single-object list, e.g. {"admin": {...}}. + * + * @param array $list + * @return array + * @throws InvalidResponseException + */ + protected function convertDetail(array $list): array { + $detail = array_shift($list); - return $list; - } + if (!is_array($detail)) { + throw new InvalidResponseException('Empty response'); + } - protected function convertDetail(array $list): ArrayHash { - return array_shift($list); + return $detail; } } diff --git a/src/HestiaCP/Command/Lists/Database.php b/src/HestiaCP/Command/Lists/Database.php index dfd7c03..14a56b2 100644 --- a/src/HestiaCP/Command/Lists/Database.php +++ b/src/HestiaCP/Command/Lists/Database.php @@ -1,35 +1,34 @@ username = $username; - $this->database = $database; + $this->database = $database; } public function getName(): string { return 'v-list-database'; } - public function process(): ArrayHash { + public function process(): array { return $this->convertDetail(parent::process()); } public function getRequestParams(): array { return [ self::ARG_1 => $this->username, - self::ARG_2 => $this->database, + self::ARG_2 => $this->database, self::ARG_3 => self::FORMAT_JSON ]; } diff --git a/src/HestiaCP/Command/Lists/Databases.php b/src/HestiaCP/Command/Lists/Databases.php index 127bb28..660939d 100644 --- a/src/HestiaCP/Command/Lists/Databases.php +++ b/src/HestiaCP/Command/Lists/Databases.php @@ -1,14 +1,14 @@ username = $username; @@ -18,10 +18,6 @@ public function getName(): string { return 'v-list-databases'; } - public function process(): ArrayHash { - return $this->convertDetail(parent::process()); - } - public function getRequestParams(): array { return [ self::ARG_1 => $this->username, diff --git a/src/HestiaCP/Command/Lists/MailAccounts.php b/src/HestiaCP/Command/Lists/MailAccounts.php index da83440..b121570 100644 --- a/src/HestiaCP/Command/Lists/MailAccounts.php +++ b/src/HestiaCP/Command/Lists/MailAccounts.php @@ -1,16 +1,16 @@ user = $user; diff --git a/src/HestiaCP/Command/Lists/MailDomainDkim.php b/src/HestiaCP/Command/Lists/MailDomainDkim.php index 3758442..e0c606b 100644 --- a/src/HestiaCP/Command/Lists/MailDomainDkim.php +++ b/src/HestiaCP/Command/Lists/MailDomainDkim.php @@ -1,16 +1,16 @@ user = $user; @@ -21,11 +21,6 @@ public function getName(): string { return 'v-list-mail-domain-dkim'; } - public function getResponseText(): string { - // bug, issue 1827 ( https://github.com/serghey-rodin/vesta/issues/1827 ) - return preg_replace('~\,\s*\}~', '}', parent::getResponseText()); - } - public function getRequestParams(): array { return [ self::ARG_1 => $this->user, diff --git a/src/HestiaCP/Command/Lists/MailDomainDkimDns.php b/src/HestiaCP/Command/Lists/MailDomainDkimDns.php index bd19ce3..1c86563 100644 --- a/src/HestiaCP/Command/Lists/MailDomainDkimDns.php +++ b/src/HestiaCP/Command/Lists/MailDomainDkimDns.php @@ -1,16 +1,16 @@ user = $user; @@ -21,11 +21,6 @@ public function getName(): string { return 'v-list-mail-domain-dkim-dns'; } - public function getResponseText(): string { - // bug, issue 1620 ( https://github.com/serghey-rodin/vesta/issues/1620 ) - return preg_replace('~\\n~', '', parent::getResponseText()); - } - public function getRequestParams(): array { return [ self::ARG_1 => $this->user, diff --git a/src/HestiaCP/Command/Lists/MailDomains.php b/src/HestiaCP/Command/Lists/MailDomains.php index d0d0c16..d990733 100644 --- a/src/HestiaCP/Command/Lists/MailDomains.php +++ b/src/HestiaCP/Command/Lists/MailDomains.php @@ -1,13 +1,14 @@ user = $user; diff --git a/src/HestiaCP/Command/Lists/User.php b/src/HestiaCP/Command/Lists/User.php index 07ee07a..5c19ff9 100644 --- a/src/HestiaCP/Command/Lists/User.php +++ b/src/HestiaCP/Command/Lists/User.php @@ -1,14 +1,14 @@ username = $username; @@ -18,7 +18,7 @@ public function getName(): string { return 'v-list-user'; } - public function process(): ArrayHash { + public function process(): array { return $this->convertDetail(parent::process()); } diff --git a/src/HestiaCP/Command/Lists/UserBackup.php b/src/HestiaCP/Command/Lists/UserBackup.php index 5fba325..a259de0 100644 --- a/src/HestiaCP/Command/Lists/UserBackup.php +++ b/src/HestiaCP/Command/Lists/UserBackup.php @@ -1,36 +1,35 @@ user = $user; - $this->backup = $backup; - } + public function __construct(string $user, string $backup) { + $this->user = $user; + $this->backup = $backup; + } - public function getName(): string { - return 'v-list-user-backup'; - } + public function getName(): string { + return 'v-list-user-backup'; + } - public function process(): ArrayHash { + public function process(): array { return $this->convertDetail(parent::process()); } - public function getRequestParams(): array { - return [ - self::ARG_1 => $this->user, - self::ARG_2 => $this->backup, - self::ARG_3 => self::FORMAT_JSON - ]; - } + public function getRequestParams(): array { + return [ + self::ARG_1 => $this->user, + self::ARG_2 => $this->backup, + self::ARG_3 => self::FORMAT_JSON + ]; + } } diff --git a/src/HestiaCP/Command/Lists/UserBackupExclusions.php b/src/HestiaCP/Command/Lists/UserBackupExclusions.php index ebc3121..910191e 100644 --- a/src/HestiaCP/Command/Lists/UserBackupExclusions.php +++ b/src/HestiaCP/Command/Lists/UserBackupExclusions.php @@ -1,26 +1,27 @@ user = $user; - } + public function __construct(string $user) { + $this->user = $user; + } - public function getName(): string { - return 'v-list-user-backup-exclusions'; - } + public function getName(): string { + return 'v-list-user-backup-exclusions'; + } - public function getRequestParams(): array { - return [ - self::ARG_1 => $this->user, - self::ARG_2 => self::FORMAT_JSON - ]; - } + public function getRequestParams(): array { + return [ + self::ARG_1 => $this->user, + self::ARG_2 => self::FORMAT_JSON + ]; + } } diff --git a/src/HestiaCP/Command/Lists/UserBackups.php b/src/HestiaCP/Command/Lists/UserBackups.php index 41627ec..56d86df 100644 --- a/src/HestiaCP/Command/Lists/UserBackups.php +++ b/src/HestiaCP/Command/Lists/UserBackups.php @@ -1,26 +1,27 @@ user = $user; - } + public function __construct(string $user) { + $this->user = $user; + } - public function getName(): string { - return 'v-list-user-backups'; - } + public function getName(): string { + return 'v-list-user-backups'; + } - public function getRequestParams(): array { - return [ - self::ARG_1 => $this->user, - self::ARG_2 => self::FORMAT_JSON - ]; - } + public function getRequestParams(): array { + return [ + self::ARG_1 => $this->user, + self::ARG_2 => self::FORMAT_JSON + ]; + } } diff --git a/src/HestiaCP/Command/Lists/Users.php b/src/HestiaCP/Command/Lists/Users.php index d8416a8..de0e84f 100644 --- a/src/HestiaCP/Command/Lists/Users.php +++ b/src/HestiaCP/Command/Lists/Users.php @@ -1,5 +1,7 @@ user = $user; diff --git a/src/HestiaCP/Command/ProcessCommand.php b/src/HestiaCP/Command/ProcessCommand.php index cea4322..af4cbfd 100644 --- a/src/HestiaCP/Command/ProcessCommand.php +++ b/src/HestiaCP/Command/ProcessCommand.php @@ -1,10 +1,11 @@ getResponseCode() === 0) { + $code = $this->getResponseCode(); + if ($code === ResponseCode::OK) { return true; } - $this->throwCodeException($this->getResponseCode()); + throw $this->codeException($code); } } diff --git a/src/HestiaCP/Command/ResponseCode.php b/src/HestiaCP/Command/ResponseCode.php index 23bf696..f302057 100644 --- a/src/HestiaCP/Command/ResponseCode.php +++ b/src/HestiaCP/Command/ResponseCode.php @@ -1,5 +1,7 @@ 'Command has been successfuly performed', + /** @var array */ + public static array $messages = [ + self::OK => 'Command has been successfully performed', self::E_ARGS => 'Not enough arguments provided', self::E_INVALID => 'Object or argument is not valid', self::E_NOTEXIST => 'Object doesn\'t exist', @@ -37,16 +43,21 @@ class ResponseCode { self::E_INUSE => 'Object can\'t be deleted because is used by the other object', self::E_LIMIT => 'Object cannot be created because of hosting package limits', self::E_PASSWORD => 'Wrong password', - self::E_FORBIDEN => 'Object cannot be accessed be the user', + self::E_FORBIDDEN => 'Object cannot be accessed by the user', self::E_DISABLED => 'Subsystem is disabled', self::E_PARSING => 'Configuration is broken', self::E_DISK => 'Not enough disk space to complete the action', - self::E_LA => 'Server is to busy to complete the action', + self::E_LA => 'Server is too busy to complete the action', self::E_CONNECT => 'Connection failed. Host is unreachable', self::E_FTP => 'FTP server is not responding', self::E_DB => 'Database server is not responding', self::E_RRD => 'RRDtool failed to update the database', self::E_UPDATE => 'Update operation failed', - self::E_RESTART => 'Service restart failed' + self::E_RESTART => 'Service restart failed', + self::E_BACKUP => 'Backup failed, or API access is disabled for this user' ]; + + public static function getMessage(int $code): string { + return self::$messages[$code] ?? 'Unknown error (exit code ' . $code . ')'; + } } diff --git a/src/HestiaCP/Command/Suspend/MailAccount.php b/src/HestiaCP/Command/Suspend/MailAccount.php index 9f6a839..d842fd7 100644 --- a/src/HestiaCP/Command/Suspend/MailAccount.php +++ b/src/HestiaCP/Command/Suspend/MailAccount.php @@ -1,35 +1,34 @@ user = $user; - $this->domain = $domain; - $this->account = $account; - } + public function __construct(string $user, string $domain, string $account) { + $this->user = $user; + $this->domain = $domain; + $this->account = $account; + } - public function getName(): string { - return 'v-suspend-mail-account'; - } + public function getName(): string { + return 'v-suspend-mail-account'; + } - public function getRequestParams(): array { - return [ - self::ARG_1 => $this->user, - self::ARG_2 => $this->domain, - self::ARG_3 => $this->account - ]; - } + public function getRequestParams(): array { + return [ + self::ARG_1 => $this->user, + self::ARG_2 => $this->domain, + self::ARG_3 => $this->account + ]; + } } diff --git a/src/HestiaCP/Command/Suspend/MailDomain.php b/src/HestiaCP/Command/Suspend/MailDomain.php index d518c6b..60a45fe 100644 --- a/src/HestiaCP/Command/Suspend/MailDomain.php +++ b/src/HestiaCP/Command/Suspend/MailDomain.php @@ -1,30 +1,30 @@ user = $user; - $this->domain = $domain; - } + public function __construct(string $user, string $domain) { + $this->user = $user; + $this->domain = $domain; + } - public function getName(): string { - return 'v-suspend-mail-domain'; - } + public function getName(): string { + return 'v-suspend-mail-domain'; + } - public function getRequestParams(): array { - return [ - self::ARG_1 => $this->user, - self::ARG_2 => $this->domain - ]; - } + public function getRequestParams(): array { + return [ + self::ARG_1 => $this->user, + self::ARG_2 => $this->domain + ]; + } } diff --git a/src/HestiaCP/Command/Suspend/User.php b/src/HestiaCP/Command/Suspend/User.php index 44ac897..83075ed 100644 --- a/src/HestiaCP/Command/Suspend/User.php +++ b/src/HestiaCP/Command/Suspend/User.php @@ -1,30 +1,30 @@ user = $user; - $this->restart = $restart; - } + public function __construct(string $user, bool $restart = false) { + $this->user = $user; + $this->restart = $restart; + } - public function getName(): string { - return 'v-suspend-user'; - } + public function getName(): string { + return 'v-suspend-user'; + } - public function getRequestParams(): array { - return [ - self::ARG_1 => $this->user, - self::ARG_2 => $this->convertBool($this->restart) - ]; - } + public function getRequestParams(): array { + return [ + self::ARG_1 => $this->user, + self::ARG_2 => $this->convertBool($this->restart) + ]; + } } diff --git a/src/HestiaCP/Command/Suspend/WebDomain.php b/src/HestiaCP/Command/Suspend/WebDomain.php index 75a8fc4..b74498f 100644 --- a/src/HestiaCP/Command/Suspend/WebDomain.php +++ b/src/HestiaCP/Command/Suspend/WebDomain.php @@ -1,35 +1,34 @@ user = $user; - $this->domain = $domain; - $this->restart = $restart; - } + public function __construct(string $user, string $domain, bool $restart = false) { + $this->user = $user; + $this->domain = $domain; + $this->restart = $restart; + } - public function getName(): string { - return 'v-suspend-web-domain'; - } + public function getName(): string { + return 'v-suspend-web-domain'; + } - public function getRequestParams(): array { - return [ - self::ARG_1 => $this->user, - self::ARG_2 => $this->domain, - self::ARG_3 => $this->convertBool($this->restart) - ]; - } + public function getRequestParams(): array { + return [ + self::ARG_1 => $this->user, + self::ARG_2 => $this->domain, + self::ARG_3 => $this->convertBool($this->restart) + ]; + } } diff --git a/src/HestiaCP/Command/TestAuthorization.php b/src/HestiaCP/Command/TestAuthorization.php index 0f61bb1..0ce054a 100644 --- a/src/HestiaCP/Command/TestAuthorization.php +++ b/src/HestiaCP/Command/TestAuthorization.php @@ -1,15 +1,21 @@ getResponseCode(); + if ($code === ResponseCode::OK) { + return true; + } + + throw $this->codeException($code); } catch (AuthorizationException $e) { return false; } - - if ($this->getResponseCode() === 1) { - return true; - } - - throw new InvalidResponseException('Invalid Response. Is host really HestiaCP?'); } public function getRequestParams(): array { diff --git a/src/HestiaCP/Command/Unsuspend/MailAccount.php b/src/HestiaCP/Command/Unsuspend/MailAccount.php index 685d93c..651e214 100644 --- a/src/HestiaCP/Command/Unsuspend/MailAccount.php +++ b/src/HestiaCP/Command/Unsuspend/MailAccount.php @@ -1,35 +1,34 @@ user = $user; - $this->domain = $domain; - $this->account = $account; - } + public function __construct(string $user, string $domain, string $account) { + $this->user = $user; + $this->domain = $domain; + $this->account = $account; + } - public function getName(): string { - return 'v-unsuspend-mail-account'; - } + public function getName(): string { + return 'v-unsuspend-mail-account'; + } - public function getRequestParams(): array { - return [ - self::ARG_1 => $this->user, - self::ARG_2 => $this->domain, - self::ARG_3 => $this->account - ]; - } + public function getRequestParams(): array { + return [ + self::ARG_1 => $this->user, + self::ARG_2 => $this->domain, + self::ARG_3 => $this->account + ]; + } } diff --git a/src/HestiaCP/Command/Unsuspend/MailDomain.php b/src/HestiaCP/Command/Unsuspend/MailDomain.php index 06ef979..bbd15c5 100644 --- a/src/HestiaCP/Command/Unsuspend/MailDomain.php +++ b/src/HestiaCP/Command/Unsuspend/MailDomain.php @@ -1,30 +1,30 @@ user = $user; - $this->domain = $domain; - } + public function __construct(string $user, string $domain) { + $this->user = $user; + $this->domain = $domain; + } - public function getName(): string { - return 'v-unsuspend-mail-domain'; - } + public function getName(): string { + return 'v-unsuspend-mail-domain'; + } - public function getRequestParams(): array { - return [ - self::ARG_1 => $this->user, - self::ARG_2 => $this->domain - ]; - } + public function getRequestParams(): array { + return [ + self::ARG_1 => $this->user, + self::ARG_2 => $this->domain + ]; + } } diff --git a/src/HestiaCP/Command/Unsuspend/User.php b/src/HestiaCP/Command/Unsuspend/User.php index d336ac1..5201fbe 100644 --- a/src/HestiaCP/Command/Unsuspend/User.php +++ b/src/HestiaCP/Command/Unsuspend/User.php @@ -1,30 +1,30 @@ user = $user; - $this->restart = $restart; - } + public function __construct(string $user, bool $restart = false) { + $this->user = $user; + $this->restart = $restart; + } - public function getName(): string { - return 'v-unsuspend-user'; - } + public function getName(): string { + return 'v-unsuspend-user'; + } - public function getRequestParams(): array { - return [ - self::ARG_1 => $this->user, - self::ARG_2 => $this->convertBool($this->restart) - ]; - } + public function getRequestParams(): array { + return [ + self::ARG_1 => $this->user, + self::ARG_2 => $this->convertBool($this->restart) + ]; + } } diff --git a/src/HestiaCP/Command/Unsuspend/WebDomain.php b/src/HestiaCP/Command/Unsuspend/WebDomain.php index 468f8ca..7164b50 100644 --- a/src/HestiaCP/Command/Unsuspend/WebDomain.php +++ b/src/HestiaCP/Command/Unsuspend/WebDomain.php @@ -1,35 +1,34 @@ user = $user; - $this->domain = $domain; - $this->restart = $restart; - } + public function __construct(string $user, string $domain, bool $restart = false) { + $this->user = $user; + $this->domain = $domain; + $this->restart = $restart; + } - public function getName(): string { - return 'v-unsuspend-web-domain'; - } + public function getName(): string { + return 'v-unsuspend-web-domain'; + } - public function getRequestParams(): array { - return [ - self::ARG_1 => $this->user, - self::ARG_2 => $this->domain, - self::ARG_3 => $this->convertBool($this->restart) - ]; - } + public function getRequestParams(): array { + return [ + self::ARG_1 => $this->user, + self::ARG_2 => $this->domain, + self::ARG_3 => $this->convertBool($this->restart) + ]; + } } diff --git a/src/HestiaCP/Exceptions.php b/src/HestiaCP/Exceptions.php deleted file mode 100644 index 91b5dd2..0000000 --- a/src/HestiaCP/Exceptions.php +++ /dev/null @@ -1,17 +0,0 @@ -user = $user; } - /** - * This call is used for backing up user with all its domains and databases. - * - * @param bool $notify - * @return string - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException - */ - public function backup(bool $notify = true): string { - return $this->client->send(new BackupUser($this->user, $notify)); - } + /** + * This call is used for backing up user with all its domains and databases. + * + * @param bool $notify + * @return bool + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException + */ + public function backup(bool $notify = true): bool { + return $this->client->send(new BackupUser($this->user, $notify)); + } - /** - * This function deletes user backup. - * - * @param string $backup - * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException - */ - public function delete(string $backup): bool { - return $this->client->send(new DeleteUserBackup($this->user, $backup)); - } + /** + * This function deletes user backup. + * + * @param string $backup + * @return bool + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException + */ + public function delete(string $backup): bool { + return $this->client->send(new DeleteUserBackup($this->user, $backup)); + } - /** - * This function for deleting backup exclusion - * - * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException - */ - public function deleteExclusions(): bool { - return $this->client->send(new DeleteUserBackupExclusions($this->user)); - } + /** + * This function for deleting backup exclusion + * + * @param string|null $system Exclusion type to delete (WEB, DNS, MAIL, DB, CRON or USER). Deletes all of them when not set. + * @return bool + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException + */ + public function deleteExclusions(?string $system = null): bool { + return $this->client->send(new DeleteUserBackupExclusions($this->user, $system)); + } - /** - * This function for obtaining the list of available user backups. - * - * @return array - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException - */ - public function listBackups(): array { - return $this->client->send(new UserBackups($this->user)); - } + /** + * This function for obtaining the list of available user backups. + * + * @return array + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException + */ + public function listBackups(): array { + return $this->client->send(new UserBackups($this->user)); + } - /** - * This function of obtaining the list of backup parameters. - * - * @param string $backup - * @return ArrayHash - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException - */ - public function listBackup(string $backup): ArrayHash { - return $this->client->send(new ListsUserBackup($this->user, $backup)); - } + /** + * This function of obtaining the list of backup parameters. + * + * @param string $backup + * @return array + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException + */ + public function listBackup(string $backup): array { + return $this->client->send(new ListsUserBackup($this->user, $backup)); + } - /** - * This function for obtaining the backup exclusion list - * - * @return array - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException - */ - public function listBackupExclusions(): array { - return $this->client->send(new ListsUserBackupExclusions($this->user)); - } -} \ No newline at end of file + /** + * This function for obtaining the backup exclusion list + * + * @return array + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException + */ + public function listBackupExclusions(): array { + return $this->client->send(new ListsUserBackupExclusions($this->user)); + } +} diff --git a/src/HestiaCP/Module/Databases.php b/src/HestiaCP/Module/Databases.php index 9891b6a..0697016 100644 --- a/src/HestiaCP/Module/Databases.php +++ b/src/HestiaCP/Module/Databases.php @@ -1,10 +1,10 @@ user = $user; } - /** - * This function is used for creating a new database. - * - * @param string $database - * @param string $dbuser - * @param string $dbpass - * @param string $type - * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException - */ - public function add(string $database, string $dbuser, string $dbpass, string $type = 'mysql'): bool { - return $this->client->send(new AddDatabase($this->user, $database, $dbuser, $dbpass, $type)); - } + /** + * This function is used for creating a new database. + * + * @param string $database + * @param string $dbuser + * @param string $dbpass + * @param string $type + * @return bool + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException + */ + public function add(string $database, string $dbuser, string $dbpass, string $type = 'mysql'): bool { + return $this->client->send(new AddDatabase($this->user, $database, $dbuser, $dbpass, $type)); + } - /** - * This function is used for delete a specified database. - * - * @param string $database - * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException - */ - public function delete(string $database): bool { - return $this->client->send(new DeleteDatabase($this->user, $database)); - } + /** + * This function is used for delete a specified database. + * + * @param string $database + * @return bool + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException + */ + public function delete(string $database): bool { + return $this->client->send(new DeleteDatabase($this->user, $database)); + } - /** - * This function deletes all user databases. - * - * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException - */ - public function deleteDatabases(): bool { - return $this->client->send(new DeleteDatabases($this->user)); - } + /** + * This function deletes all user databases. + * + * @return bool + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException + */ + public function deleteDatabases(): bool { + return $this->client->send(new DeleteDatabases($this->user)); + } - /** - * This function for obtaining of all database's parameters. - * - * @param string $database - * @return ArrayHash - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException - */ - public function listDatabase(string $database): ArrayHash { - return $this->client->send(new ListDatabase($this->user, $database)); - } + /** + * This function for obtaining of all database's parameters. + * + * @param string $database + * @return array + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException + */ + public function listDatabase(string $database): array { + return $this->client->send(new ListDatabase($this->user, $database)); + } - /** - * This function for obtaining the list of all user's databases. - * - * @param string $database - * @return ArrayHash - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException - */ - public function listDatabases(): ArrayHash { - return $this->client->send(new ListDatabases($this->user)); - } -} \ No newline at end of file + /** + * This function for obtaining the list of all user's databases. + * + * @return array> + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException + */ + public function listDatabases(): array { + return $this->client->send(new ListDatabases($this->user)); + } +} diff --git a/src/HestiaCP/Module/IModule.php b/src/HestiaCP/Module/IModule.php index 4520c52..50519f8 100644 --- a/src/HestiaCP/Module/IModule.php +++ b/src/HestiaCP/Module/IModule.php @@ -1,5 +1,7 @@ > + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function listAccounts(string $domain): array { return $this->client->send(new MailAccounts($this->user, $domain)); @@ -42,13 +42,13 @@ public function listAccounts(string $domain): array { /** * This function add new email account. - * + * * @param string $domain * @param string $account * @param string $password * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function addAccount(string $domain, string $account, string $password): bool { return $this->client->send(new MailAccount($this->user, $domain, $account, $password)); @@ -56,13 +56,13 @@ public function addAccount(string $domain, string $account, string $password): b /** * This function changes email account password. - * + * * @param string $domain * @param string $account * @param string $password * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function changeAccountPassword(string $domain, string $account, string $password): bool { return $this->client->send(new MailAccountPassword($this->user, $domain, $account, $password)); @@ -70,38 +70,38 @@ public function changeAccountPassword(string $domain, string $account, string $p /** * This function suspends mail account. - * + * * @param string $domain * @param string $account * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function suspendAccount(string $domain, string $account): bool { - return $this->client->send(new SuspendMailAccount($this->user, $domain, $account)); + return $this->client->send(new SuspendMailAccount($this->user, $domain, $account)); } /** * This function unsuspends mail account. - * + * * @param string $domain * @param string $account * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function unsuspendAccount(string $domain, string $account): bool { - return $this->client->send(new UnsuspendMailAccount($this->user, $domain, $account)); + return $this->client->send(new UnsuspendMailAccount($this->user, $domain, $account)); } /** * This function deletes email account. - * + * * @param string $domain * @param string $account * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function deleteAccount(string $domain, string $account): bool { return $this->client->send(new DeleteMailAccount($this->user, $domain, $account)); @@ -109,14 +109,14 @@ public function deleteAccount(string $domain, string $account): bool { /** * This function adds MAIL domain. - * + * * @param string $domain * @param bool $antispam * @param bool $antivirus * @param bool $dkim * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function addDomain(string $domain, bool $antispam = true, bool $antivirus = true, bool $dkim = true): bool { return $this->client->send(new MailDomain($this->user, $domain, $antispam, $antivirus, $dkim)); @@ -124,11 +124,11 @@ public function addDomain(string $domain, bool $antispam = true, bool $antivirus /** * This function suspends mail domain. - * + * * @param string $domain * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function suspendDomain(string $domain): bool { return $this->client->send(new SuspendMailDomain($this->user, $domain)); @@ -136,11 +136,11 @@ public function suspendDomain(string $domain): bool { /** * This function unsuspends mail domain. - * + * * @param string $domain * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function unsuspendDomain(string $domain): bool { return $this->client->send(new UnsuspendMailDomain($this->user, $domain)); @@ -148,11 +148,11 @@ public function unsuspendDomain(string $domain): bool { /** * This function for deleting MAIL domain. By deleting it all accounts will also be deleted. - * + * * @param string $domain * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function deleteDomain(string $domain): bool { return $this->client->send(new DeleteMailDomain($this->user, $domain)); @@ -160,11 +160,11 @@ public function deleteDomain(string $domain): bool { /** * This function of obtaining domain dkim files. - * + * * @param string $domain - * @return ArrayHash[] - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @return array> + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function listDomainDkim(string $domain): array { return $this->client->send(new MailDomainDkim($this->user, $domain)); @@ -172,11 +172,11 @@ public function listDomainDkim(string $domain): array { /** * This function of obtaining domain dkim dns records for proper setup. - * + * * @param string $domain - * @return ArrayHash[] - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @return array> + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function listDomainDkimDns(string $domain): array { return $this->client->send(new MailDomainDkimDns($this->user, $domain)); @@ -184,10 +184,10 @@ public function listDomainDkimDns(string $domain): array { /** * This function of obtaining the list of all user domains. - * - * @return ArrayHash[] - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * + * @return array> + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function listDomains(): array { return $this->client->send(new MailDomains($this->user)); diff --git a/src/HestiaCP/Module/Module.php b/src/HestiaCP/Module/Module.php index 2f10656..5be683a 100644 --- a/src/HestiaCP/Module/Module.php +++ b/src/HestiaCP/Module/Module.php @@ -1,13 +1,14 @@ client = $client; diff --git a/src/HestiaCP/Module/Users.php b/src/HestiaCP/Module/Users.php index 6316228..378f894 100644 --- a/src/HestiaCP/Module/Users.php +++ b/src/HestiaCP/Module/Users.php @@ -1,12 +1,13 @@ + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function list(): array { - return $this->client->send(new ListsUsers); + return $this->client->send(new ListsUsers()); } /** * This function to obtain user parameters. - * + * * @param string $user - * @return ArrayHash - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @return array + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ - public function detail(string $user): ArrayHash { + public function detail(string $user): array { return $this->client->send(new User($user)); } /** * This function changes user's password and updates RKEY value. - * + * * @param string $user * @param string $password * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function changePassword(string $user, string $password): bool { return $this->client->send(new UserPassword($user, $password)); @@ -52,27 +53,29 @@ public function changePassword(string $user, string $password): bool { /** * This function creates new user account. - * + * * @param string $user * @param string $password * @param string $email * @param string|null $package + * @param string|null $name + * @param string|null $lastname * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ - public function add(string $user, string $password, string $email, string $package = null): bool { - return $this->client->send(new AddUser($user, $password, $email, $package)); + public function add(string $user, string $password, string $email, ?string $package = null, ?string $name = null, ?string $lastname = null): bool { + return $this->client->send(new AddUser($user, $password, $email, $package, $name, $lastname)); } /** * This function suspends a certain user and all his objects. - * + * * @param string $user * @param bool $restart * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function suspend(string $user, bool $restart = false): bool { return $this->client->send(new SuspendUser($user, $restart)); @@ -80,12 +83,12 @@ public function suspend(string $user, bool $restart = false): bool { /** * This function unsuspends user and all his objects. - * + * * @param string $user * @param bool $restart * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function unsuspend(string $user, bool $restart = false): bool { return $this->client->send(new UnsuspendUser($user, $restart)); @@ -93,11 +96,11 @@ public function unsuspend(string $user, bool $restart = false): bool { /** * This function deletes a certain user and all his resources such as domains, databases, cron jobs, etc. - * + * * @param string $user * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function delete(string $user): bool { return $this->client->send(new DeleteUser($user)); @@ -105,12 +108,12 @@ public function delete(string $user): bool { /** * This function backups all system users. - * - * @return string - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * + * @return bool + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ - public function backup(): string { - return $this->client->send(new BackupUsers); + public function backup(): bool { + return $this->client->send(new BackupUsers()); } } diff --git a/src/HestiaCP/Module/Webs.php b/src/HestiaCP/Module/Webs.php index 38a01bb..66075d4 100644 --- a/src/HestiaCP/Module/Webs.php +++ b/src/HestiaCP/Module/Webs.php @@ -1,8 +1,9 @@ client->send(new WebDomain($this->user, $domain, $ip, $aliases, $proxyExtensions, $restart)); } /** - * This function for suspending the site's operation. - * + * This function for suspending the site's operation. + * * @param string $domain * @param bool $restart * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function suspendDomain(string $domain, bool $restart = false): bool { return $this->client->send(new SuspendWebDomain($this->user, $domain, $restart)); @@ -57,12 +57,12 @@ public function suspendDomain(string $domain, bool $restart = false): bool { /** * This function of unsuspending the domain. - * + * * @param string $domain * @param bool $restart * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function unsuspendDomain(string $domain, bool $restart = false): bool { return $this->client->send(new UnsuspendWebDomain($this->user, $domain, $restart)); @@ -70,11 +70,11 @@ public function unsuspendDomain(string $domain, bool $restart = false): bool { /** * This call of function leads to the removal of domain and all its components (statistics, folders contents, ssl certificates, etc.). - * + * * @param string $domain * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function deleteDomain(string $domain): bool { return $this->client->send(new DeleteWebDomain($this->user, $domain)); @@ -82,26 +82,28 @@ public function deleteDomain(string $domain): bool { /** * This function check and validates domain with Let's Encrypt - * + * * @param string $domain * @param string|null $aliases - * @param bool $restart + * @param bool $mail Issue the certificate for the mail domain instead of the web domain + * @param string|null $keyAlgo One of the LetsEncryptDomain::KEY_ALGO_* constants. Needs a HestiaCP version newer than 1.10.5. * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException + * @throws \neto737\HestiaCP\Exceptions\InvalidArgumentException */ - public function addDomainLetsEncrypt(string $domain, string $aliases = null, bool $restart = false): bool { - return $this->client->send(new LetsEncryptDomain($this->user, $domain, $aliases, $restart)); + public function addDomainLetsEncrypt(string $domain, ?string $aliases = null, bool $mail = false, ?string $keyAlgo = null): bool { + return $this->client->send(new LetsEncryptDomain($this->user, $domain, $aliases, $mail, $keyAlgo)); } /** * This function turns off letsencrypt SSL support for a domain. - * + * * @param string $domain * @param bool $restart * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function deleteDomainLetsEncrypt(string $domain, bool $restart = false): bool { return $this->client->send(new DeleteLetsEncryptDomain($this->user, $domain, $restart)); @@ -109,28 +111,28 @@ public function deleteDomainLetsEncrypt(string $domain, bool $restart = false): /** * This function creates additional ftp account for web domain. - * + * * @param string $domain * @param string $ftpUser * @param string $ftpPassword * @param string|null $ftpPath * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ - public function addDomainFtp(string $domain, string $ftpUser, string $ftpPassword, string $ftpPath = null): bool { + public function addDomainFtp(string $domain, string $ftpUser, string $ftpPassword, ?string $ftpPath = null): bool { return $this->client->send(new WebDomainFtp($this->user, $domain, $ftpUser, $ftpPassword, $ftpPath)); } /** * This function changes ftp user password. - * + * * @param string $domain * @param string $ftpUser * @param string $ftpPassword * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function changeDomainFtpPassword(string $domain, string $ftpUser, string $ftpPassword): bool { return $this->client->send(new WebDomainFtpPassword($this->user, $domain, $ftpUser, $ftpPassword)); @@ -138,13 +140,13 @@ public function changeDomainFtpPassword(string $domain, string $ftpUser, string /** * This function changes ftp user path. - * + * * @param string $domain * @param string $ftpUser * @param string $ftpPath * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function changeDomainFtpPath(string $domain, string $ftpUser, string $ftpPath): bool { return $this->client->send(new WebDomainFtpPath($this->user, $domain, $ftpUser, $ftpPath)); @@ -152,12 +154,12 @@ public function changeDomainFtpPath(string $domain, string $ftpUser, string $ftp /** * This function deletes additional ftp account. - * + * * @param string $domain * @param string $ftpUser * @return bool - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function deleteDomainFtp(string $domain, string $ftpUser): bool { return $this->client->send(new DeleteWebDomainFtp($this->user, $domain, $ftpUser)); @@ -165,10 +167,10 @@ public function deleteDomainFtp(string $domain, string $ftpUser): bool { /** * This function to obtain the list of all user web domains. - * - * @return ArrayHash[] - * @throws \neto737\HestiaCP\ClientException - * @throws \neto737\HestiaCP\ProcessException + * + * @return array> + * @throws \neto737\HestiaCP\Exceptions\ClientException + * @throws \neto737\HestiaCP\Exceptions\ProcessException */ public function listDomains(): array { return $this->client->send(new WebDomains($this->user)); diff --git a/tests/clientSendTest.php b/tests/clientSendTest.php new file mode 100644 index 0000000..464da43 --- /dev/null +++ b/tests/clientSendTest.php @@ -0,0 +1,359 @@ +} + */ +class clientSendTest extends \PHPUnit\Framework\TestCase { + + /** @var array */ + private array $history = []; + + /** + * @param list $responses + * @param ClientOptions $options + */ + private function createClient(array $responses, array $options = []): Client { + $this->history = []; + $stack = HandlerStack::create(new MockHandler($responses)); + $stack->push(Middleware::history($this->history)); + + return Client::simpleFactory('https://someHost', 'ACCESS:SECRET', null, $options + ['handler' => $stack]); + } + + private static function codeResponse(int $code, int $status = 200): Response { + return new Response($status, ['Hestia-Exit-Code' => (string) $code], (string) $code); + } + + public function testRequestFormat(): void { + $client = $this->createClient([self::codeResponse(0)]); + $client->send(new AddUser('user', 'pass', 'mail@domain.com')); + + $request = $this->history[0]['request']; + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('https://somehost:8083/api/', (string) $request->getUri()); + + parse_str((string) $request->getBody(), $params); + $this->assertSame([ + 'hash' => 'ACCESS:SECRET', + 'arg1' => 'user', + 'arg2' => 'pass', + 'arg3' => 'mail@domain.com', + 'arg4' => 'default', + 'returncode' => 'yes', + 'cmd' => 'v-add-user' + ], $params); + } + + public function testRequestFormatWithAccessKey(): void { + $this->history = []; + $stack = HandlerStack::create(new MockHandler([self::codeResponse(0)])); + $stack->push(Middleware::history($this->history)); + + $host = new Host('https://someHost', Credentials::accessKey('ACCESS', 'SECRET')); + $client = new Client($host, ['handler' => $stack]); + $client->send(new AddUser('user', 'pass', 'mail@domain.com')); + + parse_str((string) $this->history[0]['request']->getBody(), $params); + $this->assertSame([ + 'access_key' => 'ACCESS', + 'secret_key' => 'SECRET', + 'arg1' => 'user', + 'arg2' => 'pass', + 'arg3' => 'mail@domain.com', + 'arg4' => 'default', + 'returncode' => 'yes', + 'cmd' => 'v-add-user' + ], $params); + } + + public function testVerifyTlsByDefault(): void { + $client = $this->createClient([self::codeResponse(0)]); + $client->send(new AddUser('user', 'pass', 'mail@domain.com')); + $this->assertTrue($this->history[0]['options']['verify']); + } + + public function testVerifyTlsCanBeDisabled(): void { + $client = $this->createClient([self::codeResponse(0)], ['verify' => false]); + $client->send(new AddUser('user', 'pass', 'mail@domain.com')); + $this->assertFalse($this->history[0]['options']['verify']); + } + + public function testCommandTimeout(): void { + $client = $this->createClient([self::codeResponse(0), self::codeResponse(0), self::codeResponse(0)]); + + $client->send(new AddUser('user', 'pass', 'mail@domain.com')); + $this->assertEquals(10, $this->history[0]['options']['timeout']); + + $client->send(new LetsEncryptDomain('user', 'domain.com')); + $this->assertEquals(90, $this->history[1]['options']['timeout']); + + $client->send(new \neto737\HestiaCP\Command\Backup\User('user')); + $this->assertEquals(0, $this->history[2]['options']['timeout']); + } + + public function testCommandTimeoutNeverShortensClientTimeout(): void { + $client = $this->createClient([self::codeResponse(0)], ['timeout' => 120]); + $client->send(new LetsEncryptDomain('user', 'domain.com')); + $this->assertEquals(120, $this->history[0]['options']['timeout']); + } + + public function testProcessSuccess(): void { + $client = $this->createClient([self::codeResponse(0)]); + $this->assertTrue($client->send(new AddUser('user', 'pass', 'mail@domain.com'))); + } + + public function testProcessSuccessLegacy(): void { + $client = $this->createClient([new Response(200, [], '0')]); + $this->assertTrue($client->send(new AddUser('user', 'pass', 'mail@domain.com'))); + } + + public function testProcessError(): void { + $client = $this->createClient([self::codeResponse(ResponseCode::E_EXISTS, 400)]); + + try { + $client->send(new AddUser('user', 'pass', 'mail@domain.com')); + $this->fail('ProcessException expected'); + } catch (ProcessException $e) { + $this->assertNotInstanceOf(AuthorizationException::class, $e); + $this->assertSame(ResponseCode::E_EXISTS, $e->getCode()); + $this->assertSame('Object already exists', $e->getMessage()); + } + } + + public function testProcessErrorLegacy(): void { + $client = $this->createClient([new Response(200, [], '3')]); + + $this->expectException(ProcessException::class); + $this->expectExceptionCode(ResponseCode::E_NOTEXIST); + $client->send(new AddUser('user', 'pass', 'mail@domain.com')); + } + + public function testUnknownErrorCode(): void { + $client = $this->createClient([self::codeResponse(99, 400)]); + + $this->expectException(ProcessException::class); + $this->expectExceptionMessage('Unknown error (exit code 99)'); + $client->send(new AddUser('user', 'pass', 'mail@domain.com')); + } + + public function testAuthorizationFailure(): void { + $client = $this->createClient([self::codeResponse(ResponseCode::E_PASSWORD, 401)]); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionCode(ResponseCode::E_PASSWORD); + $client->send(new AddUser('user', 'pass', 'mail@domain.com')); + } + + public function testAuthorizationFailureLegacyText(): void { + $client = $this->createClient([ + new Response(401, ['Hestia-Exit-Code' => '9'], 'Error: authentication failed') + ]); + + $this->expectException(AuthorizationException::class); + $client->send(new Users()); + } + + public function testListErrorKeepsHestiaMessage(): void { + $client = $this->createClient([ + new Response(400, ['Hestia-Exit-Code' => '3'], "Error: user someone doesn't exist") + ]); + + $this->expectException(ProcessException::class); + $this->expectExceptionMessage("Object doesn't exist (Error: user someone doesn't exist)"); + $client->send(new User('someone')); + } + + public function testUnexpectedHttpStatus(): void { + $client = $this->createClient([new Response(502, [], 'Bad Gateway')]); + + $this->expectException(InvalidResponseException::class); + $client->send(new Users()); + } + + public function testNotHestiaResponse(): void { + $client = $this->createClient([new Response(200, [], 'Hello')]); + + $this->expectException(InvalidResponseException::class); + $client->send(new AddUser('user', 'pass', 'mail@domain.com')); + } + + public function testConnectionError(): void { + $client = $this->createClient([new ConnectException('Connection refused', new Request('POST', 'test'))]); + + try { + $client->send(new Users()); + $this->fail('ClientException expected'); + } catch (ClientException $e) { + $this->assertInstanceOf(ConnectException::class, $e->getPrevious()); + } + } + + public function testList(): void { + $client = $this->createClient([ + new Response(200, ['Hestia-Exit-Code' => '0'], '{"admin": {"NAME": "System Administrator"}, "user": {"NAME": "User"}}') + ]); + + $users = $client->send(new Users()); + $this->assertSame([ + 'admin' => ['NAME' => 'System Administrator'], + 'user' => ['NAME' => 'User'] + ], $users); + } + + public function testListDetail(): void { + $client = $this->createClient([new Response(200, [], '{"admin": {"NAME": "System Administrator"}}')]); + + $user = $client->send(new User('admin')); + $this->assertSame(['NAME' => 'System Administrator'], $user); + } + + public function testListDetailNotObject(): void { + $client = $this->createClient([new Response(200, [], '{"admin": "System Administrator"}')]); + + $this->expectException(InvalidResponseException::class); + $client->send(new User('admin')); + } + + public function testListNotList(): void { + $client = $this->createClient([new Response(200, [], '"OK"')]); + + $this->expectException(ProcessException::class); + $client->send(new Users()); + } + + public function testListEmpty(): void { + $client = $this->createClient([new Response(204, ['Hestia-Exit-Code' => '0'], '')]); + $this->assertSame([], $client->send(new Users())); + } + + public function testListInvalidJson(): void { + $client = $this->createClient([new Response(200, [], 'not json')]); + + $this->expectException(ProcessException::class); + $this->expectExceptionMessage('not json'); + $client->send(new Users()); + } + + public function testListDatabasesReturnsAll(): void { + $client = $this->createClient([ + new Response(200, [], '{"admin_one": {"DBUSER": "admin_one"}, "admin_two": {"DBUSER": "admin_two"}}') + ]); + + $databases = $client->getModuleDatabase('admin')->listDatabases(); + $this->assertSame(['admin_one', 'admin_two'], array_keys($databases)); + } + + // output of bin/v-list-mail-domain-dkim in HestiaCP 1.10.5, PEM newlines escaped by the script + public function testListDomainDkim(): void { + $body = <<<'JSON' +{ + "domain.com": { + "PEM": "-----BEGIN PRIVATE KEY-----\nMIIEvQ\n-----END PRIVATE KEY-----\n", + "PUB": "-----BEGIN PUBLIC KEY-----\nMIIBIj\n-----END PUBLIC KEY-----\n" + } +} +JSON; + $client = $this->createClient([new Response(200, [], $body)]); + + $dkim = $client->getModuleMail('admin')->listDomainDkim('domain.com'); + $this->assertSame("-----BEGIN PRIVATE KEY-----\nMIIEvQ\n-----END PRIVATE KEY-----\n", $dkim['domain.com']['PEM']); + $this->assertSame("-----BEGIN PUBLIC KEY-----\nMIIBIj\n-----END PUBLIC KEY-----\n", $dkim['domain.com']['PUB']); + } + + // output of bin/v-list-mail-domain-dkim-dns in HestiaCP 1.10.5 + public function testListDomainDkimDns(): void { + $body = <<<'JSON' +{ + + "mail._domainkey": { + "TTL": "3600", + "TXT": "'v=DKIM1; k=rsa; p=MIIBIj'" + } +} +JSON; + $client = $this->createClient([new Response(200, [], $body)]); + + $this->assertSame( + ['mail._domainkey' => ['TTL' => '3600', 'TXT' => "'v=DKIM1; k=rsa; p=MIIBIj'"]], + $client->getModuleMail('admin')->listDomainDkimDns('domain.com') + ); + } + + public function testBackup(): void { + $client = $this->createClient([self::codeResponse(0), self::codeResponse(0)]); + + $this->assertTrue($client->getModuleBackup('admin')->backup()); + $this->assertTrue($client->getModuleUser()->backup()); + + parse_str((string) $this->history[1]['request']->getBody(), $params); + $this->assertSame('v-backup-users', $params['cmd']); + $this->assertArrayNotHasKey('arg1', $params); + } + + public function testAuthorizationValid(): void { + $client = $this->createClient([self::codeResponse(0)]); + $this->assertTrue($client->testAuthorization()); + + parse_str((string) $this->history[0]['request']->getBody(), $params); + $this->assertSame('v-list-sys-info', $params['cmd']); + } + + public function testAuthorizationValidLegacy(): void { + $client = $this->createClient([new Response(200, [], '0')]); + $this->assertTrue($client->testAuthorization()); + } + + public function testAuthorizationInvalid(): void { + $client = $this->createClient([ + self::codeResponse(ResponseCode::E_PASSWORD, 401), + self::codeResponse(ResponseCode::E_FORBIDDEN, 401) + ]); + + $this->assertFalse($client->testAuthorization()); + $this->assertFalse($client->testAuthorization()); + } + + public function testAuthorizationApiDisabled(): void { + $client = $this->createClient([self::codeResponse(ResponseCode::E_DISABLED, 400)]); + + $this->expectException(ProcessException::class); + $this->expectExceptionCode(ResponseCode::E_DISABLED); + $client->testAuthorization(); + } + + public function testAuthorizationNotHestia(): void { + $client = $this->createClient([new Response(200, [], 'Hello')]); + + $this->expectException(InvalidResponseException::class); + $client->testAuthorization(); + } +} diff --git a/tests/clientTest.php b/tests/clientTest.php index fc37177..60bdf82 100644 --- a/tests/clientTest.php +++ b/tests/clientTest.php @@ -1,5 +1,7 @@ assertSame($host, $client->getHost()); - $otherHost = new Host('otherHost', $credentials); + $otherHost = new Host('https://otherHost', $credentials); $client->setHost($otherHost); $this->assertNotSame($host, $client->getHost()); $this->assertSame($otherHost, $client->getHost()); @@ -23,34 +25,34 @@ public function testHostWithAPIKey(): void { public function testHost(): void { $credentials = new Credentials('', ''); - $host = new Host('', $credentials); + $host = new Host('https://someHost', $credentials); $client = new Client($host); $this->assertSame($host, $client->getHost()); - $otherHost = new Host('otherHost', $credentials); + $otherHost = new Host('https://otherHost', $credentials); $client->setHost($otherHost); $this->assertNotSame($host, $client->getHost()); $this->assertSame($otherHost, $client->getHost()); } public function testSimpleFactoryWithAPIKey(): void { - $client = Client::simpleFactory('someHost', 'ApiKey'); + $client = Client::simpleFactory('https://someHost', 'ApiKey'); $host = $client->getHost(); $credentials = $host->getCredentials(); - $this->assertSame('someHost', $host->getHostname()); + $this->assertSame('https://somehost', $host->getHostname()); $this->assertSame('ApiKey', $credentials->getApiKey()); } public function testSimpleFactory(): void { - $client = Client::simpleFactory('someHost', 'someUser', 'somePass'); + $client = Client::simpleFactory('https://someHost', 'someUser', 'somePass'); $host = $client->getHost(); $credentials = $host->getCredentials(); - $this->assertSame('someHost', $host->getHostname()); + $this->assertSame('https://somehost', $host->getHostname()); $this->assertSame('someUser', $credentials->getUser()); $this->assertSame('somePass', $credentials->getPassword()); } diff --git a/tests/commandAddTest.php b/tests/commandAddTest.php index 9b3c561..8247a00 100644 --- a/tests/commandAddTest.php +++ b/tests/commandAddTest.php @@ -1,5 +1,7 @@ assertSame('v-add-user', $command->getName()); - $this->assertSame(['arg1' => 'admin', 'arg2' => 'pass', 'arg3' => 'mail@domain.com', 'arg4' => null], $command->getRequestParams()); + $this->assertSame(['arg1' => 'admin', 'arg2' => 'pass', 'arg3' => 'mail@domain.com', 'arg4' => 'default'], $command->getRequestParams()); $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } + public function testUserWithName(): void { + $command = new User('admin', 'pass', 'mail@domain.com', null, 'John', 'Doe'); + $this->assertSame([ + 'arg1' => 'admin', + 'arg2' => 'pass', + 'arg3' => 'mail@domain.com', + 'arg4' => 'default', + 'arg5' => 'John', + 'arg6' => 'Doe' + ], $command->getRequestParams()); + + $command = new User('admin', 'pass', 'mail@domain.com', 'custom', 'John'); + $this->assertSame([ + 'arg1' => 'admin', + 'arg2' => 'pass', + 'arg3' => 'mail@domain.com', + 'arg4' => 'custom', + 'arg5' => 'John' + ], $command->getRequestParams()); + + $command = new User('admin', 'pass', 'mail@domain.com', null, null, 'Doe'); + $this->assertSame([ + 'arg1' => 'admin', + 'arg2' => 'pass', + 'arg3' => 'mail@domain.com', + 'arg4' => 'default', + 'arg5' => '', + 'arg6' => 'Doe' + ], $command->getRequestParams()); + } + public function testWebDomain(): void { $command = new WebDomain('admin', 'domain.com'); $this->assertSame('v-add-web-domain', $command->getName()); @@ -37,7 +70,7 @@ public function testWebDomain(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -54,7 +87,7 @@ public function testWebDomainFtp(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -70,10 +103,26 @@ public function testLEDomain(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } + public function testLEDomainMailAndKeyAlgo(): void { + $command = new LetsEncryptDomain('admin', 'domain.com', 'www.domain.com', true, LetsEncryptDomain::KEY_ALGO_RSA); + $this->assertSame([ + 'arg1' => 'admin', + 'arg2' => 'domain.com', + 'arg3' => 'www.domain.com', + 'arg4' => 'yes', + 'arg5' => 'rsa' + ], $command->getRequestParams()); + } + + public function testLEDomainInvalidKeyAlgo(): void { + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidArgumentException::class); + new LetsEncryptDomain('admin', 'domain.com', null, false, 'dsa'); + } + public function testMailAccount(): void { $command = new MailAccount('admin', 'domain.com', 'account', 'pass'); $this->assertSame('v-add-mail-account', $command->getName()); @@ -86,7 +135,7 @@ public function testMailAccount(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -103,7 +152,7 @@ public function testMailDomain(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -120,7 +169,7 @@ public function testDatabase(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } } diff --git a/tests/commandBackupTest.php b/tests/commandBackupTest.php index f054b01..f344a69 100644 --- a/tests/commandBackupTest.php +++ b/tests/commandBackupTest.php @@ -1,5 +1,7 @@ assertSame('v-backup-users', $command->getName()); - $this->assertSame(['arg1' => 'json'], $command->getRequestParams()); + public function testBackupUsers(): void { + $command = new Users(); + $this->assertSame('v-backup-users', $command->getName()); + $this->assertSame([], $command->getRequestParams()); - $this->expectException(TypeError::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); - } + } - public function testBackupUser(): void { - $command = new User('admin'); - $this->assertSame('v-backup-user', $command->getName()); + public function testBackupUser(): void { + $command = new User('admin'); + $this->assertSame('v-backup-user', $command->getName()); $this->assertSame(['arg1' => 'admin', 'arg2' => 'yes'], $command->getRequestParams()); - $this->expectException(TypeError::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); - } + } } diff --git a/tests/commandChangeTest.php b/tests/commandChangeTest.php index 4a9fe02..8d84e7d 100644 --- a/tests/commandChangeTest.php +++ b/tests/commandChangeTest.php @@ -1,5 +1,7 @@ assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -32,7 +34,7 @@ public function testMailAccountPassword(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -48,7 +50,7 @@ public function testWebDomainFtpPassword(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -64,7 +66,7 @@ public function testWebDomainFtpPath(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } } diff --git a/tests/commandDeleteTest.php b/tests/commandDeleteTest.php index b0ab7d3..2c917f5 100644 --- a/tests/commandDeleteTest.php +++ b/tests/commandDeleteTest.php @@ -1,5 +1,7 @@ assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -36,7 +38,7 @@ public function testWebDomain(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -51,7 +53,7 @@ public function testWebDomainFtp(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -66,7 +68,7 @@ public function testLEDomain(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -81,7 +83,7 @@ public function testMailAccount(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -95,7 +97,7 @@ public function testMailDomain(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -109,7 +111,7 @@ public function testUserBackup(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -120,9 +122,14 @@ public function testUserBackupExclusions(): void { 'arg1' => 'admin' ], $command->getRequestParams()); + $this->assertSame([ + 'arg1' => 'admin', + 'arg2' => 'WEB' + ], (new UserBackupExclusions('admin', 'WEB'))->getRequestParams()); + $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -136,7 +143,7 @@ public function testDatabase(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -149,7 +156,7 @@ public function testDatabases(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } } diff --git a/tests/commandListTest.php b/tests/commandListTest.php index a8e1a1d..67355aa 100644 --- a/tests/commandListTest.php +++ b/tests/commandListTest.php @@ -1,5 +1,7 @@ assertFalse($command->needReturnCode()); - $this->expectException(TypeError::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -33,8 +35,7 @@ public function testUsers(): void { $this->assertSame('v-list-users', $command->getName()); $this->assertSame(['arg1' => 'json'], $command->getRequestParams()); - $this->expectException(TypeError::class); - $command->process(); + $this->assertSame([], $command->process()); } public function testWebDomains(): void { @@ -42,8 +43,7 @@ public function testWebDomains(): void { $this->assertSame('v-list-web-domains', $command->getName()); $this->assertSame(['arg1' => 'admin', 'arg2' => 'json'], $command->getRequestParams()); - $this->expectException(TypeError::class); - $command->process(); + $this->assertSame([], $command->process()); } public function testMailDomains(): void { @@ -51,22 +51,23 @@ public function testMailDomains(): void { $this->assertSame('v-list-mail-domains', $command->getName()); $this->assertSame(['arg1' => 'admin', 'arg2' => 'json'], $command->getRequestParams()); - $this->expectException(TypeError::class); - $command->process(); + $this->assertSame([], $command->process()); } public function testMailDomainDkim(): void { $command = new MailDomainDkim('admin', 'domain'); $this->assertSame('v-list-mail-domain-dkim', $command->getName()); $this->assertSame(['arg1' => 'admin', 'arg2' => 'domain', 'arg3' => 'json'], $command->getRequestParams()); - $this->assertSame('', $command->getResponseText()); + + $this->assertSame([], $command->process()); } public function testMailDomainDkimDns(): void { $command = new MailDomainDkimDns('admin', 'domain'); $this->assertSame('v-list-mail-domain-dkim-dns', $command->getName()); $this->assertSame(['arg1' => 'admin', 'arg2' => 'domain', 'arg3' => 'json'], $command->getRequestParams()); - $this->assertSame('', $command->getResponseText()); + + $this->assertSame([], $command->process()); } public function testMailAccounts(): void { @@ -74,8 +75,7 @@ public function testMailAccounts(): void { $this->assertSame('v-list-mail-accounts', $command->getName()); $this->assertSame(['arg1' => 'admin', 'arg2' => 'domain', 'arg3' => 'json'], $command->getRequestParams()); - $this->expectException(TypeError::class); - $command->process(); + $this->assertSame([], $command->process()); } public function testUserBackups(): void { @@ -83,16 +83,15 @@ public function testUserBackups(): void { $this->assertSame('v-list-user-backups', $command->getName()); $this->assertSame(['arg1' => 'admin', 'arg2' => 'json'], $command->getRequestParams()); - $this->expectException(TypeError::class); - $command->process(); + $this->assertSame([], $command->process()); } public function testUserBackup(): void { $command = new UserBackup('admin', 'admin.2021-10-07_05-15-09.tar'); $this->assertSame('v-list-user-backup', $command->getName()); $this->assertSame(['arg1' => 'admin', 'arg2' => 'admin.2021-10-07_05-15-09.tar', 'arg3' => 'json'], $command->getRequestParams()); - - $this->expectException(TypeError::class); + + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -101,8 +100,7 @@ public function testUserBackupExclusions(): void { $this->assertSame('v-list-user-backup-exclusions', $command->getName()); $this->assertSame(['arg1' => 'admin', 'arg2' => 'json'], $command->getRequestParams()); - $this->expectException(TypeError::class); - $command->process(); + $this->assertSame([], $command->process()); } public function testListDatabase(): void { @@ -110,7 +108,7 @@ public function testListDatabase(): void { $this->assertSame('v-list-database', $command->getName()); $this->assertSame(['arg1' => 'admin', 'arg2' => 'test', 'arg3' => 'json'], $command->getRequestParams()); - $this->expectException(TypeError::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -119,7 +117,6 @@ public function testListDatabases(): void { $this->assertSame('v-list-databases', $command->getName()); $this->assertSame(['arg1' => 'admin', 'arg2' => 'json'], $command->getRequestParams()); - $this->expectException(TypeError::class); - $command->process(); + $this->assertSame([], $command->process()); } } diff --git a/tests/commandSuspendTest.php b/tests/commandSuspendTest.php index 5ff7c66..66b0419 100644 --- a/tests/commandSuspendTest.php +++ b/tests/commandSuspendTest.php @@ -1,5 +1,7 @@ assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -26,12 +28,12 @@ public function testWebDomain(): void { $this->assertSame([ 'arg1' => 'admin', 'arg2' => 'domain.com', - 'arg3' => 'no' + 'arg3' => 'no' ], $command->getRequestParams()); $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -46,7 +48,7 @@ public function testMailAccount(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -60,7 +62,7 @@ public function testMailDomain(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } } diff --git a/tests/commandTest.php b/tests/commandTest.php index 400f1f9..c2f5a55 100644 --- a/tests/commandTest.php +++ b/tests/commandTest.php @@ -1,8 +1,9 @@ assertSame('', $command->getName()); + $this->assertSame('v-list-sys-info', $command->getName()); $this->assertSame([], $command->getRequestParams()); $this->assertTrue($command->needReturnCode()); - - $this->expectException(InvalidResponseException::class); - $command->process(); - $this->assertNull($command->getResponseText()); $this->expectException(InvalidResponseException::class); - $this->assertNull($command->getResponseCode()); - - $command->defaultProcess(); - - $this->expectException(ProcessException::class); - $command->processException(new \GuzzleHttp\Exception\ClientException('throw!')); + $command->process(); } } diff --git a/tests/commandUnsuspendTest.php b/tests/commandUnsuspendTest.php index e4cbcf8..4c53321 100644 --- a/tests/commandUnsuspendTest.php +++ b/tests/commandUnsuspendTest.php @@ -1,5 +1,7 @@ assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -26,12 +28,12 @@ public function testWebDomain(): void { $this->assertSame([ 'arg1' => 'admin', 'arg2' => 'domain.com', - 'arg3' => 'no' + 'arg3' => 'no' ], $command->getRequestParams()); $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -46,7 +48,7 @@ public function testMailAccount(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } @@ -60,7 +62,7 @@ public function testMailDomain(): void { $this->assertTrue($command->needReturnCode()); - $this->expectException(\neto737\HestiaCP\InvalidResponseException::class); + $this->expectException(\neto737\HestiaCP\Exceptions\InvalidResponseException::class); $command->process(); } } diff --git a/tests/credentialTest.php b/tests/credentialTest.php index a59cb2e..300546b 100644 --- a/tests/credentialTest.php +++ b/tests/credentialTest.php @@ -1,12 +1,14 @@ assertSame('', $credentials->getApiKey()); @@ -14,7 +16,7 @@ public function testApiKey() { $this->assertSame('key', $credentials->getApiKey()); } - public function testUser() { + public function testUser(): void { $credentials = new Credentials('', ''); $this->assertSame('', $credentials->getUser()); @@ -22,7 +24,7 @@ public function testUser() { $this->assertSame('test', $credentials->getUser()); } - public function testPassword() { + public function testPassword(): void { $credentials = new Credentials('', ''); $this->assertSame('', $credentials->getPassword()); @@ -30,11 +32,40 @@ public function testPassword() { $this->assertSame('test', $credentials->getPassword()); } - public function testParams() { + public function testParams(): void { $credentials = new Credentials('us', 'er'); $this->assertSame(['user' => 'us', 'password' => 'er'], $credentials->getRequestParams()); $credentials_apikey = new Credentials('key'); $this->assertSame(['hash' => 'key'], $credentials_apikey->getRequestParams()); + + $credentials_accesskey = Credentials::accessKey('access', 'secret'); + $this->assertSame(['access_key' => 'access', 'secret_key' => 'secret'], $credentials_accesskey->getRequestParams()); + } + + public function testAccessKey(): void { + $credentials = Credentials::accessKey('access', 'secret'); + $this->assertSame('access', $credentials->getAccessKey()); + $this->assertSame('secret', $credentials->getSecretKey()); + $this->assertNull($credentials->getApiKey()); + $this->assertNull($credentials->getUser()); + $this->assertNull($credentials->getPassword()); + } + + public function testSwitchBetweenKeys(): void { + $credentials = Credentials::accessKey('access', 'secret'); + $credentials->setApiKey('key'); + $this->assertNull($credentials->getAccessKey()); + $this->assertNull($credentials->getSecretKey()); + $this->assertSame(['hash' => 'key'], $credentials->getRequestParams()); + + $credentials->setAccessKey('access', 'secret'); + $this->assertNull($credentials->getApiKey()); + $this->assertSame(['access_key' => 'access', 'secret_key' => 'secret'], $credentials->getRequestParams()); + } + + public function testNamedConstructors(): void { + $this->assertSame(['hash' => 'key'], Credentials::legacyHash('key')->getRequestParams()); + $this->assertSame(['user' => 'us', 'password' => 'er'], Credentials::password('us', 'er')->getRequestParams()); } } diff --git a/tests/hostTest.php b/tests/hostTest.php index 4c29162..01bed59 100644 --- a/tests/hostTest.php +++ b/tests/hostTest.php @@ -1,32 +1,100 @@ assertSame('https://hestia', $host->getHostname()); - $this->assertSame('', $host->getHostname()); + $host->setHostname('http://host'); + $this->assertSame('http://host', $host->getHostname()); + } - $host->setHostname('host'); - $this->assertSame('host', $host->getHostname()); + /** @return array */ + public static function normalizedHostnames(): array { + return [ + 'trailing slash' => ['https://hestia/', 'https://hestia'], + 'several trailing slashes' => ['https://hestia///', 'https://hestia'], + 'surrounding whitespace' => [" https://hestia \n", 'https://hestia'], + 'uppercase scheme and host' => ['HTTPS://Panel.Example.COM', 'https://panel.example.com'], + 'ip address' => ['https://192.0.2.10', 'https://192.0.2.10'], + 'ipv6 address' => ['https://[2001:db8::1]/', 'https://[2001:db8::1]'], + ]; } - public function testUri() { + #[\PHPUnit\Framework\Attributes\DataProvider('normalizedHostnames')] + public function testNormalizesHostname(string $hostname, string $expected): void { + $host = new Host($hostname, new Credentials('', '')); + + $this->assertSame($expected, $host->getHostname()); + } + + /** @return array */ + public static function invalidHostnames(): array { + return [ + 'empty' => [''], + 'missing scheme' => ['hestia'], + 'missing scheme with port' => ['hestia:8083'], + 'scheme only' => ['https://'], + 'unsupported scheme' => ['ftp://hestia'], + 'port' => ['https://hestia:8083'], + 'ip address with port' => ['https://192.0.2.10:8083'], + 'ipv6 address with port' => ['https://[2001:db8::1]:8083'], + 'path' => ['https://hestia/api/'], + 'query' => ['https://hestia?a=b'], + 'fragment' => ['https://hestia#top'], + 'user info' => ['https://user:pass@hestia'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('invalidHostnames')] + public function testRejectsInvalidHostname(string $hostname): void { + $this->expectException(InvalidArgumentException::class); + + new Host($hostname, new Credentials('', '')); + } + + public function testRejectsUnbracketedIpv6(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must be in brackets'); + + new Host('https://2001:db8::1', new Credentials('', '')); + } + + public function testSetHostnameKeepsPreviousValueOnError(): void { + $host = new Host('https://hestia', new Credentials('', '')); + + try { + $host->setHostname('hestia'); + $this->fail('Expected InvalidArgumentException'); + } catch (InvalidArgumentException $e) { + $this->assertSame('https://hestia', $host->getHostname()); + } + } + + public function testUri(): void { $credentials = new Credentials('', ''); $host = new Host('https://hestia', $credentials); $this->assertSame('https://hestia:8083/api/', $host->buildUri()); + + $host = new Host('https://hestia/', $credentials, 2083); + $this->assertSame('https://hestia:2083/api/', $host->buildUri()); } - public function testPort() { + public function testPort(): void { $credentials = new Credentials('', ''); - $host = new Host('', $credentials); + $host = new Host('https://hestia', $credentials); $this->assertSame(8083, $host->getPort()); @@ -34,9 +102,9 @@ public function testPort() { $this->assertSame(2023, $host->getPort()); } - public function testCredentials() { + public function testCredentials(): void { $credentials = new Credentials('', ''); - $host = new Host('', $credentials); + $host = new Host('https://hestia', $credentials); $this->assertSame($credentials, $host->getCredentials()); diff --git a/tests/moduleTest.php b/tests/moduleTest.php index 4aa9d40..a9183a1 100644 --- a/tests/moduleTest.php +++ b/tests/moduleTest.php @@ -1,5 +1,7 @@ createClient(); $this->assertSame(Databases::class, get_class($client->getModuleDatabase('test'))); } + + public function testModuleCachedPerUser(): void { + $client = $this->createClient(); + + $this->assertSame($client->getModuleUser(), $client->getModuleUser()); + + $this->assertSame($client->getModuleMail('alice'), $client->getModuleMail('alice')); + $this->assertNotSame($client->getModuleMail('alice'), $client->getModuleMail('bob')); + $this->assertNotSame($client->getModuleWeb('alice'), $client->getModuleWeb('bob')); + $this->assertNotSame($client->getModuleBackup('alice'), $client->getModuleBackup('bob')); + $this->assertNotSame($client->getModuleDatabase('alice'), $client->getModuleDatabase('bob')); + } }