diff --git a/config/default.schema.json b/config/default.schema.json index 3d8fcc3e3f..28a8df22d6 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -945,11 +945,6 @@ "description": "The password for edu-sharing api access", "default": "" }, - "ES_API_V7": { - "type": "boolean", - "default": false, - "description": "Use the newer API for Edusharing v7" - }, "FEATURE_ES_MERLIN_ENABLED": { "type": "boolean", "default": false, @@ -1372,7 +1367,7 @@ "default": 300000, "description": "threshold in milliseconds to try again to process a hanging Pending or Failed deletion requests" }, - "DELETION_CONSIDER_FAILED_AFTER_MS" : { + "DELETION_CONSIDER_FAILED_AFTER_MS": { "type": "number", "default": 360000000, "description": "threshold in milliseconds to stop trying to process Pending or Failed deletion requests" diff --git a/src/services/edusharing/index.js b/src/services/edusharing/index.js index 3dd2aa570e..68a5dc542a 100644 --- a/src/services/edusharing/index.js +++ b/src/services/edusharing/index.js @@ -1,40 +1,26 @@ /* eslint-disable max-classes-per-file */ -const { MethodNotAllowed } = require('@feathersjs/errors'); const { static: staticContent } = require('@feathersjs/express'); -const { Configuration } = require('@hpi-schul-cloud/commons/lib'); const path = require('path'); const hooks = require('./hooks'); const playerHooks = require('./hooks/player.hooks'); const merlinHooks = require('./hooks/merlin.hooks'); -const EduSharingConnectorV6 = require('./services/EduSharingConnectorV6'); -const EduSharingConnectorV7 = require('./services/EduSharingConnectorV7'); +const eduSharingConnectorV7 = require('./services/EduSharingConnectorV7'); const MerlinTokenGenerator = require('./services/MerlinTokenGenerator'); class EduSharing { - constructor() { - if (Configuration.get('ES_API_V7')) { - this.connector = EduSharingConnectorV7; - } else { - this.connector = EduSharingConnectorV6; - } - } - find(params) { - return this.connector.FIND(params.query, params.authentication.payload.schoolId); + return eduSharingConnectorV7.FIND(params.query, params.authentication.payload.schoolId); } get(id, params) { - return this.connector.GET(id, params.authentication.payload.schoolId); + return eduSharingConnectorV7.GET(id, params.authentication.payload.schoolId); } } class EduSharingPlayer { get(uuid) { - if (!Configuration.get('ES_API_V7')) { - throw new MethodNotAllowed('This feature is disabled on this instance'); - } - const esPlayer = EduSharingConnectorV7.getPlayerForNode(uuid); + const esPlayer = eduSharingConnectorV7.getPlayerForNode(uuid); return esPlayer; } diff --git a/src/services/edusharing/services/EduSharingConnectorV6.js b/src/services/edusharing/services/EduSharingConnectorV6.js deleted file mode 100644 index cc9f67f823..0000000000 --- a/src/services/edusharing/services/EduSharingConnectorV6.js +++ /dev/null @@ -1,332 +0,0 @@ -const request = require('request-promise-native'); -const { Configuration } = require('@hpi-schul-cloud/commons'); - -const { Forbidden, GeneralError, NotFound } = require('../../../errors'); -const logger = require('../../../logger'); -const EduSharingResponse = require('./EduSharingResponse'); -const { getCounty } = require('../helpers'); - -const ES_METADATASET = - Configuration.get('FEATURE_ES_MERLIN_ENABLED') || - Configuration.get('FEATURE_ES_COLLECTIONS_ENABLED') || - Configuration.get('FEATURE_ES_SEARCHABLE_ENABLED') - ? 'mds_oeh' - : 'mds'; -const ES_ENDPOINTS = { - AUTH: `${Configuration.get('ES_DOMAIN')}/edu-sharing/rest/authentication/v1/validateSession`, - NODE: `${Configuration.get('ES_DOMAIN')}/edu-sharing/rest/node/v1/nodes/-home-/`, - SEARCH: `${Configuration.get('ES_DOMAIN')}/edu-sharing/rest/search/v1/queriesV2/-home-/${ES_METADATASET}/ngsearch/`, -}; - -const basicAuthorizationHeaders = { - Authorization: `Basic ${Buffer.from(`${Configuration.get('ES_USER')}:${Configuration.get('ES_PASSWORD')}`).toString( - 'base64' - )}`, -}; - -/* Instace-specific configuration, which we should try in the future to provide via configuration or some other means. - Permissions should be in-sync with Edu-Sharing permissions for the corresponding Edu-Sharing user. -*/ -const edusharingInstancePermissions = [ - { - name: 'LowerSaxony', - alias: 'n21', // or abbreviation - privateGroups: ['GROUP_LowerSaxony-private'], - publicGroups: ['GROUP_public', 'GROUP_LowerSaxony-public', 'GROUP_Brandenburg-public', 'GROUP_Thuringia-public'], - }, - { - name: 'Brandenburg', - alias: 'brb', - privateGroups: ['GROUP_Brandenburg-private'], - publicGroups: ['GROUP_public', 'GROUP_LowerSaxony-public', 'GROUP_Brandenburg-public', 'GROUP_Thuringia-public'], - }, - { - name: 'Thuringia', - alias: 'thr', - privateGroups: ['GROUP_Thuringia-private'], - publicGroups: ['GROUP_public', 'GROUP_Thuringia-public'], - }, - { - // same for BossCloud, Open, and International. - name: 'HPIBossCloud', - alias: 'default', // open, int - privateGroups: ['GROUP_HPIBossCloud'], - publicGroups: ['GROUP_public', 'GROUP_LowerSaxony-public', 'GROUP_Brandenburg-public', 'GROUP_Thuringia-public'], - }, -]; - -// bug in edu-sharing limits session to 5 min instead of 1h -const eduSharingCookieValidity = 240000; // 4 min -let eduSharingCookieExpires = new Date(); - -class EduSharingConnector { - setup(app) { - this.app = app; - } - - // gets cookie (JSESSION) for authentication when fetching images - async getCookie() { - const options = { - uri: ES_ENDPOINTS.AUTH, - method: 'GET', - headers: basicAuthorizationHeaders, - resolveWithFullResponse: true, - json: true, - }; - - try { - const result = await request.get(options); - - if (result.statusCode !== 200 || result.body.isValidLogin !== true) { - throw Error('authentication error with edu sharing'); - } - - return result.headers['set-cookie'][0]; - } catch (err) { - logger.error(`Edu-Sharing failed to get session cookie: ${err.statusCode} ${err.message}`); - throw new GeneralError('Edu-Sharing Request failed'); - } - } - - async authorize() { - const now = new Date(); - // should relogin if cookie expired - if (now >= eduSharingCookieExpires) { - try { - this.eduSharingCookie = await this.getCookie(); - eduSharingCookieExpires = new Date(now.getTime() + eduSharingCookieValidity); - } catch (err) { - logger.error(`Edu-Sharing failed to authorise request`, err); - throw new GeneralError('Edu-Sharing Request failed'); - } - } - } - - async eduSharingRequest(options, retried = false) { - try { - await this.authorize(); - if (options.method.toUpperCase() === 'POST') { - return await request.post(options); - } - const res = await request.get(options); - return res; - } catch (err) { - if (err.statusCode === 404) { - return null; - } - // eslint-disable-next-line no-unused-vars - const { headers, ...logOptions } = options; - logger.error(`Edu-Sharing failed request with error ${err.statusCode} ${err.message}`, logOptions); - if (retried === true) { - throw new GeneralError('Edu-Sharing Request failed'); - } else { - eduSharingCookieExpires = new Date(); - const response = await this.eduSharingRequest(options, true); - return response; - } - } - } - - async checkNodePermission(node, schoolId) { - const counties = node.properties['ccm:ph_invited']; - const isPublic = counties.some((county) => county.endsWith('public')); - if (counties.length > 1 && !isPublic) { - const county = await getCounty(schoolId); - const permission = counties.includes(`GROUP_county-${county.countyId}`); - return permission; - } - return true; - } - - async getImage(url) { - const options = { - uri: url, - method: 'GET', - headers: { - cookie: this.eduSharingCookie, - }, - encoding: null, // necessary to get the image as binary value - resolveWithFullResponse: true, - // edu-sharing returns 302 to an error page instead of 403, - // and the error page has wrong status codes - followRedirect: false, - }; - - try { - const result = await this.eduSharingRequest(options); - const encodedData = `data:image;base64,${result.body.toString('base64')}`; - return Promise.resolve(encodedData); - } catch (err) { - logger.error(`Edu-Sharing failed fetching image ${url}`, err, options); - return Promise.reject(err); - } - } - - async GET(uuid, schoolId) { - if (!schoolId) { - throw new Forbidden('Missing school'); - } - if (!this.validateUuid(uuid)) { - throw new NotFound('Invalid node id'); - } - - const criterias = []; - criterias.push({ property: 'ngsearchword', values: ['*'] }); - criterias.push({ - property: 'ccm:replicationsourceuuid', - values: [uuid], - }); - - const response = await this.searchEduSharing(criterias, 0, 1); - - if (!response.data || response.data.length !== 1) { - throw new NotFound('Item not found'); - } - - if (Configuration.get('FEATURE_ES_MERLIN_ENABLED')) { - const permission = await this.checkNodePermission(response.data[0], schoolId); - if (!permission) { - throw new Forbidden('This content is not available for your school'); - } - } - - return response.data[0]; - } - - async FIND({ searchQuery = '', $skip, $limit, sortProperties = 'score', collection = '' }, schoolId) { - if (!schoolId) { - throw new Forbidden('Missing school'); - } - - const maxItems = parseInt($limit, 10) || 9; - const skipCount = parseInt($skip, 10) || 0; - - if ((searchQuery.trim().length < 2 && !collection) || (collection !== '' && !this.validateUuid(collection))) { - return new EduSharingResponse(); - } - - /* Providing the appropriate permissions, i.e., which items we are allowed to access, by specifying - the necessary groups. */ - - const countyGroups = []; // County permissions. - const county = await getCounty(schoolId); - if (county && county.countyId) { - countyGroups.push(`GROUP_county-${county.countyId}`); - } - - let instancePermissions = edusharingInstancePermissions.find( - (element) => Configuration.get('ES_USER').includes(element.name) // e.g., if ES_USER includes 'LowerSaxony' - ); - if (typeof instancePermissions === 'undefined') { - instancePermissions = edusharingInstancePermissions.find( - (element) => element.alias === 'default' // default permissions - ); - } - - // Private: from its own state, public: from all states - const groups = [ - ...(countyGroups.length > 0 ? countyGroups : instancePermissions.privateGroups), - ...instancePermissions.publicGroups, - ]; - - const criterias = []; - if (groups.length) { - criterias.push({ - property: 'ccm:ph_invited', - values: groups, - }); - } - - if (Configuration.get('FEATURE_ES_SEARCHABLE_ENABLED') && !collection) { - criterias.push({ - property: 'ccm:hpi_searchable', - values: ['1'], - }); - } - - if (Configuration.get('FEATURE_ES_COLLECTIONS_ENABLED') === false) { - criterias.push({ - property: 'ccm:hpi_lom_general_aggregationlevel', - values: ['1'], - }); - } else if (collection) { - criterias.push({ property: 'ngsearchword', values: ['*'] }); - criterias.push({ - property: 'ccm:hpi_lom_relation', - values: [`{'kind': 'ispartof', 'resource': {'identifier': ['${collection}']}}`], - }); - } else { - criterias.push({ property: 'ngsearchword', values: [searchQuery.toLowerCase()] }); - } - - const response = await this.searchEduSharing(criterias, skipCount, maxItems); - return response; - } - - async searchEduSharing(criterias, skipCount, maxItems) { - try { - const url = `${ES_ENDPOINTS.SEARCH}?${[ - `contentType=FILES`, - `skipCount=${skipCount}`, - `maxItems=${maxItems}`, - `sortProperties=score`, - `sortAscending=false`, - `propertyFilter=-all-`, - ].join('&')}`; - - const facettes = ['cclom:general_keyword']; - - const options = { - method: 'POST', - url, - headers: { - Accept: 'application/json', - 'Content-type': 'application/json', - ...basicAuthorizationHeaders, - }, - body: JSON.stringify({ - criterias, - facettes, - }), - timeout: Configuration.get('REQUEST_OPTION__TIMEOUT_MS'), - }; - - const response = await this.eduSharingRequest(options); - const parsed = JSON.parse(response); - if (parsed && parsed.nodes) { - const promises = parsed.nodes.map(async (node) => { - if (node.preview && node.preview.url) { - node.preview.url = await this.getImage(`${node.preview.url}&crop=true&maxWidth=300&maxHeight=300`); - } - - // workaround for Edu-Sharing bug, where arrays are as strings "['a,b,c']" - if ( - node.properties && - node.properties['cclom:general_keyword'] && - node.properties['cclom:general_keyword'][0] - ) { - node.properties['cclom:general_keyword'] = node.properties['cclom:general_keyword'][0] - .slice(1, -1) - .split(','); - } - }); - await Promise.allSettled(promises); - } else { - return new EduSharingResponse(); - } - - return new EduSharingResponse(parsed); - } catch (err) { - logger.error('Edu-Sharing failed search ', err.message); - return Promise.reject(err); - } - } - - validateUuid(uuid) { - const uuidV5Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - const uuidV4Regex = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; - return uuidV4Regex.test(uuid) === true || uuidV5Regex.test(uuid) === true; - } -} - -module.exports = new EduSharingConnector(); diff --git a/test/services/edusharing/services/EduSharingConnectorV6.test.js b/test/services/edusharing/services/EduSharingConnectorV6.test.js deleted file mode 100644 index 2ba7320c43..0000000000 --- a/test/services/edusharing/services/EduSharingConnectorV6.test.js +++ /dev/null @@ -1,248 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const request = require('request-promise-native'); -const { Configuration } = require('@hpi-schul-cloud/commons'); -const appPromise = require('../../../../src/app'); -const MockNode = JSON.stringify(require('../mock/response-node.json')); -const MockNodeRestricted = JSON.stringify(require('../mock/response-node-restricted.json')); -const MockNodes = JSON.stringify(require('../mock/response-nodes.json')); -const MockAuth = require('../mock/response-auth.json'); -const EduSharingConnectorV6 = require('../../../../src/services/edusharing/services/EduSharingConnectorV6'); -const EduSharingResponse = require('../../../../src/services/edusharing/services/EduSharingResponse'); -const testHelper = require('../../helpers/testObjects'); - -const { expect } = chai; - -const { setupNestServices, closeNestServices } = require('../../../utils/setup.nest.services'); - -describe('EduSharingV6 FIND', () => { - let app; - let eduSharingResponse; - let eduSharingService; - let server; - let nestServices; - let testObjects; - - before(async () => { - Configuration.set('ES_API_V7', false); - app = await appPromise(); - eduSharingService = app.service('edu-sharing'); - eduSharingService.connector = EduSharingConnectorV6; - eduSharingResponse = new EduSharingResponse(); - server = await app.listen(0); - testObjects = testHelper(app); - nestServices = await setupNestServices(app); - }); - - after(async () => { - await testObjects.cleanup(); - await server.close(); - await closeNestServices(nestServices); - }); - - afterEach(async () => { - sinon.verifyAndRestore(); - }); - - it('search with an empty query', async () => { - try { - const student = await testObjects.createTestUser({ roles: ['student'] }); - const paramsStudent = await testObjects.generateRequestParamsFromUser(student); - - sinon.stub(request, 'get').returns(MockAuth); - - paramsStudent.query = { searchQuery: '' }; - const response = await eduSharingService.find(paramsStudent); - - chai.expect(JSON.stringify(response)).to.equal(JSON.stringify(eduSharingResponse)); - } catch (err) { - throw new Error(err); - } - }); - - it('search with params', async () => { - try { - const student = await testObjects.createTestUser({ roles: ['student'] }); - const paramsStudent = await testObjects.generateRequestParamsFromUser(student); - - sinon.stub(request, 'get').returns(MockAuth); - const postStub = sinon.stub(request, 'post'); - postStub.onCall(0).throws({ statusCode: 403, message: 'Stubbing request fail' }); - postStub.onCall(1).returns(MockNodes); - - paramsStudent.query = { searchQuery: 'foo' }; - const response = await eduSharingService.find(paramsStudent); - - chai.expect(response.total).to.gte(1); - } catch (err) { - throw new Error(err); - } - }); - - it('should search for a collection', async () => { - try { - const user = await testObjects.createTestUser({ roles: ['teacher'] }); - const params = await testObjects.generateRequestParamsFromUser(user); - - sinon.stub(request, 'get').returns(MockAuth); - const postStub = sinon.stub(request, 'post'); - postStub.onCall(0).returns(MockNodes); - - params.query = { collection: 'a4808865-da94-4884-bdba-0ad66070e83b' }; - const response = await eduSharingService.find(params); - - chai - .expect(postStub.getCalls()[0].args[0].body) - .contains( - `{"property":"ccm:hpi_lom_relation","values":["{'kind': 'ispartof', 'resource': {'identifier': ['a4808865-da94-4884-bdba-0ad66070e83b']}}"]` - ); - chai.expect(response.total).to.gte(1); - } catch (err) { - throw new Error(err); - } - }); - - it('should search with searchable flag', async () => { - try { - const user = await testObjects.createTestUser({ roles: ['teacher'] }); - const params = await testObjects.generateRequestParamsFromUser(user); - - sinon.stub(request, 'get').returns(MockAuth); - const postStub = sinon.stub(request, 'post'); - postStub.onCall(0).returns(MockNodes); - - params.query = { searchQuery: 'foo' }; - const response = await eduSharingService.find(params); - - chai.expect(postStub.getCalls()[0].args[0].body).contains(`{"property":"ccm:hpi_searchable","values":["1"]}`); - chai.expect(response.total).to.gte(1); - } catch (err) { - throw new Error(err); - } - }); - - it('should search with appropriate ph_invited group permissions', async () => { - try { - const user = await testObjects.createTestUser({ roles: ['teacher'] }); - const params = await testObjects.generateRequestParamsFromUser(user); - - sinon.stub(request, 'get').returns(MockAuth); - const postStub = sinon.stub(request, 'post'); - postStub.onCall(0).returns(MockNodes); - - params.query = { searchQuery: 'foo' }; - await eduSharingService.find(params); - - chai - .expect(postStub.getCalls()[0].args[0].body) - .contains( - `{"property":"ccm:ph_invited","values":["GROUP_county-12051","GROUP_public","GROUP_LowerSaxony-public","GROUP_Brandenburg-public","GROUP_Thuringia-public"]}` - ); - } catch (err) { - throw new Error(err); - } - }); - - it('should fail to get a node with invalid uuid', async () => { - try { - const user = await testObjects.createTestUser({ roles: ['teacher'], schoolId: '5fcfb0bc685b9af4d4abf899' }); - const params = await testObjects.generateRequestParamsFromUser(user); - await eduSharingService.get('dummyNodeId', params); - throw new Error('should have failed'); - } catch (err) { - chai.expect(err.message).to.not.equal('should have failed'); - chai.expect(err.code).to.equal(404); - chai.expect(err.message).to.equal('Invalid node id'); - } - }); - - it('should get a node', async () => { - try { - const user = await testObjects.createTestUser({ roles: ['teacher'], schoolId: '5fcfb0bc685b9af4d4abf899' }); - const params = await testObjects.generateRequestParamsFromUser(user); - - sinon.stub(request, 'get').returns(MockAuth); - const postStub = sinon.stub(request, 'post'); - postStub.onCall(0).returns(MockNode); - const mockImg = { body: 'dummyImage' }; - postStub.onCall(1).returns(mockImg); - - const response = await eduSharingService.get('9ff3ee4e-e679-4576-bad7-0eeb9b174716', params); - chai.expect(response.title).to.equal('dummy title'); - chai - .expect(postStub.getCalls()[0].args[0].body) - .contains(`{"property":"ccm:replicationsourceuuid","values":["9ff3ee4e-e679-4576-bad7-0eeb9b174716"]`); - } catch (err) { - throw new Error(err); - } - }); - - it('should fail to get a restricted node', async () => { - const configBefore = Configuration.toObject({ plainSecrets: true }); - Configuration.set('FEATURE_ES_MERLIN_ENABLED', true); - const user = await testObjects.createTestUser({ roles: ['teacher'] }); - const params = await testObjects.generateRequestParamsFromUser(user); - - sinon.stub(request, 'get').returns(MockAuth); - const postStub = sinon.stub(request, 'post'); - postStub.onCall(0).returns(MockNodeRestricted); - - await expect(eduSharingService.get('9ff3ee4e-e679-4576-bad7-0eeb9b174716', params)).to.be.rejectedWith( - Error, - 'This content is not available for your school' - ); - Configuration.reset(configBefore); - }); -}); - -describe('EduSharingV6 config flags', () => { - let app; - let eduSharingService; - let server; - let nestServices; - let originalConfiguration; - let testObjects; - - before(async () => { - originalConfiguration = Configuration.get('FEATURE_ES_COLLECTIONS_ENABLED'); - app = await appPromise(); - eduSharingService = app.service('edu-sharing'); - server = await app.listen(0); - testObjects = testHelper(app); - nestServices = await setupNestServices(app); - }); - - after(async () => { - Configuration.set('FEATURE_ES_COLLECTIONS_ENABLED', originalConfiguration); - await testObjects.cleanup(); - await server.close(); - await closeNestServices(nestServices); - }); - - afterEach(async () => { - sinon.verifyAndRestore(); - }); - - it('should search with collections flag disabled', async () => { - try { - Configuration.set('FEATURE_ES_COLLECTIONS_ENABLED', false); - - const user = await testObjects.createTestUser({ roles: ['teacher'] }); - const params = await testObjects.generateRequestParamsFromUser(user); - - sinon.stub(request, 'get').returns(MockAuth); - const postStub = sinon.stub(request, 'post'); - postStub.onCall(0).returns(MockNodes); - - params.query = { searchQuery: 'foo' }; - const response = await eduSharingService.find(params); - - chai - .expect(postStub.getCalls()[0].args[0].body) - .contains(`{"property":"ccm:hpi_lom_general_aggregationlevel","values":["1"]}`); - chai.expect(response.total).to.gte(1); - } catch (err) { - throw new Error(err); - } - }); -}); diff --git a/test/services/edusharing/services/EduSharingConnectorV7.test.js b/test/services/edusharing/services/EduSharingConnectorV7.test.js index 5ae6ac091f..33f1ca54e9 100644 --- a/test/services/edusharing/services/EduSharingConnectorV7.test.js +++ b/test/services/edusharing/services/EduSharingConnectorV7.test.js @@ -22,7 +22,6 @@ describe('EduSharingV7 FIND', () => { let nestServices; before(async () => { - Configuration.set('ES_API_V7', true); app = await appPromise(); eduSharingService = app.service('edu-sharing'); eduSharingPlayerService = app.service('edu-sharing/player');