* * If you specify both a token and a site URL this method will also verify that the token * matches the site URL. * * @param string $productSlug * @param string $licenseKey * @param string|null $token Takes precedence over the license key. * @param string|null $siteUrl * @return Wslm_ProductLicense|WP_Error A license object, or WP_Error if the license doesn't exist or doesn't match the URL. */ public function verifyLicenseExists($productSlug, $licenseKey, $token = null, $siteUrl = null) { if ( empty($licenseKey) && empty($token) ) { return new WP_Error('not_found', 'You must specify a license key or a site token.', 400); } $license = $this->loadLicense($licenseKey, $token); if ( empty($license) ) { if ( !empty($token) ) { return new WP_Error('not_found', 'Invalid site token.', 404); } else { return new WP_Error('not_found', 'Invalid license key. Please verify the key or contact the developer for assistance.', 404); } } if ( $license['product_slug'] !== $productSlug ) { if ( $license->hasAddOn($productSlug) ) { //This request is for an add-on, not the main product. That's fine. } else { return new WP_Error('not_found', 'This license key is for a different product.', 404); } } //Make sure the site token was actually issued to that site and not another one. if ( $siteUrl !== null && $token !== null ) { $siteUrl = $this->sanitizeSiteUrl($siteUrl); if ( !$this->isValidUrl($siteUrl) ) { return new WP_Error('invalid_site_url', 'You must specify a valid site URL when using a site token.', 400); } if ( $siteUrl != $this->sanitizeSiteUrl($license['site_url']) ) { return new WP_Error('wrong_site', 'This token is associated with a different site.', 400); } } return $license; } /** * Retrieve a license by license key or token. * * If you specify a token, this method will ignore $licenseKey and just * look for the token. The returned license object will also include * the URL of the site associated with that token in a 'site_url' field. * * @param string|int|null $licenseKeyOrId * @param string|null $token * @throws InvalidArgumentException * @return Wslm_ProductLicense|null A license object, or null if the license doesn't exist. */ public function loadLicense($licenseKeyOrId, $token = null) { if ( !empty($token) ) { $query = "SELECT licenses.*, tokens.site_url FROM `{$this->tablePrefix}licenses` AS licenses JOIN `{$this->tablePrefix}tokens` AS tokens ON licenses.license_id = tokens.license_id WHERE tokens.token = ?"; $params = array($token); } else if ( is_numeric($licenseKeyOrId) && (!is_string($licenseKeyOrId) || (strlen($licenseKeyOrId) < 13)) ) { $query = "SELECT licenses.* FROM `{$this->tablePrefix}licenses` AS licenses WHERE license_id = ?"; $params = array($licenseKeyOrId); } else if ( !empty($licenseKeyOrId) ) { $query = "SELECT licenses.* FROM `{$this->tablePrefix}licenses` AS licenses WHERE license_key = ?"; $params = array($licenseKeyOrId); } else { throw new InvalidArgumentException('You must specify a license key or a site token.'); } $license = $this->db->getRow($query, $params); if ( !empty($license) ) { //Also include the list of sites and add-ons associated with this license. $license['sites'] = $this->loadLicenseSites($license['license_id']); $license['addons'] = $this->loadLicenseAddOns($license['license_id']); $license = new Wslm_ProductLicense($license); $license['renewal_url'] = 'http://adminmenueditor.com/renew-license/'; //TODO: Put this in a config of some sort instead. $license['upgrade_url'] = 'http://adminmenueditor.com/upgrade-license/'; } else { $license = null; } return $license; } protected function loadLicenseSites($licenseId) { $licensedSites = $this->db->getResults( "SELECT site_url, token, issued_on FROM {$this->tablePrefix}tokens WHERE license_id = ?", array($licenseId) ); return $licensedSites; } protected function loadLicenseAddOns($licenseId) { $rows = $this->db->getResults( "SELECT addons.slug, addons.name FROM {$this->tablePrefix}license_addons AS license_addons JOIN {$this->tablePrefix}addons AS addons ON (license_addons.addon_id = addons.addon_id) WHERE license_addons.license_id = ?", array($licenseId) ); $addOns = array(); foreach($rows as $row) { $addOns[$row['slug']] = $row['name']; } return $addOns; } /** * @param Wslm_ProductLicense $license * @param bool $usingToken * @return array */ public function prepareLicenseForOutput($license, $usingToken = false) { $data = $license->getData(); $data['status'] = $license->getStatus(); //Ensure timestamps are formatted consistently. foreach(array('issued_on', 'expires_on') as $datetimeField) { if ( isset($data[$datetimeField]) ) { $data[$datetimeField] = gmdate('c', strtotime($data[$datetimeField])); } } $visibleFields = array_fill_keys(array( 'license_key', 'product_slug', 'status', 'issued_on', 'max_sites', 'expires_on', 'sites', 'site_url', 'error', 'renewal_url', 'addons', ), true); if ( $usingToken ) { $visibleFields = array_merge($visibleFields, array( 'license_key' => false, 'sites' => false, )); } if ( function_exists('apply_filters') ) { $visibleFields = apply_filters('wslm_api_visible_license_fields', $visibleFields); } $data = array_intersect_key($data, array_filter($visibleFields)); return $data; } /** * Record that a specific site just checked for updates. * * @param string $token Unique site token. */ public function logUpdateCheck($token) { $query = "UPDATE {$this->tablePrefix}tokens SET last_update_check = NOW() WHERE token = ?"; $this->db->query($query, array($token)); } /** * Delete tokens associated with sites that haven't checked for updates in the last X days. */ public function deleteUnusedTokens() { $query = "DELETE FROM {$this->tablePrefix}tokens WHERE last_update_check IS NOT NULL AND last_update_check < DATE_SUB(NOW(), INTERVAL ? DAY)"; $this->db->query($query, array($this->tokenDeletionThreshold)); //TODO: Also delete sites that were licensed a long time ago and that have never checked for updates. } protected function actionLicenseSite($productSlug, $licenseKey) { $this->requireRequestMethod('POST'); $license = $this->validateLicenseRequest($productSlug, $licenseKey); //Is the license still valid? if ( !$license->isValid() ) { if ( $license->getStatus() == 'expired' ) { $renewalUrl = $license->get('renewal_url'); $this->outputError( 'expired_license', sprintf( 'This license key has expired. You can still use the plugin (without activating the key), but you will need to %1$srenew the license%2$s to receive updates.', $renewalUrl ? '' : '', $renewalUrl ? '' : '' ), 400 ); } else { $this->outputError('invalid_license', 'This license key is invalid or has expired.', 400); } return; } $siteUrl = isset($this->post['site_url']) ? strval($this->post['site_url']) : ''; if ( !$this->isValidUrl($siteUrl) ) { $this->outputError('site_url_expected', "Missing or invalid site URL.", 400); return; } $siteUrl = $this->sanitizeSiteUrl($siteUrl); //Maybe the site is already licensed? $token = $this->wpdb->get_var($this->wpdb->prepare( "SELECT token FROM {$this->tablePrefix}tokens WHERE site_url = %s AND license_id = %d", $siteUrl, $license['license_id'] )); if ( !empty($token) ) { $this->outputResponse(array( 'site_token' => $token, 'license' => $this->prepareLicenseForOutput($license), )); return; } //Check the number of sites already licensed and see if we can add another one. if ( $license['max_sites'] !== null ) { $licensedSiteCount = $this->wpdb->get_var($this->wpdb->prepare( "SELECT COUNT(*) FROM {$this->tablePrefix}tokens WHERE license_id = %d", $license['license_id'] )); if ( intval($licensedSiteCount) >= intval($license['max_sites']) ) { $upgradeUrl = $license->get('upgrade_url'); $this->outputError( 'max_sites_reached', sprintf( 'You have reached the maximum number of sites allowed by your license. ' . 'To activate it on another site, you need to either %1$supgrade the license%2$s ' . 'or remove it from one of your existing sites in the "Manage Sites" tab.', $upgradeUrl ? '' : '', $upgradeUrl ? '' : '', 'ame-open-tab-manage-sites' ), 400, $this->prepareLicenseForOutput($license, false) ); return; } } //If the site was already associated with another key, remove that association. Only one key per site. //Local sites are an exception because they're not unique. Many developers use http://localhost/. if ( !$this->isLocalHost($siteUrl) ) { $otherToken = $this->wpdb->get_var($this->wpdb->prepare( "SELECT tokens.token FROM {$this->tablePrefix}tokens AS tokens JOIN {$this->tablePrefix}licenses AS licenses ON tokens.license_id = licenses.license_id WHERE tokens.site_url = %s AND licenses.product_slug = %s AND licenses.license_id <> %d", $siteUrl, $productSlug, $license['license_id'] )); if ( !empty($otherToken) ) { $this->wpdb->delete($this->tablePrefix . 'tokens', array('token' => $otherToken)); } } //Everything checks out, lets create a new token. $token = $this->generateRandomString(32); $this->wpdb->insert( $this->tablePrefix . 'tokens', array( 'license_id' => $license['license_id'], 'token' => $token, 'site_url' => $siteUrl, 'issued_on' => date('Y-m-d H:i:s'), ) ); //Reload the license to ensure it includes the changes we just made. $license = $this->loadLicense($licenseKey); $response = array( 'site_token' => $token, 'license' => $this->prepareLicenseForOutput($license), ); //Add a notice to expired licenses. if ( $license->getStatus() === 'expired' ) { $renewalUrl = $license->get('renewal_url'); $response['notice'] = array( 'message' => sprintf( 'Your access to updates and support has expired. To receive updates, please %1$srenew your license%2$s.', $renewalUrl ? '' : '', $renewalUrl ? '' : '' ), 'class' => 'notice-warning' ); } $this->outputResponse($response); } public function generateRandomString($length, $alphabet = null) { if ( $alphabet === null ) { $alphabet = 'ABDEFGHJKLMNOPQRSTVWXYZ0123456789'; //U and C intentionally left out to lessen the chances of generating an obscene string. //"I" was left out because it's visually similar to 1. } $maxIndex = strlen($alphabet) - 1; $str = ''; for ($i = 0; $i < $length; $i++) { $str .= substr($alphabet, rand(0, $maxIndex), 1); } return $str; } protected function isLocalHost($siteUrl) { $host = @parse_url($siteUrl, PHP_URL_HOST); if ( empty($host) ) { return false; } return (preg_match('/\.?(localhost|local|dev)$/', $host) == 1); } protected function actionUnlicenseSite($productSlug, $licenseKey = null, $token = null) { $this->requireRequestMethod('POST'); $license = $this->validateLicenseRequest($productSlug, $licenseKey, $token, $this->post); $siteUrl = $this->sanitizeSiteUrl(isset($this->post['site_url']) ? strval($this->post['site_url']) : ''); $usingToken = !empty($token); $response = array( 'license' => $this->prepareLicenseForOutput($license, $usingToken), ); if ( !$usingToken ) { $token = $this->wpdb->get_var($this->wpdb->prepare( "SELECT token FROM `{$this->tablePrefix}tokens` WHERE site_url = %s AND license_id = %d", $siteUrl, $license['license_id'] )); } if ( empty($token) ) { //The user tried to un-license a site that wasn't licensed in the first place. Still, //the desired end state - site not licensed - has ben achieved, so treat it as a success. $response['notice'] = "The specified site wasn't licensed in the first place."; } else { $this->wpdb->delete( $this->tablePrefix . 'tokens', array( 'token' => $token, 'license_id' => $license['license_id'], ) ); //Reload the license to ensure the site list is correct. $license = $this->loadLicense($license['license_key']); $response['license'] = $this->prepareLicenseForOutput($license, $usingToken); $response = array_merge($response, array( 'site_token_removed' => $token, 'site_url' => $siteUrl )); } $this->outputResponse($response); } protected function requireRequestMethod($httpVerbs) { $httpVerbs = array_map('strtoupper', (array)$httpVerbs); if ( !in_array(strtoupper($_SERVER['REQUEST_METHOD']), $httpVerbs) ) { header('Allow: '. implode(', ', $httpVerbs)); $this->outputError( 'invalid_method', 'This resource does not support the ' . $_SERVER['REQUEST_METHOD'] . ' method.', 405 ); exit; } } protected function outputError($code, $message, $httpStatus = null, $licenseData = null) { $httpStatus = (isset($httpStatus) && is_numeric($httpStatus)) ? $httpStatus : 500; $response = array('error' => array('code' => $code, 'message' => $message),); if ( isset($licenseData) ) { $response['license'] = $licenseData; } $this->outputResponse($response, $httpStatus); } private function outputResponse($body, $httpStatus = 200) { status_header($httpStatus); header('Content-Type: application/json'); echo wsh_pretty_json(json_encode($body)); } private function isValidUrl($url) { $parts = @parse_url($url); return !empty($url) && !empty($parts) && isset($parts['host']); } private function sanitizeSiteUrl($url) { //Convert Punycode domain names to UTF-8. $domain = @parse_url($url, PHP_URL_HOST); if ( $domain && preg_match('/xn--./', $domain) && is_callable('idn_to_utf8') ) { /** @noinspection PhpComposerExtensionStubsInspection */ $converted = idn_to_utf8($domain); if ($converted && ($converted !== $domain)) { $url = preg_replace_callback( '/' . preg_quote($domain, '/') . '/', function() use ($converted) { return $converted; }, $url, 1 ); } } return rtrim($url, '/'); } /** * Retrieve a customer's licenses. You can optionally specify a slug to retrieve only * licenses for that product. * * @param int $customerId * @param string|null $productSlug * @return Wslm_ProductLicense[] An array of licenses ordered by status and expiry (newest valid licenses first). */ public function getCustomerLicenses($customerId, $productSlug = null) { //This UNION hack is due to the fact that, when dealing with businesses, different people //can renew or upgrade the same license. People have different emails, so they count as different //customers. We need all of them to be able to access the license. $query = $this->wpdb->prepare( "SELECT customerLicenses.* FROM ( SELECT licenses.* FROM {$this->tablePrefix}licenses AS licenses WHERE (licenses.customer_id = %d) UNION DISTINCT SELECT order_licenses.* FROM {$this->tablePrefix}orders AS orders JOIN {$this->tablePrefix}licenses AS order_licenses ON (orders.license_id = order_licenses.license_id) WHERE (orders.customer_id = %d) ) AS customerLicenses WHERE 1", array($customerId, $customerId) ); if ( $productSlug !== null ) { $query .= $this->wpdb->prepare(' AND product_slug=%s', $productSlug); } $query .= ' ORDER BY status ASC, expires_on IS NULL DESC, expires_on DESC'; //Valid licenses first. $rows = $this->wpdb->get_results($query, ARRAY_A); $licenses = array(); if ( is_array($rows) ) { foreach($rows as $row) { $row['addons'] = $this->loadLicenseAddOns($row['license_id']); $licenses[] = new Wslm_ProductLicense($row); } } return $licenses; } public function addRewriteRules() { $apiRewriteRules = array( 'licensing_api/products/([^/\?]+)/licenses/bytoken/([^/\?]+)(?:/([a-z0-9_]+))?' => 'index.php?licensing_api=1&license_product=$matches[1]&license_token=$matches[2]&license_action=$matches[3]', 'licensing_api/products/([^/\?]+)/licenses/([^/\?]+)(?:/([a-z0-9_]+))?' => 'index.php?licensing_api=1&license_product=$matches[1]&license_key=$matches[2]&license_action=$matches[3]', ); foreach ($apiRewriteRules as $pattern => $redirect) { add_rewrite_rule($pattern, $redirect, 'top'); } //Flush the rules only if they didn't exist before. $wp_rewrite = $GLOBALS['wp_rewrite']; /** @var WP_Rewrite $wp_rewrite */ $missingRules = array_diff_assoc($apiRewriteRules, $wp_rewrite->wp_rewrite_rules()); if ( !empty($missingRules) ) { flush_rewrite_rules(false); } } public function addQueryVars($queryVariables) { $licensingVariables = array( 'licensing_api', 'license_product', 'license_key', 'license_token', 'license_action' ); $queryVariables = array_merge($queryVariables, $licensingVariables); return $queryVariables; } }