From 58e35e747830b248a70c53b7ac629e9037200e17 Mon Sep 17 00:00:00 2001
From: Fritz Ray <fritz.ray@eduworks.com>
Date: Tue, 17 Sep 2024 16:06:48 -0700
Subject: [PATCH] Server fixes and xapi caching

---
 .eslintrc.js                         |   4 +
 src/main/server/adapter/xapi/xapi.js | 186 +++++++++++++++++----------
 src/main/server/skyRepo.js           |  27 ++--
 3 files changed, 137 insertions(+), 80 deletions(-)

diff --git a/.eslintrc.js b/.eslintrc.js
index f1746e521..5669f16c7 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -22,5 +22,9 @@ module.exports = {
         'prefer-const': 'off',
         'require-jsdoc': 'off',
         'camelcase': 'off',
+        'no-var': 'off',
+        'curly': 'off',
+        'quotes': 'off',
+        'semi': 'off',
     },
 };
diff --git a/src/main/server/adapter/xapi/xapi.js b/src/main/server/adapter/xapi/xapi.js
index c37df1dd5..51711cb7f 100644
--- a/src/main/server/adapter/xapi/xapi.js
+++ b/src/main/server/adapter/xapi/xapi.js
@@ -10,7 +10,7 @@ var xapiConfig = function () {
             xapiHostname: "",
             xapiAuth: "",
             enabled: false
-        }),xapiConfigFilePath);
+        }), xapiConfigFilePath);
     this.xapiConfig = JSON.parse(fileToString(fileLoad(xapiConfigFilePath)));
     return this.xapiConfig;
 }
