442 lines
17 KiB
PHP
442 lines
17 KiB
PHP
<?php
|
|
// This file is part of the Studyplan plugin for Moodle
|
|
//
|
|
// Moodle is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// Moodle is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
|
|
/**
|
|
* 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 $premiumsupported = 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::$premiumsupported;
|
|
}
|
|
|
|
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.
|
|
$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++) {
|
|
$p = trim(preg_replace('/\s+/u', ' ', $parts[$i])); // Make sure all unicode spaces are converted to normal spaces before comparing...
|
|
|
|
if ( $p == $STARTHEADER ) {
|
|
$start = $i+1;
|
|
}
|
|
if ($start > 0 && $p == $ENDHEADER) {
|
|
$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::$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;
|
|
}
|
|
}
|
|
|
|
public static function debuginfo() {
|
|
$s = "<pre>";
|
|
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.="</pre>";
|
|
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::$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
|
|
*/
|
|
public static function require_premium($message="premiumfeature:warning") {
|
|
if (! self::enabled()) {
|
|
throw new \moodle_exception($message, "local_treestudyplan");
|
|
}
|
|
}
|
|
|
|
} |