From f6fec5e436408586e82b5202d869344ff5cd77c0 Mon Sep 17 00:00:00 2001 From: Sibin Grasic Date: Sun, 24 Sep 2023 11:30:38 +0200 Subject: [PATCH] feat: Initial implementation --- .editorconfig | 17 ++ .gitattributes | 4 + .github/renovate.json | 4 + .github/workflows/release.yml | 25 +++ .gitignore | 2 + .phpcs.xml | 25 +++ README.md | 7 + composer.json | 45 ++++ composer.lock | 155 ++++++++++++++ src/Hooks/HookManager.php | 40 ++++ src/Hooks/Hookable.php | 391 ++++++++++++++++++++++++++++++++++ src/Module/BaseGateway.php | 240 +++++++++++++++++++++ src/Module/BaseModule.php | 143 +++++++++++++ src/Traits/Singleton.php | 41 ++++ 14 files changed, 1139 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/renovate.json create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .phpcs.xml create mode 100644 README.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 src/Hooks/HookManager.php create mode 100644 src/Hooks/Hookable.php create mode 100644 src/Module/BaseGateway.php create mode 100644 src/Module/BaseModule.php create mode 100644 src/Traits/Singleton.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c89d084 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.php] +indent_size = 4 + +[phpcs.xml] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8f9b468 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.phpcs.xml export-ignore diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..4bd832f --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:base"] +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..290fff0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +name: Release +on: + workflow_dispatch: + push: + branches: + - master +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + token: ${{ secrets.OBLAK_BOT_TOKEN }} + - name: Publish a composer package + uses: better-php-actions/publish-composer-package@v1 + with: + package_slug: "whmcs-utils" + package_name: "WHMCS Utils" + with_gpg: true + gpg_key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg_passphrase: ${{ secrets.GPG_PASSPHRASE }} + release_token: ${{ secrets.OBLAK_BOT_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..719bfcc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.vscode +vendor diff --git a/.phpcs.xml b/.phpcs.xml new file mode 100644 index 0000000..51d1986 --- /dev/null +++ b/.phpcs.xml @@ -0,0 +1,25 @@ + + + Coding standards for AutoConstructor Composer Plugin + + + + + + + + . + */.git* + */build/* + */vendor/* + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fba080 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +
+ +# WHMCS Module Utilities + +
+ +A collection of base classes that will help you to create your own WHMCS modules. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..006d7bc --- /dev/null +++ b/composer.json @@ -0,0 +1,45 @@ +{ + "name": "oblak/whmcs-utils", + "description": "WHMCS Addon Utility classes and functions", + "version": "1.0.0", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Sibin Grasic", + "email": "sibin.grasic@oblak.studio", + "homepage": "https://oblak.host" + } + ], + "keywords": [ + "whmcs", + "addon", + "utility" + ], + "support": { + "issues": "https://github.com/oblakhost/whmcs-utils" + }, + "autoload": { + "psr-4": { + "Oblak\\WHMCS\\": "src" + } + }, + "require": { + "php": "^8.0|^8.1" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "squizlabs/php_codesniffer": "3.7.1" + }, + "config": { + "optimize-autoloader": true, + "classmap-authoritative": true, + "preferred-install": "dist", + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..6b0c3bc --- /dev/null +++ b/composer.lock @@ -0,0 +1,155 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "ec59534b574a0b70cc50746ca4648d0e", + "packages": [], + "packages-dev": [ + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "4be43904336affa5c2f70744a348312336afd0da" + }, + "dist": { + "type": "zip", + "url": "https://github.com/gitapi/repos/PHPCSStandards/composer-installer/zipball/4be43904336affa5c2f70744a348312336afd0da", + "reference": "4be43904336affa5c2f70744a348312336afd0da", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "*", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.3.1", + "phpcompatibility/php-compatibility": "^9.0", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "franck.nijhof@dealerdirect.com", + "homepage": "http://www.frenck.nl", + "role": "Developer / IT Manager" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "homepage": "http://www.dealerdirect.com", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "time": "2023-01-05T11:28:13+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.7.1", + "source": { + "type": "git", + "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", + "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619" + }, + "dist": { + "type": "zip", + "url": "https://github.com/gitapi/repos/squizlabs/PHP_CodeSniffer/zipball/1359e176e9307e906dc3d890bcc9603ff6d90619", + "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "bin": [ + "bin/phpcs", + "bin/phpcbf" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "lead" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", + "source": "https://github.com/squizlabs/PHP_CodeSniffer", + "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" + }, + "time": "2022-06-18T07:21:10+00:00" + } + ], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": [], + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.0|^8.1" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/src/Hooks/HookManager.php b/src/Hooks/HookManager.php new file mode 100644 index 0000000..5797d5a --- /dev/null +++ b/src/Hooks/HookManager.php @@ -0,0 +1,40 @@ +hooks[$module][] = new $hook(); + } + } +} diff --git a/src/Hooks/Hookable.php b/src/Hooks/Hookable.php new file mode 100644 index 0000000..863dcf1 --- /dev/null +++ b/src/Hooks/Hookable.php @@ -0,0 +1,391 @@ +hookname, self::$availableHooks)) { + throw new Exception("Invalid hook name: {$this->hookname}"); + } + add_hook($this->hookname, $this->priority, [$this, 'hookCallback']); + } + + /** + * Hook callback + * + * @param array $params Hook parameters + * @return mixed|null Hook return value + */ + abstract public function hookCallback(array $vars): mixed; +} diff --git a/src/Module/BaseGateway.php b/src/Module/BaseGateway.php new file mode 100644 index 0000000..36f55a3 --- /dev/null +++ b/src/Module/BaseGateway.php @@ -0,0 +1,240 @@ +meta = $this->getMetadata(); + + parent::__construct(); + } + + protected function loadSettings(): array + { + return getGatewayVariables($this->moduleName); + } + + /** + * Get the Gateway module Meta Data + * + * @return array + */ + abstract public static function getMetadata(): array; + + public function getTransactions(int $invoiceId): ?array + { + $invoice = new Invoice(); + try { + $invoice->setID($invoiceId); + return array_filter( + $invoice->getTransactions(), + fn($transaction) => $transaction['gateway'] == $this->settings['name'] + ); + } catch (Exception $e) { + logModuleCall($this->moduleName, 'getTransactions', $invoiceId, $e->getMessage(), $e->getTraceAsString()); + return null; + } + } + + public function getGatewayLogs(): array + { + $logs = []; + $rawLogs = Capsule::table('tblgatewaylog') + ->where('gateway', $this->settings['name']) + ->orderBy('id', 'desc') + ->get(); + + foreach ($rawLogs as $log) { + $logs[] = [ + 'id' => $log->id, + 'date' => $log->date, + 'gateway' => $log->gateway, + 'data' => json_decode($log->data, true), + ]; + } + + return $logs; + } + + public function getGatewayLog(string $transactionId): ?array + { + $logs = array_filter( + $this->getGatewayLogs(), + fn($log) => $this->getTransactionId($log) == $transactionId + ); + + return array_shift($logs); + } + + /** + * Returns a transaction ID for the transaction log + * + * @param $log + */ + abstract protected function getTransactionId(array $log): string; + + public function checkResponse(): void + { + if (empty($_POST)) { + die('POST SHOULD NOT BE EMPTY'); + } + + $data = $this->unslash($_POST); + + $invoiceId = $this->getInvoiceId($data); + $invoice = new \WHMCS\Invoice($invoiceId); + + $duplicate = array_filter( + $this->getTransactions($invoiceId), + fn($transaction) => $transaction['transid'] == $this->getTransactionId($data) + ); + + + if (count($duplicate) > 0 && $invoice->getData('status') == 'Paid') { + $this->handleRedirect(true, $invoiceId); + } + + $this->gatewayCallback($data, $invoiceId); + } + + abstract protected function gatewayCallback(array $data, int $invoiceId): void; + + /** + * Get the invoice id based on gateway response + * + * You can use WHMCS native `checkCbInvoiceId` function to check the invoice ID + * + * @param array $data Gateway response + * @return int Invoice ID + */ + abstract protected function getInvoiceId(array $data): int; + + + /** + * Process the transaction data. + * + * Does the following: + * * Logs the transaction + * * Sends an email to the client + * + * @param bool $transactionStatus Whether the transaction was successful + * @param array $data Transaction data + * @param int $invoiceId Invoice ID + */ + protected function processTransaction(bool $transactionStatus, array $data, int $invoiceId) + { + logTransaction($this->settings['name'], json_encode($data), $transactionStatus ? 'Success' : 'Failed'); + $this->sendEmail($transactionStatus, $invoiceId); + } + + /** + * Redirects the user to the invoice page. + * + * @param bool $transactionStatus Whether the transaction was successful + * @param int $invoiceId Invoice ID + * @param bool $noQs Whether to remove query string to the URL + */ + protected function handleRedirect(bool $transactionStatus, int $invoiceId, bool $noQs = false) + { + $redirUrl = sprintf( + 'id=%d', + $invoiceId, + ); + + if (!$noQs) { + $redirUrl .= sprintf( + '&%s=true', + $transactionStatus ? 'paymentsuccess' : 'paymentfailed', + ); + } + + redirSystemURL($redirUrl, "viewinvoice.php"); + return; + } + + /** + * Sends an email to the client + * + * @param bool $transactionStatus Whether the transaction was successful + * @param int $invoiceId Invoice ID + */ + protected function sendEmail(bool $transactionStatus, int $invoiceId) + { + $templateName = $this->meta[$transactionStatus ? 'successEmail' : 'failedEmail']; + $emailTemplate = \WHMCS\Mail\Template::where("name", "=", $templateName)->first()->name; + + if (!$emailTemplate) { + return; + } + + logModuleCall( + $this->moduleName, + $transactionStatus ? 'successEmail' : 'failedEmail', + $emailTemplate, + $this->settings, + ); + + sendMessage($emailTemplate, $invoiceId); + } + + /** + * Unslashes the posted data + * + * @param mixed $value The value to unslash + * @return mixed The unslashed value + */ + protected function unslash(mixed $value): mixed + { + return $this->mapDeep($value, fn($value) => is_string($value) ? stripslashes($value) : $value); + } + + /** + * Maps a function to all non-iterable elements of an array or an object. + * + * This is similar to `array_walk_recursive()` but acts upon objects too. + * + * @param mixed $value The array, object, or scalar. + * @param callable $callback The function to map onto $value. + * @return mixed The value with the callback applied to all non-arrays and non-objects inside it. + */ + protected function mapDeep($value, $callback) + { + if (is_array($value)) { + foreach ($value as $index => $item) { + $value[ $index ] = $this->mapDeep($item, $callback); + } + } elseif (is_object($value)) { + $objectVars = get_object_vars($value); + foreach ($objectVars as $propName => $propValue) { + $value->$propName = $this->mapDeep($propValue, $callback); + } + } else { + $value = call_user_func($callback, $value); + } + + return $value; + } +} diff --git a/src/Module/BaseModule.php b/src/Module/BaseModule.php new file mode 100644 index 0000000..f547d32 --- /dev/null +++ b/src/Module/BaseModule.php @@ -0,0 +1,143 @@ +settings = $this->loadSettings(); + $this->loadLanguage(); + } + + /** + * Returns the Gateway module configuration + * + * @return array + */ + abstract public static function getConfig(): array; + + /** + * Loads the module settings from the database + * + * @return array + */ + protected function loadSettings(): array + { + $settings = []; + $rawSettings = Capsule::table('tbladdonmodules') + ->select('setting', 'value') + ->where('module', $this->moduleName) + ->get(); + + foreach ($rawSettings as $setting) { + $settings[$setting->setting] = $setting->value; + } + + return Sanitize::convertToCompatHtml($settings); + } + + /** + * Loads the module language file - across the WHMCS + */ + protected function loadLanguage() + { + $currentLanguage = $this->getCurrentLanguage(defined('CLIENTAREA')); + $languageFile = $this->getLanguageFile($currentLanguage); + + if ($languageFile === null) { + return; + } + + global $_LANG; + + require $languageFile; + + $_LANG[$this->moduleName] = $_ADDONLANG; + } + + /** + * Get the language file path + * + * @param string $languageName Language name + * @return string|null Language file path + */ + private function getLanguageFile(string $languageName): ?string + { + $languageFile = "{$this->getModuleDir()}/lang/{$languageName}.php"; + + if (!file_exists($languageFile)) { + $languageFile = "{$this->getModuleDir()}/lang/english.php"; + } + + return file_exists($languageFile) ? $languageFile : null; + } + + /** + * Get the module directory path + * + * @return string Module directory path + */ + public function getModuleDir(): string + { + $path = dirname((new ReflectionClass($this))->getFileName()); + while (basename($path) != $this->moduleName) { + $path = dirname($path); + } + + return $path; + } + + /** + * Get the current language + * + * @param bool $inClientArea Whether the language is in the client area + * @return string Language name + */ + public function getCurrentLanguage(bool $inClientArea = true): string + { + $settingLanguage = Setting::getValue('Language'); + $sessionLanguage = Session::get('Language'); + $requestLanguage = $_REQUEST['language'] ?? ''; + + $languageName = $settingLanguage; + + if ($sessionLanguage != "") { + $languageName = $sessionLanguage; + } + + if ($inClientArea && $requestLanguage != "") { + $languageName = $requestLanguage; + } + + return $languageName; + } +} diff --git a/src/Traits/Singleton.php b/src/Traits/Singleton.php new file mode 100644 index 0000000..d71507c --- /dev/null +++ b/src/Traits/Singleton.php @@ -0,0 +1,41 @@ +