diff --git a/analyst/assets/css/customize.css b/analyst/assets/css/customize.css new file mode 100644 index 0000000..f50cc1f --- /dev/null +++ b/analyst/assets/css/customize.css @@ -0,0 +1,280 @@ +.analyst-action-opt { + cursor: pointer; +} + +.analyst-modal { + color: #000000; + display: none; + position: fixed; + z-index: 1000; + padding-top: 100px; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgb(0,0,0); + background-color: rgba(0,0,0,0.4); +} + +.analyst-modal-content { + font-family: Helvetica, serif; + position: relative; + background-color: #fefefe; + margin: auto; + padding: 35px 35px 20px; + border: 1px solid #F2F2F2; + width: 40%; + box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19); + -webkit-animation-name: analyst-animatetop; + -webkit-animation-duration: 0.4s; + animation-name: analyst-animatetop; + animation-duration: 0.4s +} + +.analyst-btn-success { + cursor: pointer; + color: #ffffff; + background-color: #00AF5E; + border: none; + width: 100%; + font-size: 18px; + padding: 8px; + font-weight: bold; +} + +.analyst-btn-grey { + cursor: pointer; + color: #2D2D2D; + background-color: #D8D8D8; + border: none; + width: 100%; + font-size: 18px; + padding: 8px; + font-weight: bold; +} + +.analyst-btn-secondary-ghost { + cursor: pointer; + background: transparent; + border: none; + color: #898686; + font-size: 18px; +} + +.analyst-modal-def-top-padding { + padding-top: 20px; +} + +.analyst-modal-header { + font-size: 20px; + font-weight: bold; +} + +/*INSTALL STYLES*/ +.analyst-install-footer { + padding-top: 10px; + text-align: center; +} + +.analyst-install-image-block { + width: 140px; +} + +.analyst-install-image-block img { + width: inherit; +} + +.analyst-install-description-block { + padding-left: 40px; + padding-top: 5px +} + +.analyst-install-description-text { + font-size: 16px; + color: #000000; +} + +.analyst-install-permissions-list { + list-style: disc inside; +} + +.analyst-install-permissions-list li { + padding-left: 15px; + margin-bottom: 2px; +} + +.analyst-install-footer span { + color: #8a8787; + padding-right: 10px; + padding-left: 10px; +} + +.analyst-install-footer span:not(:last-child) { + border-right: 1px solid #8a8787; +} + +/*INSTALL STYLES*/ + +.reason-answer { + padding: 7px; + margin-left: 23px; + border: 1px solid #F2F2F2; +} + +.analyst-link { + color: #00AF5E; + text-decoration: none; +} + +.analyst-action-text { + cursor: pointer; +} + +.analyst-action-text:hover { + color: #9d9a9a; +} + +.analyst-disable-modal-mask { + width: 100%; + height: 100%; + opacity: 0.5; + position: absolute; + background: white; + top: 0; + left: 0; +} + +.analyst-smile-image { + vertical-align: middle; + padding-bottom: 3px; + width: 24px; +} + +#analyst-deactivation-reasons li { + padding-bottom: 3px; + font-size: 16px; + color: #000000; +} + +@-webkit-keyframes analyst-animatetop { + from {top:-300px; opacity:0} + to {top:0; opacity:1} +} + +@keyframes analyst-animatetop { + from {top:-300px; opacity:0} + to {top:0; opacity:1} +} + +.analyst-modal-close { + color: #48036F; + font-size: 28px; + font-weight: bold; + top: 12px; + position: absolute; + right: 15px; +} + +.analyst-modal-close:hover, +.analyst-modal-close:focus { + color: #000; + text-decoration: none; + cursor: pointer; +} + +.analyst-modal-body {padding: 2px 16px;} + +.analyst-modal-footer { + padding: 6px 16px; + background-color: #FFE773; + color: white; +} + +#analyst-deactivate-modal .question-answer input, textarea { + margin-top: 5px; + width: 100%; +} + +.analyst-btn-primary { + cursor: pointer; + border: none; + display:inline-block; + padding:0.7em 1.4em; + margin:0 0.3em 0.3em 0; + border-radius:0.15em; + box-sizing: border-box; + text-decoration:none; + font-family:'Roboto',sans-serif; + text-transform:uppercase; + font-weight:400; + color:#FFFFFF; + background-color:#9F3ED5; + box-shadow:inset 0 -0.6em 0 -0.35em rgba(0,0,0,0.17); + text-align:center; + position:relative; +} + +.analyst-btn-primary:disabled { + background-color: #AD66D5; + cursor: not-allowed; +} + +.analyst-btn-primary:active{ + top:0.1em; +} +@media all and (max-width:30em){ + .analyst-btn-primary { + display:block; + margin:0.4em auto; + } +} + +.analyst-btn-secondary { + cursor: pointer; + border: none; + display:inline-block; + padding:0.7em 1.4em; + margin:0 0.3em 0.3em 0; + border-radius:0.15em; + box-sizing: border-box; + text-decoration:none; + font-family:'Roboto',sans-serif; + text-transform:uppercase; + font-weight:400; + color:#FFFFFF; + background-color:#6C8CD5; + box-shadow:inset 0 -0.6em 0 -0.35em rgba(0,0,0,0.17); + text-align:center; + position:relative; +} + +.analyst-btn-secondary:disabled { + background-color: #6C8CD5; + cursor: not-allowed; +} + +.analyst-btn-secondary:active{ + top:0.1em; +} +@media all and (max-width:30em){ + .analyst-btn-secondary { + display:block; + margin:0.4em auto; + } +} + +.analyst-notice { + padding-right: 38px; + position: relative; + margin-bottom: 30px !important; +} + +.analyst-notice .analyst-plugin-name { + background-color: #00000024; + padding-left: 7px; + padding-right: 7px; + position: absolute; + top: 100%; + border-radius: 0 0 5px 5px; +} diff --git a/analyst/assets/img/pencil.png b/analyst/assets/img/pencil.png new file mode 100644 index 0000000..45f4f65 Binary files /dev/null and b/analyst/assets/img/pencil.png differ diff --git a/analyst/assets/img/shield_question.png b/analyst/assets/img/shield_question.png new file mode 100644 index 0000000..a187189 Binary files /dev/null and b/analyst/assets/img/shield_question.png differ diff --git a/analyst/assets/img/shield_success.png b/analyst/assets/img/shield_success.png new file mode 100644 index 0000000..531abe9 Binary files /dev/null and b/analyst/assets/img/shield_success.png differ diff --git a/analyst/assets/img/smile.png b/analyst/assets/img/smile.png new file mode 100644 index 0000000..4505378 Binary files /dev/null and b/analyst/assets/img/smile.png differ diff --git a/analyst/assets/index.php b/analyst/assets/index.php new file mode 100644 index 0000000..0e36b55 --- /dev/null +++ b/analyst/assets/index.php @@ -0,0 +1,2 @@ +registerAccount(new Account\Account($options['client-id'], $options['client-secret'], $options['base-dir'])); + } catch (Exception $e) { + error_log('Analyst SDK receive an error: [' . $e->getMessage() . '] Please contact our support at support@analyst.com'); + } + } +} diff --git a/analyst/sdk_resolver.php b/analyst/sdk_resolver.php new file mode 100644 index 0000000..1c041bb --- /dev/null +++ b/analyst/sdk_resolver.php @@ -0,0 +1,79 @@ += 0; + $wpSupported = version_compare($wp_version, $sdk['wp']) >= 0; + + return $phpSupported && $wpSupported; + })); + + // Sort SDK by version in descending order + uasort($supported, function ($x, $y) { + return version_compare($y['sdk'], $x['sdk']); + }); + + // Reset sorted values keys + $supported = array_values($supported); + + if (!isset($supported[0])) { + throw new Exception('There is no SDK which is support current PHP version and WP version'); + } + + // Autoload files for supported SDK + $autoloaderPath = str_replace( + '\\', + '/', + sprintf('%s/autoload.php', $supported[0]['path']) + ); + + require_once $autoloaderPath; + + $loaded = true; + } +} diff --git a/analyst/src/Account/Account.php b/analyst/src/Account/Account.php new file mode 100644 index 0000000..28e8da4 --- /dev/null +++ b/analyst/src/Account/Account.php @@ -0,0 +1,604 @@ +id = $id; + $this->clientSecret = $secret; + + $this->path = $baseDir; + + $this->basePluginPath = plugin_basename($baseDir); + } + + /** + * @return string + */ + public function getPath() + { + return $this->path; + } + + /** + * @param string $path + */ + public function setPath($path) + { + $this->data->setPath($path); + + $this->path = $path; + } + + /** + * @return bool + */ + public function isOptedIn() + { + return $this->isOptedIn; + } + + /** + * @param bool $isOptedIn + */ + public function setIsOptedIn($isOptedIn) + { + $this->data->setIsOptedIn($isOptedIn); + + $this->isOptedIn = $isOptedIn; + } + + /** + * Whether plugin is active + * + * @return bool + */ + public function isActive() + { + return is_plugin_active($this->path); + } + + /** + * @param string $id + */ + public function setId($id) + { + $this->id = $id; + } + + /** + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * @return bool + */ + public function isInstalled() + { + return $this->isInstalled; + } + + /** + * @param bool $isInstalled + */ + public function setIsInstalled($isInstalled) + { + $this->data->setIsInstalled($isInstalled); + + $this->isInstalled = $isInstalled; + } + + /** + * Should register activation and deactivation + * event hooks + * + * @return void + */ + public function registerHooks() + { + register_activation_hook($this->basePluginPath, [&$this, 'onActivePluginListener']); + register_uninstall_hook($this->basePluginPath, ['Account\Account', 'onUninstallPluginListener']); + + $this->addFilter('plugin_action_links', [&$this, 'onRenderActionLinksHook']); + + $this->addAjax('analyst_opt_in', [&$this, 'onOptInListener']); + $this->addAjax('analyst_opt_out', [&$this, 'onOptOutListener']); + $this->addAjax('analyst_plugin_deactivate', [&$this, 'onDeactivatePluginListener']); + $this->addAjax('analyst_install', [&$this, 'onInstallListener']); + $this->addAjax('analyst_skip_install', [&$this, 'onSkipInstallListener']); + $this->addAjax('analyst_install_verified', [&$this, 'onInstallVerifiedListener']); + } + + /** + * Will fire when admin activates plugin + * + * @return void + */ + public function onActivePluginListener() + { + if (!$this->isInstallResolved()) { + DatabaseCache::getInstance()->put('plugin_to_install', $this->id); + } + + if (!$this->isAllowingLogging()) return; + + ActivateRequest::make($this->collector, $this->id, $this->path) + ->execute($this->requestor); + + $this->setIsInstalled(true); + + AccountDataFactory::syncData(); + } + + /** + * Will fire when admin deactivates plugin + * + * @return void + */ + public function onDeactivatePluginListener() + { + if (!$this->isAllowingLogging()) return; + + $question = isset($_POST['question']) ? stripslashes($_POST['question']) : null; + $reason = isset($_POST['reason']) ? stripslashes($_POST['reason']) : null; + + $response = DeactivateRequest::make($this->collector, $this->id, $this->path, $question, $reason) + ->execute($this->requestor); + + // Exit if request failed + if (!$response->isSuccess()) { + wp_send_json_error($response->body); + } + + $this->setIsInstalled(false); + + AccountDataFactory::syncData(); + + wp_send_json_success(); + } + + /** + * Will fire when user opted in + * + * @return void + */ + public function onOptInListener() + { + $response = OptInRequest::make($this->collector, $this->id, $this->path)->execute($this->requestor); + + // Exit if request failed + if (!$response->isSuccess()) { + wp_send_json_error($response->body); + } + + $this->setIsOptedIn(true); + + AccountDataFactory::syncData(); + + wp_die(); + } + + /** + * Will fire when user opted out + * + * @return void + */ + public function onOptOutListener() + { + $response = OptOutRequest::make($this->collector, $this->id, $this->path)->execute($this->requestor); + + // Exit if request failed + if (!$response->isSuccess()) { + wp_send_json_error($response->body); + } + + $this->setIsOptedIn(false); + + AccountDataFactory::syncData(); + + wp_send_json_success(); + } + + /** + * Will fire when user accept opt-in + * at first time + * + * @return void + */ + public function onInstallListener() + { + $cache = DatabaseCache::getInstance(); + + // Set flag to true which indicates that install is resolved + // also remove install plugin id from cache + $this->setIsInstallResolved(true); + $cache->delete('plugin_to_install'); + + $response = InstallRequest::make($this->collector, $this->id, $this->path)->execute($this->requestor); + + // Exit if request failed + if (!$response->isSuccess()) { + wp_send_json_error($response->body); + } + + $this->setIsSigned(true); + + $this->setIsOptedIn(true); + + $factory = NoticeFactory::instance(); + + $message = sprintf('Please confirm your email by clicking on the link we sent to %s. This makes sure you’re not a bot.', $this->collector->getGeneralEmailAddress()); + + $notificationId = uniqid(); + + $notice = Notice::make( + $notificationId, + $this->getId(), + $message, + $this->collector->getPluginName($this->path) + ); + + $factory->addNotice($notice); + + AccountDataFactory::syncData(); + + // Set email confirmation notification id to cache + // se we can extract and remove it when user confirmed email + $cache->put( + sprintf('account_email_confirmation_%s', $this->getId()), + $notificationId + ); + + wp_send_json_success(); + } + + /** + * Will fire when user skipped installation + * + * @return void + */ + public function onSkipInstallListener() + { + // Set flag to true which indicates that install is resolved + // also remove install plugin id from cache + $this->setIsInstallResolved(true); + DatabaseCache::getInstance()->delete('plugin_to_install'); + } + + /** + * Will fire when user delete plugin through admin panel. + * This action will happen if admin at least once + * activated the plugin. + * + * @return void + * @throws \Exception + */ + public static function onUninstallPluginListener() + { + $factory = AccountDataFactory::instance(); + + $pluginFile = substr(current_filter(), strlen( 'uninstall_' )); + + $account = $factory->getAccountDataByBasePath($pluginFile); + + // If account somehow is not found, exit the execution + if (!$account) return; + + $analyst = Analyst::getInstance(); + + $collector = new Collector($analyst); + + $requestor = new ApiRequestor($account->getId(), $account->getSecret(), $analyst->getApiBase()); + + // Just send request to log uninstall event not caring about response + UninstallRequest::make($collector, $account->getId(), $account->getPath())->execute($requestor); + + $factory->sync(); + } + + /** + * Fires when used verified his account + */ + public function onInstallVerifiedListener() + { + $factory = NoticeFactory::instance(); + + $notice = Notice::make( + uniqid(), + $this->getId(), + 'Thank you for confirming your email.', + $this->collector->getPluginName($this->path) + ); + + $factory->addNotice($notice); + + // Remove confirmation notification + $confirmationNotificationId = DatabaseCache::getInstance()->pop(sprintf('account_email_confirmation_%s', $this->getId())); + $factory->remove($confirmationNotificationId); + + AccountDataFactory::syncData(); + + wp_send_json_success(); + } + + /** + * Will fire when wp renders plugin + * action buttons + * + * @param $defaultLinks + * @return array + */ + public function onRenderActionLinksHook($defaultLinks) + { + $customLinks = []; + + $customLinks[] = $this->isOptedIn() + ? 'Opt Out' + : 'Opt In'; + + // Append anchor to find specific deactivation link + if (isset($defaultLinks['deactivate'])) { + $defaultLinks['deactivate'] .= ''; + } + + return array_merge($customLinks, $defaultLinks); + } + + /** + * @return AccountData + */ + public function getData() + { + return $this->data; + } + + /** + * @param AccountData $data + */ + public function setData(AccountData $data) + { + $this->data = $data; + + $this->setIsOptedIn($data->isOptedIn()); + $this->setIsInstalled($data->isInstalled()); + $this->setIsSigned($data->isSigned()); + $this->setIsInstallResolved($data->isInstallResolved()); + } + + /** + * Resolves valid action name + * based on client id + * + * @param $action + * @return string + */ + private function resolveActionName($action) + { + return sprintf('%s_%s', $action, $this->id); + } + + /** + * Register action for current plugin + * + * @param $action + * @param $callback + */ + private function addFilter($action, $callback) + { + $validAction = sprintf('%s_%s', $action, $this->basePluginPath); + + add_filter($validAction, $callback, 10); + } + + /** + * Add ajax action for current plugin + * + * @param $action + * @param $callback + * @param bool $raw Format action ?? + */ + private function addAjax($action, $callback, $raw = false) + { + $validAction = $raw ? $action : sprintf('%s%s', 'wp_ajax_', $this->resolveActionName($action)); + + add_action($validAction, $callback); + } + + /** + * @return bool + */ + public function isSigned() + { + return $this->isSigned; + } + + /** + * @param bool $isSigned + */ + public function setIsSigned($isSigned) + { + $this->data->setIsSigned($isSigned); + + $this->isSigned = $isSigned; + } + + /** + * @return RequestorContract + */ + public function getRequestor() + { + return $this->requestor; + } + + /** + * @param RequestorContract $requestor + */ + public function setRequestor(RequestorContract $requestor) + { + $this->requestor = $requestor; + } + + /** + * @return string + */ + public function getClientSecret() + { + return $this->clientSecret; + } + + /** + * @return Collector + */ + public function getCollector() + { + return $this->collector; + } + + /** + * @param Collector $collector + */ + public function setCollector(Collector $collector) + { + $this->collector = $collector; + } + + /** + * Do we allowing logging + * + * @return bool + */ + public function isAllowingLogging() + { + return $this->isOptedIn; + } + + /** + * @return string + */ + public function getBasePluginPath() + { + return $this->basePluginPath; + } + + /** + * @return bool + */ + public function isInstallResolved() + { + return $this->isInstallResolved; + } + + /** + * @param bool $isInstallResolved + */ + public function setIsInstallResolved($isInstallResolved) + { + $this->data->setIsInstallResolved($isInstallResolved); + + $this->isInstallResolved = $isInstallResolved; + } +} diff --git a/analyst/src/Account/AccountData.php b/analyst/src/Account/AccountData.php new file mode 100644 index 0000000..aee975a --- /dev/null +++ b/analyst/src/Account/AccountData.php @@ -0,0 +1,176 @@ +id; + } + + /** + * @param string $id + */ + public function setId($id) + { + $this->id = $id; + } + + /** + * @param string $path + * @return AccountData + */ + public function setPath($path) + { + $this->path = $path; + return $this; + } + + /** + * @return bool + */ + public function isInstalled() + { + return $this->isInstalled; + } + + /** + * @param bool $isInstalled + */ + public function setIsInstalled($isInstalled) + { + $this->isInstalled = $isInstalled; + } + + /** + * @return bool + */ + public function isOptedIn() + { + return $this->isOptedIn; + } + + /** + * @param bool $isOptedIn + */ + public function setIsOptedIn($isOptedIn) + { + $this->isOptedIn = $isOptedIn; + } + + /** + * @return bool + */ + public function isSigned() + { + return $this->isSigned; + } + + /** + * @param bool $isSigned + */ + public function setIsSigned($isSigned) + { + $this->isSigned = $isSigned; + } + + /** + * @return string + */ + public function getPath() + { + return $this->path; + } + + /** + * @return string + */ + public function getSecret() + { + return $this->secret; + } + + /** + * @param string $secret + */ + public function setSecret($secret) + { + $this->secret = $secret; + } + + /** + * @return bool + */ + public function isInstallResolved() + { + return $this->isInstallResolved; + } + + /** + * @param bool $isInstallResolved + */ + public function setIsInstallResolved($isInstallResolved) + { + $this->isInstallResolved = $isInstallResolved; + } +} diff --git a/analyst/src/Account/AccountDataFactory.php b/analyst/src/Account/AccountDataFactory.php new file mode 100644 index 0000000..7131ba6 --- /dev/null +++ b/analyst/src/Account/AccountDataFactory.php @@ -0,0 +1,125 @@ +sync(); + } + + /** + * Find plugin account data or create fresh one + * + * @param Account $account + * @return AccountData|null + */ + public function resolvePluginAccountData(Account $account) + { + $accountData = $this->findAccountDataById($account->getId()); + + if (!$accountData) { + $accountData = new AccountData(); + + // Set proper default values + $accountData->setPath($account->getPath()); + $accountData->setId($account->getId()); + $accountData->setSecret($account->getClientSecret()); + + array_push($this->accounts, $accountData); + } + + return $accountData; + } + + /** + * Should return account data by base path + * + * @param $basePath + * @return AccountData + */ + public function getAccountDataByBasePath($basePath) + { + foreach ($this->accounts as $iterable) { + $iterableBasePath = plugin_basename($iterable->getPath()); + + if ($iterableBasePath === $basePath) { + return $iterable; + } + } + + return null; + } + + /** + * Return account by id + * + * @param $id + * @return AccountData|null + */ + private function findAccountDataById($id) + { + foreach ($this->accounts as &$iterable) { + if ($iterable->getId() === $id) { + return $iterable; + } + } + + return null; + } +} diff --git a/analyst/src/Analyst.php b/analyst/src/Analyst.php new file mode 100644 index 0000000..c3777d5 --- /dev/null +++ b/analyst/src/Analyst.php @@ -0,0 +1,167 @@ +mutator = new Mutator(); + + $this->accountDataFactory = AccountDataFactory::instance(); + + $this->mutator->initialize(); + + $this->collector = new Collector($this); + + $this->initialize(); + } + + /** + * Initialize rest of application + */ + public function initialize() + { + add_action('init', function () { + $this->collector->loadCurrentUser(); + }); + } + + /** + * Register new account + * + * @param Account $account + * @return Analyst + * @throws \Exception + */ + public function registerAccount($account) + { + // Stop propagation when account is already registered + if ($this->isAccountRegistered($account)) { + return $this; + } + + // Resolve account data from factory + $accountData = $this->accountDataFactory->resolvePluginAccountData($account); + + $account->setData($accountData); + + $account->setRequestor( + $this->resolveRequestorForAccount($account) + ); + + $account->setCollector($this->collector); + + $account->registerHooks(); + + $this->accounts[$account->getId()] = $account; + + return $this; + } + + /** + * Must return version of analyst + * + * @return string + */ + public static function version() + { + $version = require __DIR__ . '/../version.php'; + + return $version['sdk']; + } + + /** + * Is this account registered + * + * @param Account $account + * @return bool + */ + protected function isAccountRegistered($account) + { + return isset($this->accounts[$account->getId()]); + } + + /** + * Resolves requestor for account + * + * @param Account $account + * @return RequestorContract + * @throws \Exception + */ + protected function resolveRequestorForAccount(Account $account) + { + $requestor = new ApiRequestor($account->getId(), $account->getClientSecret(), $this->apiBase); + + // Set SDK version + $requestor->setDefaultHeader( + 'x-analyst-client-user-agent', + sprintf('Analyst/%s', $this->version()) + ); + + return $requestor; + } + + /** + * @return string + */ + public function getApiBase() + { + return $this->apiBase; + } +} diff --git a/analyst/src/ApiRequestor.php b/analyst/src/ApiRequestor.php new file mode 100644 index 0000000..b842d01 --- /dev/null +++ b/analyst/src/ApiRequestor.php @@ -0,0 +1,257 @@ + 'application/json', + 'content-type' => 'application/json' + ]; + + /** + * Prioritized http clients + * + * @var array + */ + protected $availableClients = [ + 'Analyst\Http\WordPressHttpClient', + 'Analyst\Http\CurlHttpClient', + 'Analyst\Http\DummyHttpClient', + ]; + + /** + * ApiRequestor constructor. + * @param $id + * @param $secret + * @param $apiBase + * @throws \Exception + */ + public function __construct($id, $secret, $apiBase) + { + $this->clientId = $id; + $this->clientSecret = $secret; + + $this->setApiBase($apiBase); + + $this->httpClient = $this->resolveHttpClient(); + } + + /** + * Set api base url + * + * @param $url + */ + public function setApiBase($url) + { + $this->apiBase = $url; + } + + /** + * Get request + * + * @param $url + * @param array $headers + * @return mixed + */ + public function get($url, $headers = []) + { + return $this->request('GET', $url, null, $headers); + } + + /** + * Post request + * + * @param $url + * @param $body + * @param array $headers + * @return mixed + */ + public function post($url, $body = [], $headers = []) + { + return $this->request('POST', $url, $body, $headers); + } + + /** + * Put request + * + * @param $url + * @param $body + * @param array $headers + * @return mixed + */ + public function put($url, $body = [], $headers = []) + { + return $this->request('PUT', $url, $body, $headers); + } + + /** + * Delete request + * + * @param $url + * @param array $headers + * @return mixed + */ + public function delete($url, $headers = []) + { + return $this->request('DELETE', $url, null, $headers); + } + + /** + * Make request to api + * + * @param $method + * @param $url + * @param array $body + * @param array $headers + * @return mixed + */ + protected function request($method, $url, $body = [], $headers = []) + { + $fullUrl = $this->resolveFullUrl($url); + + $date = date('r', time()); + + $headers['date'] = $date; + $headers['signature'] = $this->resolveSignature($this->clientSecret, $method, $fullUrl, $body, $date); + + // Lowercase header names + $headers = $this->prepareHeaders( + array_merge($headers, $this->defaultHeaders) + ); + + $response = $this->httpClient->request($method, $fullUrl, $body, $headers); + + // TODO: Check response code and take actions + + return $response; + } + + /** + * Set one default header + * + * @param $header + * @param $value + */ + public function setDefaultHeader($header, $value) + { + $this->defaultHeaders[ + $this->resolveValidHeaderName($header) + ] = $value; + } + + /** + * Resolves supported http client + * + * @return HttpClientContract + * @throws Exception + */ + protected function resolveHttpClient() + { + $clients = array_filter($this->availableClients, $this->guessClientSupportEnvironment()); + + if (!isset($clients[0])) { + throw new Exception('There is no http client which this application can support'); + } + + // Instantiate first supported http client + return new $clients[0]; + } + + /** + * This will filter out clients which is not supported + * by the current environment + * + * @return \Closure + */ + protected function guessClientSupportEnvironment() + { + return function ($client) { + return forward_static_call([$client, 'hasSupport']); + }; + } + + /** + * Resolves valid header name + * + * @param $headerName + * @return string + */ + private function resolveValidHeaderName($headerName) + { + return strtolower($headerName); + } + + /** + * Lowercase header names + * + * @param $headers + * @return array + */ + private function prepareHeaders($headers) + { + return array_change_key_case($headers, CASE_LOWER); + } + + /** + * Sign request + * + * @param $key + * @param $method + * @param $url + * @param $body + * @param $date + * + * @return false|string + */ + private function resolveSignature($key, $method, $url, $body, $date) + { + $string = implode('\n', [$method, $url, md5(json_encode($body)), $date]); + + $contentSecret = hash_hmac('sha256', $string, $key); + + return sprintf('%s:%s', $this->clientId, $contentSecret); + } + + /** + * Compose full url + * + * @param $url + * @return string + */ + private function resolveFullUrl($url) + { + return sprintf('%s/%s', $this->apiBase, trim($url, '/')); + } +} diff --git a/analyst/src/ApiResponse.php b/analyst/src/ApiResponse.php new file mode 100644 index 0000000..ec741c4 --- /dev/null +++ b/analyst/src/ApiResponse.php @@ -0,0 +1,44 @@ +body = $body; + $this->code = $code; + $this->headers = $headers; + } + + /** + * Whether status code is successful + * + * @return bool + */ + public function isSuccess() + { + return $this->code >= 200 && $this->code < 300; + } +} diff --git a/analyst/src/Cache/DatabaseCache.php b/analyst/src/Cache/DatabaseCache.php new file mode 100644 index 0000000..915da4a --- /dev/null +++ b/analyst/src/Cache/DatabaseCache.php @@ -0,0 +1,127 @@ +values = is_array($raw) ? $raw : @unserialize($raw); + + // In case serialization is failed + // make sure values is an array + if (!is_array($this->values)) { + $this->values = []; + } + } + + /** + * Save value with given key + * + * @param string $key + * @param string $value + * + * @return static + */ + public function put($key, $value) + { + $this->values[$key] = $value; + + $this->sync(); + + return $this; + } + + /** + * Get value by given key + * + * @param $key + * + * @param null $default + * @return string + */ + public function get($key, $default = null) + { + $value = isset($this->values[$key]) ? $this->values[$key] : $default; + + return $value; + } + + /** + * @param $key + * + * @return static + */ + public function delete($key) + { + if (isset($this->values[$key])) { + unset($this->values[$key]); + + $this->sync(); + } + + return $this; + } + + /** + * Update cache in DB + */ + protected function sync() + { + update_option(self::OPTION_KEY, serialize($this->values)); + } + + /** + * Should get value and remove it from cache + * + * @param $key + * @param null $default + * @return mixed + */ + public function pop($key, $default = null) + { + $value = $this->get($key); + + $this->delete($key); + + return $value; + } +} diff --git a/analyst/src/Collector.php b/analyst/src/Collector.php new file mode 100644 index 0000000..8fe1495 --- /dev/null +++ b/analyst/src/Collector.php @@ -0,0 +1,217 @@ +sdk = $sdk; + } + + /** + * Load current user into memory + */ + public function loadCurrentUser() + { + $this->user = wp_get_current_user(); + } + + /** + * Get site url + * + * @return string + */ + public function getSiteUrl() + { + return get_option('siteurl'); + } + + /** + * Get current user email + * + * @return string + */ + public function getCurrentUserEmail() + { + return $this->user->user_email; + } + + /** + * Get's email from general settings + * + * @return string + */ + public function getGeneralEmailAddress() + { + return get_option('admin_email'); + } + + /** + * Is this user administrator + * + * @return bool + */ + public function isUserAdministrator() + { + return in_array('administrator', $this->user->roles); + } + + /** + * User name + * + * @return string + */ + public function getCurrentUserName() + { + return $this->user ? $this->user->user_nicename : 'unknown'; + } + + /** + * WP version + * + * @return string + */ + public function getWordPressVersion() + { + global $wp_version; + + return $wp_version; + } + + /** + * PHP version + * + * @return string + */ + public function getPHPVersion() + { + return phpversion(); + } + + /** + * Resolves plugin information + * + * @param string $path Absolute path to plugin + * @return array + */ + public function resolvePluginData($path) + { + if( !function_exists('get_plugin_data') ){ + require_once( ABSPATH . 'wp-admin/includes/plugin.php' ); + } + + return get_plugin_data($path); + } + + /** + * Get plugin name by path + * + * @param $path + * @return string + */ + public function getPluginName($path) + { + $data = $this->resolvePluginData($path); + + return $data['Name']; + } + + /** + * Get plugin version + * + * @param $path + * @return string + */ + public function getPluginVersion($path) + { + $data = $this->resolvePluginData($path); + + return $data['Version'] ? $data['Version'] : null; + } + + /** + * Get server ip + * + * @return string + */ + public function getServerIp() + { + return $_SERVER['SERVER_ADDR']; + } + + /** + * @return string + */ + public function getSDKVersion() + { + return $this->sdk->version(); + } + + /** + * @return string + */ + public function getMysqlVersion() + { + $conn = mysqli_connect(DB_HOST, DB_USER, DB_PASSWORD, DB_NAME); + + $version = mysqli_get_server_info($conn); + + return $version ? $version : 'unknown'; + } + + /** + * @return string + */ + public function getSiteLanguage() + { + return get_locale(); + } + + + /** + * Current WP theme + * + * @return false|string + */ + public function getCurrentThemeName() + { + return wp_get_theme()->get('Name'); + } + + /** + * Get active plugins list + * + * @return array + */ + public function getActivePluginsList() + { + if (!function_exists('get_plugins')) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $allPlugins = get_plugins(); + + $activePluginsNames = array_map(function ($path) use ($allPlugins) { + return $allPlugins[$path]['Name']; + }, get_option('active_plugins')); + + return $activePluginsNames; + } +} diff --git a/analyst/src/Contracts/AnalystContract.php b/analyst/src/Contracts/AnalystContract.php new file mode 100644 index 0000000..83367fe --- /dev/null +++ b/analyst/src/Contracts/AnalystContract.php @@ -0,0 +1,12 @@ + true, + CURLOPT_URL => $url, + CURLOPT_HTTPHEADER => $this->prepareRequestHeaders($headers), + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_FAILONERROR => true, + CURLOPT_HEADER => true, + CURLOPT_TIMEOUT => 30, + ]; + + if ($method === 'POST') { + $options[CURLOPT_POST] = 1; + $options[CURLOPT_POSTFIELDS] = json_encode($body); + } + + $curl = curl_init(); + + curl_setopt_array($curl, $options); + + $response = curl_exec($curl); + + list($rawHeaders, $rawBody) = explode("\r\n\r\n", $response, 2); + + $info = curl_getinfo($curl); + + curl_close($curl); + + $responseHeaders = $this->resolveResponseHeaders($rawHeaders); + $responseBody = json_decode($rawBody, true); + + return new ApiResponse($responseBody, $info['http_code'], $responseHeaders); + } + + /** + * Must return `true` if client is supported + * + * @return bool + */ + public static function hasSupport() + { + return function_exists('curl_version'); + } + + /** + * Modify request headers from key value pair + * to vector array + * + * @param array $headers + * @return array + */ + protected function prepareRequestHeaders ($headers) + { + return array_map(function ($key, $value) { + return sprintf('%s:%s', $key, $value); + }, array_keys($headers), $headers); + } + + /** + * Resolve raw response headers as + * associative array + * + * @param $rawHeaders + * @return array + */ + private function resolveResponseHeaders($rawHeaders) + { + $headers = []; + + foreach (explode("\r\n", $rawHeaders) as $i => $line) { + $parts = explode(': ', $line); + + if (count($parts) === 1) { + continue; + } + + $headers[$parts[0]] = $parts[1]; + } + return $headers; + } +} diff --git a/analyst/src/Http/DummyHttpClient.php b/analyst/src/Http/DummyHttpClient.php new file mode 100644 index 0000000..afd2913 --- /dev/null +++ b/analyst/src/Http/DummyHttpClient.php @@ -0,0 +1,33 @@ +collector = $collector; + $this->id = $pluginId; + $this->path = $path; + } + + /** + * Cast request data to array + * + * @return array + */ + public function toArray() + { + return [ + 'plugin_id' => $this->id, + 'php_version' => $this->collector->getPHPVersion(), + 'wp_version' => $this->collector->getWordPressVersion(), + 'plugin_version' => $this->collector->getPluginVersion($this->path), + 'url' => $this->collector->getSiteUrl(), + 'sdk_version' => $this->collector->getSDKVersion(), + 'ip' => $this->collector->getServerIp(), + 'mysql_version' => $this->collector->getMysqlVersion(), + 'locale' => $this->collector->getSiteLanguage(), + 'current_theme' => $this->collector->getCurrentThemeName(), + 'active_plugins_list' => implode(', ', $this->collector->getActivePluginsList()), + 'email' => $this->collector->getGeneralEmailAddress(), + 'name' => $this->collector->getCurrentUserName() + ]; + } + + /** + * Execute the request + * @param RequestorContract $requestor + * @return ApiResponse + */ + public abstract function execute(RequestorContract $requestor); +} diff --git a/analyst/src/Http/Requests/ActivateRequest.php b/analyst/src/Http/Requests/ActivateRequest.php new file mode 100644 index 0000000..35cda8c --- /dev/null +++ b/analyst/src/Http/Requests/ActivateRequest.php @@ -0,0 +1,42 @@ +post('logger/activate', $this->toArray()); + } + + /** + * Make request instance + * + * @param Collector $collector + * @param $pluginId + * @param $path + * @return static + */ + public static function make(Collector $collector, $pluginId, $path) + { + return new static($collector, $pluginId, $path); + } +} diff --git a/analyst/src/Http/Requests/DeactivateRequest.php b/analyst/src/Http/Requests/DeactivateRequest.php new file mode 100644 index 0000000..39d2052 --- /dev/null +++ b/analyst/src/Http/Requests/DeactivateRequest.php @@ -0,0 +1,64 @@ +question = $question; + $this->answer = $answer; + } + + public function toArray() + { + return array_merge(parent::toArray(), [ + 'question' => $this->question, + 'answer' => $this->answer, + ]); + } + + /** + * Execute the request + * @param RequestorContract $requestor + * @return ApiResponse + */ + public function execute(RequestorContract $requestor) + { + return $requestor->post('logger/deactivate', $this->toArray()); + } +} diff --git a/analyst/src/Http/Requests/InstallRequest.php b/analyst/src/Http/Requests/InstallRequest.php new file mode 100644 index 0000000..773c99c --- /dev/null +++ b/analyst/src/Http/Requests/InstallRequest.php @@ -0,0 +1,38 @@ +post('logger/install', $this->toArray()); + } + + /** + * Make request instance + * + * @param Collector $collector + * @param $pluginId + * @param $path + * @return static + */ + public static function make(Collector $collector, $pluginId, $path) + { + return new static($collector, $pluginId, $path); + } +} diff --git a/analyst/src/Http/Requests/OptInRequest.php b/analyst/src/Http/Requests/OptInRequest.php new file mode 100644 index 0000000..1d38165 --- /dev/null +++ b/analyst/src/Http/Requests/OptInRequest.php @@ -0,0 +1,42 @@ +post('logger/opt-in', $this->toArray()); + } + + /** + * Make request instance + * + * @param Collector $collector + * @param $pluginId + * @param $path + * @return static + */ + public static function make(Collector $collector, $pluginId, $path) + { + return new static($collector, $pluginId, $path); + } +} diff --git a/analyst/src/Http/Requests/OptOutRequest.php b/analyst/src/Http/Requests/OptOutRequest.php new file mode 100644 index 0000000..4396883 --- /dev/null +++ b/analyst/src/Http/Requests/OptOutRequest.php @@ -0,0 +1,40 @@ +post('logger/opt-out', $this->toArray()); + } +} diff --git a/analyst/src/Http/Requests/UninstallRequest.php b/analyst/src/Http/Requests/UninstallRequest.php new file mode 100644 index 0000000..6e19f91 --- /dev/null +++ b/analyst/src/Http/Requests/UninstallRequest.php @@ -0,0 +1,36 @@ +post('logger/uninstall', $this->toArray()); + } +} diff --git a/analyst/src/Http/WordPressHttpClient.php b/analyst/src/Http/WordPressHttpClient.php new file mode 100644 index 0000000..335f869 --- /dev/null +++ b/analyst/src/Http/WordPressHttpClient.php @@ -0,0 +1,61 @@ + json_encode($body), + 'headers' => $headers, + 'method' => $method, + 'timeout' => 30, + ]; + + $response = wp_remote_request($url, $options); + + $body = []; + $responseHeaders = []; + + if ($response instanceof WP_Error) { + $code = $response->get_error_code(); + } else { + /** @var Requests_Utility_CaseInsensitiveDictionary $headers */ + $responseHeaders = $response['headers']->getAll(); + $body = json_decode($response['body'], true); + $code = $response['response']['code']; + } + + + return new ApiResponse( + $body, + $code, + $responseHeaders + ); + } + + /** + * Must return `true` if client is supported + * + * @return bool + */ + public static function hasSupport() + { + return function_exists('wp_remote_request'); + } +} diff --git a/analyst/src/Mutator.php b/analyst/src/Mutator.php new file mode 100644 index 0000000..03ea033 --- /dev/null +++ b/analyst/src/Mutator.php @@ -0,0 +1,103 @@ +factory = NoticeFactory::instance(); + + $this->notices = $this->factory->getNotices(); + + $this->cache = DatabaseCache::getInstance(); + } + + /** + * Register filters all necessary stuff. + * Can be invoked only once. + * + * @return void + */ + public function initialize() + { + $this->registerLinks(); + $this->registerAssets(); + $this->registerHooks(); + } + + /** + * Register all necessary filters and templates + * + * @return void + */ + protected function registerLinks() + { + add_action('admin_footer', function () { + analyst_require_template('optout.php', [ + 'shieldImage' => analyst_assets_url('img/shield_question.png') + ]); + + analyst_require_template('optin.php'); + + analyst_require_template('forms/deactivate.php', [ + 'pencilImage' => analyst_assets_url('img/pencil.png'), + 'smileImage' => analyst_assets_url('img/smile.png'), + ]); + + analyst_require_template('forms/install.php', [ + 'pluginToInstall' => $this->cache->get('plugin_to_install'), + 'shieldImage' => analyst_assets_url('img/shield_success.png'), + ]); + }); + + add_action('admin_notices',function () { + foreach ($this->notices as $notice) { + analyst_require_template('notice.php', ['notice' => $notice]); + } + }); + } + + /** + * Register all assets + */ + public function registerAssets() + { + add_action('admin_enqueue_scripts', function () { + wp_enqueue_style('analyst_custom', analyst_assets_url('/css/customize.css')); + wp_enqueue_script('analyst_custom', analyst_assets_url('/js/customize.js')); + }); + } + + /** + * Register action hooks + */ + public function registerHooks() + { + add_action('wp_ajax_analyst_notification_dismiss', function () { + $this->factory->remove($_POST['id']); + + $this->factory->sync(); + }); + } +} diff --git a/analyst/src/Notices/Notice.php b/analyst/src/Notices/Notice.php new file mode 100644 index 0000000..aed4035 --- /dev/null +++ b/analyst/src/Notices/Notice.php @@ -0,0 +1,121 @@ +setId($id); + $this->setBody($body); + $this->setAccountId($accountId); + $this->setPluginName($pluginName); + } + + /** + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * @param string $id + */ + public function setId($id) + { + $this->id = $id; + } + + /** + * @return string + */ + public function getBody() + { + return $this->body; + } + + /** + * @param string $body + */ + public function setBody($body) + { + $this->body = $body; + } + + /** + * @return string + */ + public function getAccountId() + { + return $this->accountId; + } + + /** + * @param string $accountId + */ + public function setAccountId($accountId) + { + $this->accountId = $accountId; + } + + /** + * @return string|null + */ + public function getPluginName() + { + return $this->pluginName; + } + + /** + * @param string $pluginName + */ + public function setPluginName($pluginName) + { + $this->pluginName = $pluginName; + } +} diff --git a/analyst/src/Notices/NoticeFactory.php b/analyst/src/Notices/NoticeFactory.php new file mode 100644 index 0000000..57619ab --- /dev/null +++ b/analyst/src/Notices/NoticeFactory.php @@ -0,0 +1,130 @@ +sync(); + } + + /** + * @return array + */ + public function getNotices() + { + return $this->notices; + } + + /** + * Filter out notices for certain account + * + * @param $accountId + * @return array + */ + public function getNoticesForAccount($accountId) + { + return array_filter($this->notices, function (Notice $notice) use ($accountId) { + return $notice->getAccountId() === $accountId; + }); + } + + /** + * Add new notice + * + * @param $notice + * + * @return $this + */ + public function addNotice($notice) + { + array_push($this->notices, $notice); + + $this->sync(); + + return $this; + } + + /** + * Find notice by id + * + * @param $id + * @return Notice|null + */ + public function find($id) + { + $notices = array_filter($this->notices, function (Notice $notice) use ($id) { + return $notice->getId() === $id; + }); + + return array_pop($notices); + } + + /** + * Remove notice by it's id + * + * @param $id + */ + public function remove($id) + { + // Get key of notice to remove + $key = array_search( + $this->find($id), + $this->notices + ); + + // Unset notice with key + unset($this->notices[$key]); + + $this->sync(); + } +} diff --git a/analyst/src/helpers.php b/analyst/src/helpers.php new file mode 100644 index 0000000..976039a --- /dev/null +++ b/analyst/src/helpers.php @@ -0,0 +1,84 @@ + +
+ + + diff --git a/analyst/templates/forms/install.php b/analyst/templates/forms/install.php new file mode 100644 index 0000000..3e7f419 --- /dev/null +++ b/analyst/templates/forms/install.php @@ -0,0 +1,113 @@ + + + diff --git a/analyst/templates/notice.php b/analyst/templates/notice.php new file mode 100644 index 0000000..ddc8e76 --- /dev/null +++ b/analyst/templates/notice.php @@ -0,0 +1,10 @@ + ++ =$notice->getPluginName()?> + =$notice->getBody()?> +
+ + +