. /** * 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'); use DateTime; use moodle_url; use stdClass; /** * 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. private static $premium_supported = false; 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-----"; private static $cachedpremiumstatus = null; public static function supported() { return self::$premium_supported; } private static function decrypt($encrypted) { // Get the public key. $key = \openssl_get_publickey(self::$premiumcrt); if ($key === false ){ throw new \ValueError("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 \ValueError("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 \ValueError("Error decrypting chunk $i ({$blocksize} bytes)"); } $i++; } // Deprecated in PHP 8.0 and up, but included to be compatible with 7.4. // Wrap in a try/catch in case the function is removed in a later version. try { \openssl_pkey_free($key); } catch (\Exception $x) {} return $decrypted; } private static function trim_headers($data) { // Headers are repeated in this function for easier testing and copy-pasting into other projects. $START_HEADER = "----- BEGIN ACTIVATION KEY -----"; $END_HEADER = "----- 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++) { $p = trim(preg_replace('/\s+/u', ' ', $parts[$i])); // Make sure all unicode spaces are converted to normal spaces before comparing... if ( $p == $START_HEADER ) { $start = $i+1; } if ($start > 0 && $p == $END_HEADER) { $end = $i; } } if ($start < 0 || $end < 0 || $end - $start <= 0) { throw new \ValueError("Invalid activation key wrappers"); } else { $keyslice = array_slice($parts, $start, $end - $start); return implode("\n",$keyslice); } } else { throw new \ValueError("Invalid activation key"); } } /** * Check if premium status is enabled * @return bool */ public static function enabled() { if (self::$premium_supported) { $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; } } 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 .= print_r($decoded,true) ."\n----\n";
$status = self::premiumStatus();
$s .= "Parsed premium status block:\n----\n";
$s .= print_r($status,true) ."\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);
$expirydate = new \DateTime(); // Default to now
if ($o->expires == 'never') {
// If expiry date == never
$expirydate->add(new \DateInterval("P1Y"));
} else {
try {
$expirydate = new \DateTime($o->expires);
} catch (\Exception $x) {}
}
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 (\ValueError $x) {
$o->status = \get_string("premium:invalidactivationcontent","local_treestudyplan");
}
self::$cachedpremiumstatus = $o;
}
return self::$cachedpremiumstatus;
}
/**
* Check if the current site matches the provided key
*/
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 == "*") {
// * 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::$premium_supported) {
$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
*/
public static function require_premium($message="premiumfeature:warning") {
if (! self::enabled()) {
throw new \moodle_exception($message,"local_treestudyplan");
}
}
}