diff --git a/amd/build/page-studyplan-report.min.js.map b/amd/build/page-studyplan-report.min.js.map
index 8e33fcd..14e2e73 100644
--- a/amd/build/page-studyplan-report.min.js.map
+++ b/amd/build/page-studyplan-report.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"page-studyplan-report.min.js","sources":["../src/page-studyplan-report.js"],"sourcesContent":["/*eslint no-var: \"error\" */\n/*eslint no-unused-vars: \"off\" */\n/*eslint linebreak-style: \"off\" */\n/*eslint no-trailing-spaces: \"off\" */\n/*eslint-env es6*/\n// Put this file in path/to/plugin/amd/src\n// You can call it anything you like\n\nimport {call} from 'core/ajax';\nimport notification from 'core/notification';\n\nimport Vue from './vue/vue';\n\nimport Debugger from './util/debugger';\nimport {load_strings} from './util/string-helper';\nimport {ProcessStudyplan} from './studyplan-processor';\nimport {studyplanTiming} from './util/date-helper';\n\nimport TSComponents from './treestudyplan-components';\nimport ModalComponents from './modedit-modal';\nVue.use(ModalComponents);\n\nimport PortalVue from './portal-vue/portal-vue.esm';\nVue.use(PortalVue);\nimport BootstrapVue from './bootstrap-vue/bootstrap-vue';\nVue.use(BootstrapVue);\n\n\nlet debug = new Debugger(\"treestudyplanviewer\");\n\nlet strings = load_strings({\n studyplan: {\n studyplan_select_placeholder: 'studyplan_select_placeholder',\n },\n});\n\n/**\n * Initialize the Page\n * @param {Number} studyplanid The id of the studyplan we need to view \n * @param {Number} period The id of the studyplan we need to view \n */\nexport function init(studyplanid,period) {\n // Make sure the id's are numeric and integer\n if (undefined === studyplanid || !Number.isInteger(Number(studyplanid)) ){ \n studyplanid = 0;\n } else {\n studyplanid = Number(studyplanid);\n } // ensure a numeric value instead of string.\n\n const app = new Vue({\n el: '#root',\n data: {\n\n },\n async mounted() {\n \n },\n computed: {\n \n },\n methods: {\n \n },\n });\n}\n"],"names":["studyplanid","period","undefined","Number","isInteger","Vue","el","data","computed","methods","use","ModalComponents","PortalVue","BootstrapVue","Debugger","studyplan","studyplan_select_placeholder"],"mappings":"onBAyCqBA,YAAYC,QAKzBD,iBAHAE,IAAcF,aAAgBG,OAAOC,UAAUD,OAAOH,cAGxCG,OAAOH,aAFP,EAKN,IAAIK,aAAI,CAChBC,GAAI,QACJC,KAAM,qBAMNC,SAAU,GAGVC,QAAS,qXAxCbC,IAAIC,oCAGJD,IAAIE,iCAEJF,IAAIG,uBAGI,IAAIC,kBAAS,wBAEX,8BAAa,CACvBC,UAAW,CACPC,6BAA8B"}
\ No newline at end of file
+{"version":3,"file":"page-studyplan-report.min.js","sources":["../src/page-studyplan-report.js"],"sourcesContent":["/*eslint no-var: \"error\" */\n/*eslint no-unused-vars: \"off\" */\n/*eslint linebreak-style: \"off\" */\n/*eslint no-trailing-spaces: \"off\" */\n/*eslint-env es6*/\n\nimport {call} from 'core/ajax';\nimport notification from 'core/notification';\n\nimport Vue from './vue/vue';\n\nimport Debugger from './util/debugger';\nimport {load_strings} from './util/string-helper';\nimport {ProcessStudyplan} from './studyplan-processor';\nimport {studyplanTiming} from './util/date-helper';\n\nimport TSComponents from './treestudyplan-components';\nimport ModalComponents from './modedit-modal';\nVue.use(ModalComponents);\n\nimport PortalVue from './portal-vue/portal-vue.esm';\nVue.use(PortalVue);\nimport BootstrapVue from './bootstrap-vue/bootstrap-vue';\nVue.use(BootstrapVue);\n\n\nlet debug = new Debugger(\"treestudyplanviewer\");\n\nlet strings = load_strings({\n studyplan: {\n studyplan_select_placeholder: 'studyplan_select_placeholder',\n },\n});\n\n/**\n * Initialize the Page\n * @param {Number} studyplanid The id of the studyplan we need to view \n * @param {Number} period The id of the studyplan we need to view \n */\nexport function init(studyplanid,period) {\n // Make sure the id's are numeric and integer\n if (undefined === studyplanid || !Number.isInteger(Number(studyplanid)) ){ \n studyplanid = 0;\n } else {\n studyplanid = Number(studyplanid);\n } // ensure a numeric value instead of string.\n\n const app = new Vue({\n el: '#root',\n data: {\n\n },\n async mounted() {\n \n },\n computed: {\n \n },\n methods: {\n \n },\n });\n}\n"],"names":["studyplanid","period","undefined","Number","isInteger","Vue","el","data","computed","methods","use","ModalComponents","PortalVue","BootstrapVue","Debugger","studyplan","studyplan_select_placeholder"],"mappings":"onBAuCqBA,YAAYC,QAKzBD,iBAHAE,IAAcF,aAAgBG,OAAOC,UAAUD,OAAOH,cAGxCG,OAAOH,aAFP,EAKN,IAAIK,aAAI,CAChBC,GAAI,QACJC,KAAM,qBAMNC,SAAU,GAGVC,QAAS,qXAxCbC,IAAIC,oCAGJD,IAAIE,iCAEJF,IAAIG,uBAGI,IAAIC,kBAAS,wBAEX,8BAAa,CACvBC,UAAW,CACPC,6BAA8B"}
\ No newline at end of file
diff --git a/amd/build/util/premium.min.js b/amd/build/util/premium.min.js
new file mode 100644
index 0000000..72ea44c
--- /dev/null
+++ b/amd/build/util/premium.min.js
@@ -0,0 +1,3 @@
+define("local_treestudyplan/util/premium",["exports","core/ajax"],(function(_exports,_ajax){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.enabled=function(){return!!premiumstatus.enabled};let premiumstatus={enabled:!1,website:"",name:"",expires:""}}));
+
+//# sourceMappingURL=premium.min.js.map
\ No newline at end of file
diff --git a/amd/build/util/premium.min.js.map b/amd/build/util/premium.min.js.map
new file mode 100644
index 0000000..1e9a6b8
--- /dev/null
+++ b/amd/build/util/premium.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"premium.min.js","sources":["../../src/util/premium.js"],"sourcesContent":["/*eslint no-var: \"error\" */\n/*eslint no-unused-vars: \"off\" */\n/*eslint linebreak-style: \"off\" */\n/*eslint no-trailing-spaces: \"off\" */\n/*eslint-env es6*/\n\nimport {call} from 'core/ajax';\n\n// Prepare default value.\nlet premiumstatus = {\n enabled: false,\n website: \"\",\n name: \"\",\n expires: \"\",\n};\n\n/**\n * Check if premium status is enabled.\n * @returns {Object} The map with strings loaded in\n */\nexport function enabled (){\n return !!premiumstatus.enabled;\n}\n"],"names":["premiumstatus","enabled","website","name","expires"],"mappings":"wLAqBaA,cAAcC,aAZvBD,cAAgB,CAChBC,SAAS,EACTC,QAAS,GACTC,KAAM,GACNC,QAAS"}
\ No newline at end of file
diff --git a/amd/src/page-studyplan-report.js b/amd/src/page-studyplan-report.js
index 6901584..ee00ba9 100644
--- a/amd/src/page-studyplan-report.js
+++ b/amd/src/page-studyplan-report.js
@@ -3,8 +3,6 @@
/*eslint linebreak-style: "off" */
/*eslint no-trailing-spaces: "off" */
/*eslint-env es6*/
-// Put this file in path/to/plugin/amd/src
-// You can call it anything you like
import {call} from 'core/ajax';
import notification from 'core/notification';
diff --git a/amd/src/util/premium.js b/amd/src/util/premium.js
new file mode 100644
index 0000000..f2561c8
--- /dev/null
+++ b/amd/src/util/premium.js
@@ -0,0 +1,23 @@
+/*eslint no-var: "error" */
+/*eslint no-unused-vars: "off" */
+/*eslint linebreak-style: "off" */
+/*eslint no-trailing-spaces: "off" */
+/*eslint-env es6*/
+
+import {call} from 'core/ajax';
+
+// Prepare default value.
+let premiumstatus = {
+ enabled: false,
+ website: "",
+ name: "",
+ expires: "",
+};
+
+/**
+ * Check if premium status is enabled.
+ * @returns {Object} The map with strings loaded in
+ */
+export function enabled (){
+ return !!premiumstatus.enabled;
+}
diff --git a/classes/premium.php b/classes/premium.php
new file mode 100644
index 0000000..e73f8de
--- /dev/null
+++ b/classes/premium.php
@@ -0,0 +1,345 @@
+.
+/**
+ * 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 {
+
+ 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;
+
+ 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++) {
+ if ( $parts[$i] == $START_HEADER ) {
+ $start = $i+1;
+ }
+ if ($start > 0 && $parts[$i] == $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");
+ }
+ }
+
+
+ public static function enabled() {
+ $status = self::premiumStatus();
+ return $status->enabled;
+ }
+
+ protected static function premiumStatus() {
+ if (!isset(self::$cachedpremiumstatus)) {
+ // Initialize default object.
+ $o = new \stdClass;
+ $o->enabled = false;
+ $o->intent = "";
+ $o->name = "";
+ $o->website = "";
+ $o->expires = "";
+ $o->expired = false;
+ $o->issued = "";
+ $o->message = \get_string("premium:notregistered","local_treestudyplan");
+
+ $activationkey = \get_config("local_treestudyplan","premium_key");
+ if (strlen($activationkey) > 0) {
+ $activationkey;
+
+ try {
+ $keydata = self::trim_headers($activationkey);
+ $json = self::decrypt($keydata);
+ $decoded = \json_decode($json,false);
+
+ if (is_object($decoded)) {
+
+ $keys = ["intent","name","website","expires","issued"];
+ foreach ( $keys as $k) {
+ if (isset($decoded->$k)) {
+ $o->$k = $decoded->$k;
+ }
+ }
+
+ // Convert dates to user dates
+ $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 ($o->intent == 'treestudyplan'
+ && !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;
+ }
+
+ 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,shashes 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)) {
+ if(empty($keyurl->host)){
+ print "\e[91mError: no host in keyurl '{$key}'\n";
+ print_r($keyurl);
+ print "\e[0m";
+ }
+ if(empty($siteurl->host)){
+ print "\e[91mError: no host in siteurl '{$site}'\n";
+ print_r($siteurl);
+ print "\e[0m";
+ }
+ return false;
+ }
+
+ // 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);
+ }
+
+ 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'),
+ ]);
+ }
+
+ /**
+ * Get premium status information for webservice
+ * @return object
+ */
+ public static function get_premiumstatus() {
+ $status = self::premiumStatus();
+ $keys = [
+ "enabled",
+ "website",
+ "name",
+ "expires",
+ "expired",
+ ];
+
+ $result = [];
+ foreach ( $keys as $param) {
+ $result[$param] = $status->$param;
+ }
+ return $result;
+ }
+
+ 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 = "";
+ }
+
+ if($status->enabled) {
+ return \get_string("premium:active",'local_treestudyplan',$msg);
+ } else if ($status->expired) {
+ return \get_string("premium:expired",'local_treestudyplan',$msg);
+ } else {
+ return \get_string("premium:notregistered",'local_treestudyplan',$msg);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/db/services.php b/db/services.php
index 37a086e..32705f8 100644
--- a/db/services.php
+++ b/db/services.php
@@ -710,4 +710,16 @@ $functions = [
'capabilities' => 'local/treestudyplan:viewuserreports',
'loginrequired' => true,
],
+ /***************************
+ * Premium status functions
+ ***************************/
+ 'local_treestudyplan_premiumstatus' => [ // Web service function name.
+ 'classname' => '\local_treestudyplan\premium', // Class containing the external function.
+ 'methodname' => 'get_premiumstatus', // External function name.
+ 'description' => 'Retrieve premium status info',
+ 'type' => 'read', // Database rights of the web service function (read, write).
+ 'ajax' => true,
+ 'capabilities' => '',
+ 'loginrequired' => false,
+ ],
];
diff --git a/lang/en/local_treestudyplan.php b/lang/en/local_treestudyplan.php
index 44ab319..e5e759e 100644
--- a/lang/en/local_treestudyplan.php
+++ b/lang/en/local_treestudyplan.php
@@ -433,4 +433,25 @@ $string["individuals"] = 'Individuals';
$string["error:cannotviewcategory"] = 'Error: You do not have access to view this category or context: {$a}';
$string["error:nostudyplanviewaccess"] = 'Error: You do not have access to view study plans in this category or context: {$a}';
$string["error:nostudyplaneditaccess"] = 'Error: You do not have access to manage study plans in this category or context: {$a}';
-$string["error:nocategoriesvisible"] = 'Error: You have no viewing permissions in any category. Therefore the course list remains empty.';
\ No newline at end of file
+$string["error:nocategoriesvisible"] = 'Error: You have no viewing permissions in any category. Therefore the course list remains empty.';
+
+$string["premium:never"] = 'never';
+$string["premium:onsite"] = 'for use on site {$a->website}';
+$string["premium:active"] = 'Premium access enabled.
Registered to {$a->name} {$a->sitestatus}
Expires {$a->expires}';
+$string["premium:notregistered"] = 'Premium access disabled ';
+$string["premium:invalidactivationcontent"] = 'Premium activation key not recognized';
+$string["premium:expired"] = 'Premium access expired on {$a->expires}
Was registered to {$a->name} {$a->sitestatus}';
+$string["settingspage_premium"] = 'Premium registration';
+$string["setting_premium_heading"] = 'Premium features';
+$string["settingdesc_premium_heading"] = 'To access premium features, you need a registration key.
+
Premium features include:
+