. /** * Determine premium status * @package local_treestudyplan * @copyright 2023 P.M. Kuipers * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace local_treestudyplan; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir.'/externallib.php'); /** * Handle badge information in the same style as the other classes */ class premium extends \external_api { /** * Toggle the variable below to enable support for premium stuff. * If set to false, all premium features will be enabled and no premium settings panel will be visible. * @var bool */ private static $premiumsupported = true; /** * Certficate code * @var string */ private static $premiumcrt = "-----BEGIN CERTIFICATE----- MIIDSzCCAjMCFFlyhmKf1fN7U5lQL/dtlsyP24AQMA0GCSqGSIb3DQEBCwUAMGEx CzAJBgNVBAYTAk5MMRYwFAYDVQQIDA1Ob29yZC1Ib2xsYW5kMRowGAYDVQQKDBFN aXFyYSBFbmdpbmVlcmluZzEeMBwGA1UEAwwVVHJlZVN0dWR5cGxhbiBQcmVtaXVt MCAXDTI0MDIxMDE2MDQwM1oYDzIxMjQwMTE3MTYwNDAzWjBhMQswCQYDVQQGEwJO TDEWMBQGA1UECAwNTm9vcmQtSG9sbGFuZDEaMBgGA1UECgwRTWlxcmEgRW5naW5l ZXJpbmcxHjAcBgNVBAMMFVRyZWVTdHVkeXBsYW4gUHJlbWl1bTCCASIwDQYJKoZI hvcNAQEBBQADggEPADCCAQoCggEBAOD7+Nf5UBYGmIadI+kRM7vSPGA12F6cyZuZ O/JsdCWzZx3cCgVYt29DxHRvFVGrhGGLsoaMY9iXc9LdeO02jKqL3RoPo2kc5moT SNarsKZcGZXgqo5NATmdMLqQpKAy41H0ybgXZDLq5XKs9YIRlkwSpzQTNeP49mOl 48giVX3icbpMw1TdQotalKXAtcs62o+guQJNANpjBRxPXssrmDoNXrJcAtUjNOjx 8M+8tCmwkKwBoK8F3wWxIo04kZ9KILtybMmn4VJJ6SwLEf4StphTIoru8zS7XUt8 3HbV3PsiyYErPlwIcobfcjwZJpub23bzetvxRvhpeIpLhrTGrPMCAwEAATANBgkq hkiG9w0BAQsFAAOCAQEAQwkbP6m3sdQgXEK3mYYZvvs6R/FI9QPu/9ICA+dgfj4y 7wvL0toYYR5oXdhO9At3MYmS+0bFUmqoTS+cxsC4COpEKFbRBWwbJ3NXAw14Hx2U ELLqMZGJNOwNV+3ZdhADrwA++AjUqu144ObrcNUqo4+A4h9R8qj+o0J50Gvwja9R Uh67LsF4Ls8fUtqzpqct94bUl6MPMHlH4qpZlgndmQdgOwLWeQEmM8X3WtSJH90S n8FqBInMBhGu1uz0Qeo09ke0RHRnghP9EXfig/veMegASZeEhFqmS2Bdiy6gqeZ5 Klc5I28bGbvxIV5pnL6ZSjHEDp2WreM8HB0XFJwU+Q== -----END CERTIFICATE-----"; /** * Cached status * @var object */ private static $cachedpremiumstatus = null; /** * Check if premium is supported * @return bool */ public static function supported() { return self::$premiumsupported; } /** * Decrypt encrypted data * @param string $encrypted Encrypted data */ private static function decrypt($encrypted) { // Get the public key. $key = \openssl_get_publickey(self::$premiumcrt); if ($key === false) { throw new \ParseError("Error parsing public key data)"); } // Determine the key size. $keysize = \openssl_pkey_get_details($key)["bits"]; $blocksize = ($keysize / 8); // Bits / 8. Whether padded or not. // Decode data in. $b64 = \base64_decode($encrypted); if ($b64 === false) { throw new \ParseError("Error in base64 decoding"); } $data = \str_split($b64, $blocksize); $decrypted = ""; $i = 0; foreach ($data as $chunk) { if (\openssl_public_decrypt($chunk, $dchunk, $key, \OPENSSL_PKCS1_PADDING)) { $decrypted .= $dchunk; } else { throw new \ParseError("Error decrypting chunk $i ({$blocksize} bytes)"); } $i++; } // Deprecated in PHP 8.0 and up, but included to be compatible with 7.4. if (\PHP_MAJOR_VERSION < 8) { \openssl_pkey_free($key); } return $decrypted; } /** * Trim headers from key data * @param string $data the key dat with headers * @return string Key without headers */ private static function trim_headers($data) { // Headers are repeated in this function for easier testing and copy-pasting into other projects. $startheader = "----- BEGIN ACTIVATION KEY -----"; $endheader = "----- END ACTIVATION KEY -----"; $parts = preg_split("/\r?\n/", \trim($data)); if (count($parts) > 2) { $start = -1; $end = -1; for ($i = 0; $i < count($parts); $i++) { // Make sure all unicode spaces are converted to normal spaces before comparing. $p = trim(preg_replace('/\s+/u', ' ', $parts[$i])); if ($p == $startheader) { $start = $i + 1; } if ($start > 0 && $p == $endheader) { $end = $i; } } if ($start < 0 || $end < 0 || $end - $start <= 0) { throw new \ParseError("Invalid activation key wrappers"); } else { $keyslice = array_slice($parts, $start, $end - $start); return implode("\n", $keyslice); } } else { throw new \ParseError("Invalid activation key"); } } /** * Check if premium status is enabled * @return bool */ public static function enabled() { if (self::$premiumsupported) { $status = self::premiumstatus(); return $status->enabled; } else { return true; } } /** * Check if the premium key includes a specific intent in addition to the treestudyplan. * @param string $intent The intent to search for * @return bool */ public static function has_intent($intent) { $status = self::premiumstatus(); if ($status->enabled) { return \in_array(\strtolower($intent), $status->intents); } else { return false; } } /** * Generate debug info. */ public static function debuginfo() { $s = "
"; try { $activationkey = \get_config("local_treestudyplan", "premium_key"); if (strlen($activationkey) > 0) { $keydata = self::trim_headers($activationkey); $json = self::decrypt($keydata); $s .= "Decoded key data:\n----\n"; $s .= $json . "\n----\n"; $decoded = \json_decode($json, false); $s .= "Read as object:\n----\n"; $s .= \json_encode($decoded, \JSON_PRETTY_PRINT) ."\n----\n"; $status = self::premiumstatus(); $s .= "Parsed premium status block:\n----\n"; $s .= \json_encode($status, \JSON_PRETTY_PRINT) ."\n----\n"; $s .= "Message: " . self::statusdescription() . "\n"; } else { $s .= "Premium key empty"; } } catch (\Throwable $x) { $s .= "!!! " . get_class($x) . ": " . $x->getCode() . " | " . $x->getMessage(). "\n"; $stack = explode("\n", $x->getTraceAsString()); foreach ($stack as $l) { $s .= " " . trim($l) ."\n"; } } $s .= ""; return $s; } /** * Determine, cache and retrieve premium status * @return object */ protected static function premiumstatus() { if (!isset(self::$cachedpremiumstatus)) { // Initialize default object. $o = new \stdClass; $o->enabled = false; $o->intents = []; $o->name = ""; $o->website = ""; $o->expires = ""; $o->expired = false; $o->issued = ""; try { $activationkey = \get_config("local_treestudyplan", "premium_key"); if (strlen($activationkey) > 0) { $keydata = self::trim_headers($activationkey); $json = self::decrypt($keydata); $decoded = \json_decode($json, false); if (is_object($decoded)) { // Copy basic data/. $keys = ["name", "website", "expires", "issued"]; foreach ($keys as $k) { if (isset($decoded->$k)) { $o->$k = $decoded->$k; } } if (!empty($decoded->intent)) { $o->intents = explode(",", $decoded->intent); } // Convert dates to DateTime for. $now = new \DateTime(); $issuedate = new \DateTime($o->issued); if ($o->expires == 'never') { // If expiry date == never. $expirydate = new \DateTime(); // Default to now and add a year. $expirydate->add(new \DateInterval("P1Y")); } else { try { $expirydate = new \DateTime($o->expires); } catch (\Exception $x) { $expirydate = new \DateTime(); // Default to now. } } if (\in_array('treestudyplan', $o->intents) && !empty($o->issued) && self::website_match($o->website) ) { if ($expirydate > $now) { $o->enabled = true; $o->expired = false; } else { $o->expired = true; $o->enabled = false; } // Format dates localized. $o->issued = \userdate($issuedate->getTimestamp(), \get_string('strftimedate', 'langconfig')); if ($o->expires == "never") { $o->expires = \get_string("premium:never", 'local_treestudyplan'); } else { $o->expires = \userdate($expirydate->getTimestamp(), \get_string('strftimedate', 'langconfig')); } } } } } catch (\ParseError $x) { $o->status = \get_string("premium:invalidactivationcontent", "local_treestudyplan"); } self::$cachedpremiumstatus = $o; } return self::$cachedpremiumstatus; } /** * Check if the current site matches the provided key * @param string $key Website pattern to match against */ private static function website_match($key) { global $CFG; $site = $CFG->wwwroot; // Add double slashes to key and site if no scheme is set. // Basically: if no double slashes present before any dots, slashes or @s. if (!\preg_match_all('#^[^./@]*?//#', $key)) { $key = "//".$key; } if (!\preg_match_all('#^[^./@]*?//#', $site)) { $site = "//".$site; } // Use parse_url() to split path and host. $keyurl = (object)\parse_url($key); $siteurl = (object)\parse_url($site); // No match if host is empty on key or site. if (empty($keyurl->host) || empty($siteurl->host)) { return false; } if ($keyurl->host == "*") { // Value * matches all. return true; } // First match the host part. $keyparts = \array_reverse(\explode(".", $keyurl->host)); $siteparts = \array_reverse(\explode(".", $siteurl->host)); // Trim starting www from both parts, since site.domain and www.site.domain should be treated as the same. if (($x = \array_pop($keyparts)) != "www") { \array_push($keyparts, $x); } if (($x = \array_pop($siteparts)) != "www") { \array_push($siteparts, $x); } for ($i = 0; $i < count($keyparts); $i++) { // No match if the site does not have a part, but the key does. Unless the key part is . if (!isset($siteparts[$i])) { if ($keyparts[$i] != "*") { return false; } else { $i++; // Increment $i by one before break, to make sure the comparison following this loop holds. break; // Stop comparison. Host part matches. } } // Now do a proper case insensitive check for matching. // Uses fnmatch to easily handle shell type wildcards. if (! \fnmatch($keyparts[$i], $siteparts[$i], \FNM_CASEFOLD)) { return false; } } // Fail if the site has a deeper subdomain than the key, unless the deepest key subdomain is . if ($keyparts[$i - 1] != '*' && count($siteparts) > ($i)) { return false; } // If we made it here then the host part matches. Now check the path. // If path is /*, matches all subpaths including /. $keypath = empty($keyurl->path) ? "/" : $keyurl->path; $sitepath = empty($siteurl->path) ? "/" : $siteurl->path; // Trim trailing / from both paths before comparison. if (\strlen($sitepath) > 1) { $sitepath = \rtrim($sitepath, "/"); } if (\strlen($keypath) > 1) { $keypath = \rtrim($keypath, "/"); } // Do a case insensitive fnmatch on the site so wildcards are matched too. return \fnmatch($keypath, $sitepath, \FNM_CASEFOLD); } /** * Parameter description for webservice function get_premiumstatus */ public static function get_premiumstatus_parameters(): \external_function_parameters { return new \external_function_parameters([]); } /** * Return value description for webservice function get_premiumstatus */ public static function get_premiumstatus_returns(): \external_description { return new \external_single_structure([ "enabled" => new \external_value(PARAM_BOOL, 'premium status enabled'), "website" => new \external_value(PARAM_TEXT, 'premium registration website'), "name" => new \external_value(PARAM_TEXT, 'premium registration name'), "expires" => new \external_value(PARAM_TEXT, 'premium registration expiry date'), "expired" => new \external_value(PARAM_BOOL, 'premium status expired'), "intents" => new \external_multiple_structure( new \external_value(PARAM_TEXT), 'additional intents included in the license' ), ]); } /** * Get premium status information for webservice * @return object */ public static function get_premiumstatus() { if (self::$premiumsupported) { $status = self::premiumstatus(); $keys = [ "enabled", "website", "name", "expires", "expired", "intents", ]; $result = []; foreach ($keys as $param) { $result[$param] = $status->$param; } return $result; } else { return [ "enabled" => true, "website" => '*', "name" => '*', "expires" => '', "expired" => false, "intents" => [], ]; } } /** * Get a descriptive text of the current premium status * @return string HTML description of premium status. */ public static function statusdescription() { $status = self::premiumstatus(); $msg = new \stdClass; $msg->name = $status->name; $msg->issued = $status->issued; $msg->expires = $status->expires; if ($status->website != "*") { $msg->sitestatus = \get_string("premium:onsite", 'local_treestudyplan', $status); } else { $msg->sitestatus = \get_string("premium:anywhere", 'local_treestudyplan'); } if ($status->enabled) { return \get_string("premium:active", 'local_treestudyplan', $msg); } else if ($status->expired) { return \get_string("premium:expired", 'local_treestudyplan', $msg); } else if (!self::website_match($status->website)) { return \get_string("premium:siteinvalid", 'local_treestudyplan', $status->website); } else { return \get_string("premium:notregistered", 'local_treestudyplan', $msg); } } /** * Throw an error if premium status is not enabled * @param string $message Message translation key to use in exception */ public static function require_premium($message="premiumfeature:warning") { if (! self::enabled()) { throw new \moodle_exception($message, "local_treestudyplan"); } } }