@@ -19,7 +19,7 @@ var xapiConfigAutoExecute = xapiConfig;
 var xapiEndpoint = async function (more, since, config) {
     var endpoint;
     if (config) {
-        endpoint = config.xapiEndpoint + "statements?format=exact&limit=0";
+        endpoint = config.xapiEndpoint + "statements?format=exact&limit=10";
     } else {
         endpoint = xapiConfig.call(this).xapiEndpoint + "statements?format=exact&limit=0";
     }
@@ -51,32 +51,32 @@ var getMbox = function (agentObject) {
     return null;
 }
 
+let personCache = {};
+setInterval(function () {
+    personCache = {};
+}, 1000 * 60 * 60);
 var personFromEmail = async function (mbox) {
     if (mbox == null) return null;
+    if (personCache[mbox] != null) return personCache[mbox];
     var person = null;
     mbox = mbox.replace("mailto:", "");
-    if (mbox.indexOf("@") != -1)
-    {
+    if (mbox.indexOf("@") != -1) {
         let people = null;
-        people = await loopback.repositorySearch(global.repo,"@type:Person AND email:\"" + mbox + "\"",{});
+        people = await loopback.repositorySearch(global.repo, "@type:Person AND email:\"" + mbox + "\"", {});
         if (people != null) {
-            if (people.length == 1)
+            if (people.length >= 1)
                 person = people[0];
-            else if (people.length > 1)
-                global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.ERROR, "XapiPersonFromEmail", `Cannot generate xAPI statements for ${mbox} -- too many people with that email.`);
         }
     }
-    else
-    {
+    else {
         let people = null;
-        people = await loopback.repositorySearch(global.repo,"@type:Person AND (identifier:\"" + mbox + "\" OR username:\"" + mbox + "\")",{});
+        people = await loopback.repositorySearch(global.repo, "@type:Person AND (identifier:\"" + mbox + "\" OR username:\"" + mbox + "\")", {});
         if (people != null) {
-            if (people.length == 1)
+            if (people.length >= 1)
                 person = people[0];
-            else if (people.length > 1)
-                global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.ERROR, "XapiPersonFromEmail", `Cannot generate xAPI statements for ${mbox} -- too many people with that identifier.`);
         }
     }
+    if (person != null) personCache[mbox] = person;
     return person;
 }
 
@@ -94,25 +94,32 @@ var pkFromMbox = async function (xapiPerson) {
     return pk;
 }
 
+let alignedCompetenciesCache = {};
+setInterval(function () {
+    alignedCompetenciesCache = {};
+}, 1000 * 60 * 60);
 var getAlignedCompetencies = async function (objectId) {
     var results = [];
-    let creativeWorks = await loopback.repositorySearch(global.repo,"@type:CreativeWork AND url:\"" + objectId + "\"",{});
-    for (let creativeWork of creativeWorks)
-    {
+    if (alignedCompetenciesCache[objectId] != null)
+        return alignedCompetenciesCache[objectId];
+    let creativeWorks = await loopback.repositorySearch(global.repo, "@type:CreativeWork AND url:\"" + objectId + "\"", {});
+    for (let creativeWork of creativeWorks) {
         if (creativeWork.educationalAlignment == null) continue;
         if (!EcArray.isArray(creativeWork.educationalAlignment))
             creativeWork.educationalAlignment = [creativeWork.educationalAlignment];
         for (var i = 0; i < creativeWork.educationalAlignment.length; i++)
             results.push(creativeWork.educationalAlignment[i]);
     }
+    alignedCompetenciesCache[objectId] = results;
     return results;
 }
 
-var xapiStatement = async function (s) {
+let defaultAuthority = null;
+var xapiStatement = async function (s, accm) {
     if (s == null) return;
     if (EcArray.isArray(s))
-        for (var i = 0;i < s.length;i++)
-            await xapiStatement(s[i]);
+        for (let st of s)
+            await xapiStatement(st, accm);
     if (!EcObject.isObject(s)) return;
     if (s.result == null) return;
     var negative = false;
@@ -122,33 +129,32 @@ var xapiStatement = async function (s) {
             negative = false;
         else
             negative = true;
+    } else if (s.result.score != null) {
+        var scaled = s.result.score.scaled;
+        if (scaled > 0.7)
+            negative = false;
+        else
+            negative = true;
     } else if (s.result.response == "Pass") {
         var scaled = 1.0;
         negative = false;
     } else if (s.result.response == "Fail") {
         var scaled = 1.0;
         negative = true;
-    } if (s.result.score != null) {
-        var scaled = s.result.score.scaled;
-        if (scaled > 0.7)
-            negative = false;
-        else
-            negative = true;
     } else
         return;
 
     var actorPk = await pkFromMbox.call(this, s.actor);
-    if (actorPk == null)
-    {
+    if (actorPk == null) {
         var ppk = await EcPpk.generateKey();
         var person = new schema.Person();
-        person.assignId(global.repo.selectedServer,ppk.toPk().fingerprint());
+        person.assignId(global.repo.selectedServer, ppk.toPk().fingerprint());
         person.addOwner(ppk.toPk());
-		var mb = getMbox.call(this, s.actor).replace("mailto:","");
-		if (mb.indexOf("@") == -1)
-			person.username = mb;
-		else
-			person.email = mb;
+        var mb = getMbox.call(this, s.actor).replace("mailto:", "");
+        if (mb.indexOf("@") == -1)
+            person.username = mb;
+        else
+            person.email = mb;
         person.name = s.actor.name;
         await EcRepository.save(person, (msg) => {
             global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.INFO, "XapiSavePerson", msg);
@@ -159,38 +165,55 @@ var xapiStatement = async function (s) {
     }
     if (actorPk == null) return;
     var authorityPk = await pkFromMbox.call(this, s.authority);
-    if (authorityPk == null)
-    {
+    if (authorityPk == null) {
         let ppk = EcPpk.fromPem(xapiMePpk);
         var person = new schema.Person();
-        person.assignId(global.repo.selectedServer,ppk.toPk().fingerprint());
+        person.assignId(global.repo.selectedServer, ppk.toPk().fingerprint());
         person.addOwner(ppk.toPk());
-		var mb = getMbox.call(this, s.authority);
-        if (mb != null) mb = mb.replace("mailto:","");
-        if (mb == null) mb = "Some Authority";
-		if (mb.indexOf("@") == -1)
-			person.username = mb;
-		else
-			person.email = mb;
-        person.name = (s.authority != null ? s.authority.name : null) || mb;
-        await EcRepository.save(person, (msg) => {
-            global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.INFO, "XapiSavePerson", msg);
-        }, (error) => {
-            global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.ERROR, "XapiSavePerson", error);
-        });
+        var mb = getMbox.call(this, s.authority);
+        if (mb != null) mb = mb.replace("mailto:", "");
+        if (mb == null) {
+            mb = "Some Authority";
+            if (defaultAuthority == null) {
+                defaultAuthority = person;
+                if (mb.indexOf("@") == -1)
+                    person.username = mb;
+                else
+                    person.email = mb;
+                person.name = (s.authority != null ? s.authority.name : null) || mb;
+                await EcRepository.save(person, (msg) => {
+                    global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.INFO, "XapiSavePerson", msg);
+                }, (error) => {
+                    global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.ERROR, "XapiSavePerson", error);
+                });
+            }
+        }
+        else {
+            if (mb.indexOf("@") == -1)
+                person.username = mb;
+            else
+                person.email = mb;
+            person.name = (s.authority != null ? s.authority.name : null) || mb;
+            await EcRepository.save(person, (msg) => {
+                global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.INFO, "XapiSavePerson", msg);
+            }, (error) => {
+                global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.ERROR, "XapiSavePerson", error);
+            });
+        }
         authorityPk = ppk.toPk();
     }
     if (authorityPk == null) return;
 
     if (s.object == null) return;
-    
+
     var alignedCompetencies = await getAlignedCompetencies.call(this, s.object.id);
     var alreadyAligned = {};
     for (var i = 0; i < alignedCompetencies.length; i++) {
         var a = new EcAssertion();
-        a.assignId(global.repo.selectedServer, EcCrypto.md5(s.id+alignedCompetencies[i].targetUrl));
+        a.assignId(global.repo.selectedServer, EcCrypto.md5(s.id + alignedCompetencies[i].targetUrl));
         a.addOwner(EcPpk.fromPem(xapiMePpk).toPk());
         a.addOwner(authorityPk);
+        a.addOwner(EcPk.fromPem(skyrepoAdminPk()));
         a.addReader(actorPk);
         await a.setSubject(actorPk);
         await a.setAgent(authorityPk);
@@ -203,27 +226,22 @@ var xapiStatement = async function (s) {
         await a.setAssertionDate(new Date(s.timestamp).getTime());
         await a.setNegative(negative);
         a.confidence = scaled;
-        EcRepository.save(a, (msg) => {
-            global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.INFO, "XapiSaveAssertion", msg);
-        }, (error) => {
-            global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.ERROR, "XapiSaveAssertion", error);
-        });
+        if (accm != null)
+            accm.push(a);
+        else
+            EcRepository.save(a, (msg) => {
+                global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.INFO, "XapiSaveAssertion", msg);
+            }, (error) => {
+                global.auditLogger.report(global.auditLogger.LogCategory.ADAPTER, global.auditLogger.Severity.ERROR, "XapiSaveAssertion", error);
+            });
     }
 }
 
 var xapiStatementListener = async function () {
+    let accm = [];
     for (let val in this.dataStreams)
-        await xapiStatement(this.dataStreams[val]);
-    // var data = fileFromDatastream.call(this);
-    // if (data == null)
-    // {
-    // }
-    // else
-    // {
-    //     data = fileToString(data.get(0));
-    //     data = JSON.parse(data);
-    //     await xapiStatement(data);
-    // }
+        await xapiStatement(this.dataStreams[val], accm);
+    await global.repo.multiput(accm);
 }
 
 var ident = new EcIdentity();
