diff --git a/src/LaravelServiceProvider.php b/src/LaravelServiceProvider.php index 3fb03b2..3870838 100644 --- a/src/LaravelServiceProvider.php +++ b/src/LaravelServiceProvider.php @@ -16,6 +16,7 @@ use Upmind\ProvisionProviders\SharedHosting\InterWorx\Provider as InterWorx; use Upmind\ProvisionProviders\SharedHosting\DirectAdmin\Provider as DirectAdmin; use Upmind\ProvisionProviders\SharedHosting\CentosWeb\Provider as CentosWeb; +use Upmind\ProvisionProviders\SharedHosting\SPanel\Provider as SPanel; class LaravelServiceProvider extends ProvisionServiceProvider { @@ -34,5 +35,6 @@ public function boot() $this->bindProvider('shared-hosting', 'solidcp', SolidCP::class); $this->bindProvider('shared-hosting', 'direct-admin', DirectAdmin::class); $this->bindProvider('shared-hosting', 'centos-web', CentosWeb::class); + $this->bindProvider('shared-hosting', 'spanel', SPanel::class); } } diff --git a/src/SPanel/Api.php b/src/SPanel/Api.php new file mode 100644 index 0000000..a782344 --- /dev/null +++ b/src/SPanel/Api.php @@ -0,0 +1,280 @@ +configuration = $configuration; + $this->client = new Client([ + 'base_uri' => sprintf('https://%s', $this->configuration->hostname), + 'headers' => [ + 'Accept' => 'application/json', + ], + 'connect_timeout' => 10, + 'timeout' => 60, + 'http_errors' => true, + 'allow_redirects' => false, + 'handler' => $handler, + ]); + } + + /** + * @throws GuzzleException + * @throws ProvisionFunctionError + * @throws \Throwable + */ + public function makeRequest( + ?array $body = null, + ?string $method = 'POST' + ): ?array { + $requestParams = []; + + $body['token'] = $this->configuration->api_token; + $requestParams['form_params'] = $body; + + $response = $this->client->request($method, '/spanel/api.php', $requestParams); + $result = $response->getBody()->getContents(); + + $response->getBody()->close(); + + if ($result === '') { + return null; + } + + return $this->parseResponseData($result); + + } + + /** + * @throws ProvisionFunctionError + */ + private function parseResponseData(string $response): array + { + try { + $parsedResult = json_decode($response, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $ex) { + throw ProvisionFunctionError::create('Failed to parse response data', $ex) + ->withData([ + 'response' => $response, + ]); + } + + if ($error = $this->getResponseErrorMessage($parsedResult)) { + throw ProvisionFunctionError::create($error) + ->withData([ + 'response' => $response, + ]); + } + + return $parsedResult; + } + + private function getResponseErrorMessage(array $response): ?string + { + if ($response['result'] === 'error') { + if (is_string($response['message'])) { + return $response['message']; + } + + if (is_array($response['message'])) { + return implode(', ', $response['message']); + } + } + + return null; + } + + /** + * @throws ProvisionFunctionError + * @throws \RuntimeException|Throwable + */ + public function createAccount(CreateParams $params, string $username): void + { + $password = $params->password ?: Helper::generatePassword(); + + $body = [ + 'action' => 'accounts/wwwacct', + 'username' => $username, + 'password' => $password, + 'domain' => $params->domain, + 'package' => $params->package_name, + 'permissions' => 'all' + ]; + + $this->makeRequest($body); + } + + /** + * @throws ProvisionFunctionError + * @throws \RuntimeException + * @throws \Throwable + */ + public function getAccountData(string $username): array + { + $body = [ + 'action' => 'accounts/listaccounts', + 'accountuser' => $username, + ]; + + $response = $this->makeRequest($body); + + $accountData = []; + + foreach($response['data'] as $data) { + if ($data['user'] === $username) { + $accountData = $data; + } + } + + // If no matching result is found, throw an error + if (empty($accountData)) { + throw ProvisionFunctionError::create('Account not found'); + } + + return [ + 'username' => $accountData['user'], + 'domain' => $accountData['domain'], + 'reseller' => false, + 'server_hostname' => $this->configuration->hostname, + 'package_name' => $accountData['package'], + 'suspended' => $accountData['suspended'] !== '0', + 'ip' => $accountData['ip'], + ]; + } + + + /** + * @throws ProvisionFunctionError + * @throws \RuntimeException|Throwable + */ + public function getAccountUsage(string $username): UsageData + { + $body = [ + 'action' => 'accounts/listaccounts', + 'accountuser' => $username, + ]; + + $response = $this->makeRequest($body); + + $accountData = []; + + foreach($response['data'] as $data) { + if ($data['user'] === $username) { + $accountData = $data; + } + } + + // If no matching result is found, throw an error + if (empty($accountData)) { + throw ProvisionFunctionError::create('Account not found'); + } + + $disk = UnitsConsumed::create() + ->setUsed(isset($accountData['disk']) ? ((int)$accountData['disk']) : null) + ->setLimit($accountData['disklimit'] === 'Unlimited' ? null : (int) $accountData['disklimit']); + + $inodes = UnitsConsumed::create() + ->setUsed(isset($accountData['inodes']) ? ((float) $accountData['inodes']) : null) + ->setLimit($accountData['inodeslimit'] === 'Unlimited' ? null : $accountData['inodeslimit']); + + return UsageData::create() + ->setDiskMb($disk) + ->setInodes($inodes); + } + + /** + * @throws ProvisionFunctionError + * @throws \RuntimeException|Throwable + */ + public function updatePackage(string $username, string $packageName): void + { + $body = [ + 'action' => 'accounts/changequota', + 'username' => $username, + 'package' => $packageName, + ]; + + $this->makeRequest($body); + } + + /** + * @throws ProvisionFunctionError + * @throws \RuntimeException|Throwable + */ + public function updatePassword(string $username, string $password): void + { + $body = [ + 'action' => 'accounts/changeuserpassword', + 'username' => $username, + 'password' => $password + ]; + + $this->makeRequest($body); + } + + + /** + * @throws ProvisionFunctionError + * @throws \RuntimeException|Throwable + */ + public function suspendAccount(string $username, ?string $reason): void + { + + $body = [ + 'action' => 'accounts/suspendaccount', + 'username' => $username, + 'reason' => $reason, + ]; + + $this->makeRequest($body); + } + + + /** + * @throws ProvisionFunctionError + * @throws \RuntimeException|Throwable + */ + public function unsuspendAccount(string $username): void + { + $body = [ + 'action' => 'accounts/unsuspendaccount', + 'username' => $username, + ]; + + $this->makeRequest($body); + } + + + /** + * @throws ProvisionFunctionError + * @throws \RuntimeException|Throwable + */ + public function deleteAccount(string $username): void + { + $body = [ + 'action' => 'accounts/terminateaccount', + 'username' => $username, + ]; + + $this->makeRequest($body); + } +} diff --git a/src/SPanel/Data/Configuration.php b/src/SPanel/Data/Configuration.php new file mode 100644 index 0000000..90910c7 --- /dev/null +++ b/src/SPanel/Data/Configuration.php @@ -0,0 +1,26 @@ + ['required', 'domain_name'], + 'username' => ['required', 'string'], + 'api_token' => ['required', 'string'] + ]); + } +} diff --git a/src/SPanel/Provider.php b/src/SPanel/Provider.php new file mode 100644 index 0000000..28032d1 --- /dev/null +++ b/src/SPanel/Provider.php @@ -0,0 +1,293 @@ +configuration = $configuration; + } + + /** + * @inheritDoc + */ + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('SPanel') + ->setDescription('Create and manage SPanel accounts and resellers using the SPanel API') + ->setLogoUrl('https://api.upmind.io/images/logos/provision/spanel-logo.png'); + } + + /** + * @inheritDoc + * + * @throws \Upmind\ProvisionBase\Exception\ProvisionFunctionError + * @throws \Throwable + */ + public function create(CreateParams $params): AccountInfo + { + if (!$params->domain) { + $this->errorResult('Domain name is required'); + } + + $username = $params->username ?? $this->generateUsername($params->domain); + + try { + $this->api()->createAccount($params, $username); + + return $this->_getInfo($username, 'Account created'); + } catch (Throwable $e) { + $this->handleException($e); + } + } + + protected function generateUsername(string $base): string + { + return substr( + preg_replace('/^[^a-z]+/', '', preg_replace('/[^a-z0-9]/', '', strtolower($base))), + 0, + $this->getMaxUsernameLength() + ); + } + + protected function getMaxUsernameLength(): int + { + return 16; + } + + /** + * @throws \Upmind\ProvisionBase\Exception\ProvisionFunctionError + * @throws \RuntimeException + * @throws \Throwable + */ + protected function _getInfo(string $username, string $message): AccountInfo + { + $info = $this->api()->getAccountData($username); + + return AccountInfo::create($info)->setMessage($message); + } + + /** + * @inheritDoc + * + * @throws \Upmind\ProvisionBase\Exception\ProvisionFunctionError + * @throws \Throwable + */ + public function getInfo(AccountUsername $params): AccountInfo + { + try { + return $this->_getInfo($params->username, 'Account info retrieved'); + } catch (Throwable $e) { + $this->handleException($e); + } + } + + /** + * @throws \Upmind\ProvisionBase\Exception\ProvisionFunctionError + * @throws \Throwable + */ + public function getUsage(AccountUsername $params): AccountUsage + { + try { + $usage = $this->api()->getAccountUsage($params->username); + + return AccountUsage::create()->setUsageData($usage); + } catch (Throwable $e) { + $this->handleException($e); + } + } + + /** + * @inheritDoc + */ + public function getLoginUrl(GetLoginUrlParams $params): LoginUrl + { + return LoginUrl::create() + ->setLoginUrl(sprintf('https://%s/spanel/login', $this->configuration->hostname)); + } + + /** + * @inheritDoc + * + * @throws \Upmind\ProvisionBase\Exception\ProvisionFunctionError + * @throws \Throwable + */ + public function changePassword(ChangePasswordParams $params): EmptyResult + { + try { + if (!$this->isValidPassword($params->password)) { + $this->errorResult('The password must be at least 8 characters long and contain at least one letter and one number.'); + } + + $this->api()->updatePassword($params->username, $params->password); + + return $this->emptyResult('Password changed'); + } catch (Throwable $e) { + $this->handleException($e); + } + } + + + function isValidPassword($password) + { + if (strlen($password) >= 8 && preg_match('/[a-zA-Z]/', $password) && preg_match('/\d/', $password)) { + return true; + } + + return false; + } + + + /** + * @inheritDoc + * + * @throws \Upmind\ProvisionBase\Exception\ProvisionFunctionError + * @throws \Throwable + */ + public function changePackage(ChangePackageParams $params): AccountInfo + { + try { + $this->api()->updatePackage($params->username, $params->package_name); + + return $this->_getInfo($params->username, 'Package changed'); + } catch (Throwable $e) { + $this->handleException($e); + } + } + + /** + * @inheritDoc + * + * @throws \Upmind\ProvisionBase\Exception\ProvisionFunctionError + * @throws \Throwable + */ + public function suspend(SuspendParams $params): AccountInfo + { + try { + $this->api()->suspendAccount($params->username, $params->reason ?? null); + + return $this->_getInfo($params->username, 'Account suspended'); + } catch (Throwable $e) { + $this->handleException($e); + } + } + + /** + * @inheritDoc + * + * @throws \Upmind\ProvisionBase\Exception\ProvisionFunctionError + * @throws \Throwable + */ + public function unSuspend(AccountUsername $params): AccountInfo + { + try { + $this->api()->unsuspendAccount($params->username); + + return $this->_getInfo($params->username, 'Account unsuspended'); + } catch (Throwable $e) { + $this->handleException($e); + } + } + + /** + * @inheritDoc + * + * @throws \Upmind\ProvisionBase\Exception\ProvisionFunctionError + * @throws \Throwable + */ + public function terminate(AccountUsername $params): EmptyResult + { + try { + $this->api()->deleteAccount($params->username); + + return $this->emptyResult('Account deleted'); + } catch (Throwable $e) { + $this->handleException($e); + } + } + + /** + * @inheritDoc + * + * @throws \Upmind\ProvisionBase\Exception\ProvisionFunctionError + */ + public function grantReseller(GrantResellerParams $params): ResellerPrivileges + { + $this->errorResult('Operation not supported'); + } + + /** + * @inheritDoc + * + * @throws \Upmind\ProvisionBase\Exception\ProvisionFunctionError + */ + public function revokeReseller(AccountUsername $params): ResellerPrivileges + { + $this->errorResult('Operation not supported'); + } + + /** + * @return no-return + * + * @throws \Upmind\ProvisionBase\Exception\ProvisionFunctionError + * @throws \Throwable + */ + protected function handleException(Throwable $e, array $data = [], array $debug = [], ?string $message = null): void + { + if ($e instanceof ProvisionFunctionError) { + throw $e->withData( + array_merge($e->getData(), $data) + )->withDebug( + array_merge($e->getDebug(), $debug) + ); + } + + // let the provision system handle this one + throw $e; + } + + protected function api(): Api + { + if ($this->api === null) { + $this->api = new Api($this->configuration, $this->getGuzzleHandlerStack()); + } + + return $this->api; + } +}