@@ -235,8 +253,10 @@ xapiKey = function () {
 }
 
 
-var xapiLoopEach = async function(since, config, sinceFilePath) {
-    var results = await xapiEndpoint.call(this, null, since, config);
+var xapiLoopEach = async function (since, config, sinceFilePath) {
+    try {
+        var results = await xapiEndpoint.call(this, null, since, config);
+    } catch (ex) { console.log(ex); return; }
     while (results != null && results.statements != null && results.statements.length > 0) {
         for (var i = 0; i < results.statements.length; i++) {
             await xapiStatement.call(this, results.statements[i]);
@@ -249,7 +269,28 @@ var xapiLoopEach = async function(since, config, sinceFilePath) {
     }
 }
 
+let openid = require('openid-client');
 var xapiLoop = async function () {
+    let tokenSet = null;
+    if (process.env.OIDC_CLIENT_ENDPOINT != null) {
+        const oidcIssuer = await openid.Issuer.discover(process.env.OIDC_CLIENT_ENDPOINT);
+        console.log('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
+        const client = new oidcIssuer.Client({
+            client_id: process.env.OIDC_CLIENT_CLIENT_ID,
+            client_secret: process.env.OIDC_CLIENT_CLIENT_SECRET,
+            redirect_uris: [process.env.OIDC_CLIENT_REDIRECT_URI],
+            response_types: [process.env.OIDC_CLIENT_RESPONSE_TYPE || "code"],
+            // id_token_signed_response_alg (default "RS256")
+            // token_endpoint_auth_method (default "client_secret_basic")
+        }); // => Client
+
+        tokenSet = await client.grant({
+            resource: 'urn:example:third-party-api',
+            grant_type: 'client_credentials'
+        });
+        console.log(tokenSet);
+    }
+
     var ident = new EcIdentity();
     ident.displayName = "xAPI Adapter";
     ident.ppk = EcPpk.fromPem(xapiMePpk);
@@ -260,6 +301,9 @@ var xapiLoop = async function () {
     for (let key in process.env) {
         if (key.startsWith("XAPI_CONFIG_")) {
             config.push(JSON.parse(process.env[key]));
+            if (process.env.OIDC_CLIENT_ENDPOINT != null) {
+                config[config.length - 1].xapiAuth = tokenSet.token_type + " " + tokenSet.access_token;
+            }
         }
     }
     if (fileExists(sinceFilePath)) {
@@ -272,7 +316,7 @@ var xapiLoop = async function () {
     if (!config || config.length === 0) {
         await xapiLoopEach.call(this, since, null, sinceFilePath);
     }
-    
+
 }
 
 if (!global.disabledAdapters['xapi']) {
diff --git a/src/main/server/skyRepo.js b/src/main/server/skyRepo.js
index 13fdcc3c2..fb359d72f 100644
--- a/src/main/server/skyRepo.js
+++ b/src/main/server/skyRepo.js
@@ -207,14 +207,16 @@ const filterResults = async function (o, dontDecryptInSso) {
     if (EcArray.isArray(o)) {
         let me = this;
         return (await Promise.all(o.map(x => {
-            try {
-                return filterResults.call(me, x, dontDecryptInSso);
-            } catch (ex) {
-                if (ex != null && ex.toString().indexOf('Object not found or you did not supply sufficient permissions to access the object.') == -1) {
-                    throw ex;
+            return new Promise((resolve,reject)=>{
+                try {
+                    resolve(filterResults.call(me, x, dontDecryptInSso));
+                } catch (ex) {
+                    if (ex != null && ex.toString().indexOf('Object not found or you did not supply sufficient permissions to access the object.') == -1) {
+                        reject(ex);
+                    }
+                    resolve(null);
                 }
-                return null;
-            }
+            });
         }))).filter(x => x);
     } else if (EcObject.isObject(o)) {
         delete o.decryptedSecret;
@@ -253,7 +255,14 @@ const filterResults = async function (o, dontDecryptInSso) {
         for (let i = 0; i < keys.length; i++) {
             const key = keys[i];
             let result = null;
-            result = await (filterResults).call(this, (o)[key], dontDecryptInSso);
+            try{
+                result = await (filterResults).call(this, (o)[key], dontDecryptInSso);
+            } catch (ex) {
+                if (ex != null && ex.toString().indexOf('Object not found or you did not supply sufficient permissions to access the object.') == -1) {
+                    throw ex;
+                }
+                result = null;
+            }
             if (result != null) {
                 (o)[key] = result;
             } else {
@@ -8785,7 +8794,7 @@ let skyrepoPutInternal = global.skyrepoPutInternal = async function (o, id, vers
     let chosenVersion = version;
     // If we are doing a manual put with a CASS_LOOPBACK that has an associated CASS_LOOPBACK_PROXY from localhost,
     // we have to pull the version from the object not the url (because it wasn't sent with the url because it's using the md5)
-    if (chosenVersion == null && (erld.id.startsWith(repo.selectedServer) || erld.id.startsWith(repo.selectedServerProxy))) {
+    if (chosenVersion == null && erld.id && (erld.id.startsWith(repo.selectedServer) || erld.id.startsWith(repo.selectedServerProxy))) {
         chosenVersion = erld.getTimestamp();
     }
     if (chosenVersion == null) {