diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.spec.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.spec.ts new file mode 100644 index 00000000000..a8945c70187 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.spec.ts @@ -0,0 +1,47 @@ +import AdmZip from 'adm-zip'; +import { CommonCartridgeFileParser } from './common-cartridge-file-parser'; + +describe('CommonCartridgeFileParser', () => { + describe('constructor', () => { + describe('when manifest file is found', () => { + const setup = (manifestName: string) => { + const archive = new AdmZip(); + + archive.addFile(manifestName, Buffer.from('')); + + const file = archive.toBuffer(); + + return { file }; + }; + + it('should use imsmanifest.xml as manifest', () => { + const { file } = setup('imsmanifest.xml'); + const parser = new CommonCartridgeFileParser(file); + + expect(parser.manifest).toBeDefined(); + }); + + it('should use manifest.xml as manifest', () => { + const { file } = setup('manifest.xml'); + const parser = new CommonCartridgeFileParser(file); + + expect(parser.manifest).toBeDefined(); + }); + }); + + describe('when manifest file is not found', () => { + const setup = () => { + const archive = new AdmZip(); + const file = archive.toBuffer(); + + return { file }; + }; + + it('should throw', () => { + const { file } = setup(); + + expect(() => new CommonCartridgeFileParser(file)).toThrow('Manifest file not found'); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.ts new file mode 100644 index 00000000000..f220b322494 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.ts @@ -0,0 +1,28 @@ +import AdmZip from 'adm-zip'; +import { CommonCartridgeManifestParser } from './common-cartridge-manifest-parser'; +import { CommonCartridgeManifestNotFoundException } from './utils/common-cartridge-manifest-not-found.exception'; + +export class CommonCartridgeFileParser { + private readonly manifestParser: CommonCartridgeManifestParser; + + public constructor(file: Buffer) { + const archive = new AdmZip(file); + + this.manifestParser = new CommonCartridgeManifestParser(this.getManifestFileAsString(archive)); + } + + public get manifest(): CommonCartridgeManifestParser { + return this.manifestParser; + } + + private getManifestFileAsString(archive: AdmZip): string | never { + // imsmanifest.xml is the standard name, but manifest.xml is also valid until v1.3 + const manifest = archive.getEntry('imsmanifest.xml') || archive.getEntry('manifest.xml'); + + if (manifest) { + return archive.readAsText(manifest); + } + + throw new CommonCartridgeManifestNotFoundException(); + } +} diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-manifest-parser.spec.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-manifest-parser.spec.ts new file mode 100644 index 00000000000..a62a027eb1f --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-manifest-parser.spec.ts @@ -0,0 +1,91 @@ +import AdmZip from 'adm-zip'; +import { readFile } from 'fs/promises'; +import { CommonCartridgeManifestParser } from './common-cartridge-manifest-parser'; + +describe('CommonCartridgeManifestParser', () => { + const setupFile = async (loadFile: boolean) => { + if (!loadFile) { + const sut = new CommonCartridgeManifestParser(''); + + return { sut }; + } + + const buffer = await readFile('./apps/server/test/assets/common-cartridge/us_history_since_1877.imscc'); + const archive = new AdmZip(buffer); + const sut = new CommonCartridgeManifestParser(archive.readAsText('imsmanifest.xml')); + + return { sut }; + }; + + describe('getSchema', () => { + describe('when schema is present', () => { + const setup = async () => setupFile(true); + + it('should return the schema', async () => { + const { sut } = await setup(); + const result = sut.getSchema(); + + expect(result).toBe('IMS Common Cartridge'); + }); + }); + + describe('when schema is not present', () => { + const setup = async () => setupFile(false); + + it('should return undefined', async () => { + const { sut } = await setup(); + const result = sut.getSchema(); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe('getVersion', () => { + describe('when version is present', () => { + const setup = async () => setupFile(true); + + it('should return the version', async () => { + const { sut } = await setup(); + const result = sut.getVersion(); + + expect(result).toBe('1.3.0'); + }); + }); + + describe('when version is not present', () => { + const setup = async () => setupFile(false); + + it('should return undefined', async () => { + const { sut } = await setup(); + const result = sut.getVersion(); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe('getTitle', () => { + describe('when title is present', () => { + const setup = async () => setupFile(true); + + it('should return the title', async () => { + const { sut } = await setup(); + const result = sut.getTitle(); + + expect(result).toBe('201510-AMH-2020-70C-12218-US History Since 1877'); + }); + }); + + describe('when title is not present', () => { + const setup = async () => setupFile(false); + + it('should return null', async () => { + const { sut } = await setup(); + const result = sut.getTitle(); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-manifest-parser.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-manifest-parser.ts new file mode 100644 index 00000000000..6f4b4e5e5a4 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-manifest-parser.ts @@ -0,0 +1,27 @@ +import { JSDOM } from 'jsdom'; + +export class CommonCartridgeManifestParser { + private readonly doc: Document; + + public constructor(manifest: string) { + this.doc = new JSDOM(manifest, { contentType: 'text/xml' }).window.document; + } + + public getSchema(): string | undefined { + const result = this.doc.querySelector('manifest > metadata > schema'); + + return result?.textContent ?? undefined; + } + + public getVersion(): string | undefined { + const result = this.doc.querySelector('manifest > metadata > schemaversion'); + + return result?.textContent ?? undefined; + } + + public getTitle(): string | undefined { + const result = this.doc.querySelector('manifest > metadata > lom > general > title > string'); + + return result?.textContent ?? undefined; + } +} diff --git a/apps/server/src/modules/common-cartridge/import/index.ts b/apps/server/src/modules/common-cartridge/import/index.ts new file mode 100644 index 00000000000..0f41a5e0581 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/index.ts @@ -0,0 +1 @@ +export { CommonCartridgeFileParser } from './common-cartridge-file-parser'; diff --git a/apps/server/src/modules/common-cartridge/import/jsdom.d.ts b/apps/server/src/modules/common-cartridge/import/jsdom.d.ts new file mode 100644 index 00000000000..35b5ca6940c --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/jsdom.d.ts @@ -0,0 +1,11 @@ +// This is a workaround for the missing types for jsdom, because the types are not included in the package itself. +// This is a declaration file for the JSDOM class, which is used in the CommonCartridgeManifestParser. +// Currently the JSDOM types are bit buggy and don't work properly with our project setup. + +declare module 'jsdom' { + class JSDOM { + constructor(html: string, options?: Record); + + window: Window; + } +} diff --git a/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-manifest-not-found.exception.spec.ts b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-manifest-not-found.exception.spec.ts new file mode 100644 index 00000000000..5a27b06a960 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-manifest-not-found.exception.spec.ts @@ -0,0 +1,29 @@ +import { CommonCartridgeManifestNotFoundException } from './common-cartridge-manifest-not-found.exception'; + +describe('CommonCartridgeManifestNotFoundException', () => { + describe('getLogMessage', () => { + describe('when returning a message', () => { + const setup = () => { + const sut = new CommonCartridgeManifestNotFoundException(); + + return { sut }; + }; + + it('should contain the type', () => { + const { sut } = setup(); + + const result = sut.getLogMessage(); + + expect(result.type).toEqual('WRONG_FILE_FORMAT'); + }); + + it('should contain the stack', () => { + const { sut } = setup(); + + const result = sut.getLogMessage(); + + expect(result.stack).toEqual(sut.stack); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-manifest-not-found.exception.ts b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-manifest-not-found.exception.ts new file mode 100644 index 00000000000..473d56515c4 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-manifest-not-found.exception.ts @@ -0,0 +1,17 @@ +import { BadRequestException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; + +export class CommonCartridgeManifestNotFoundException extends BadRequestException implements Loggable { + constructor() { + super('Manifest file not found.'); + } + + public getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: 'WRONG_FILE_FORMAT', + stack: this.stack, + }; + + return message; + } +} diff --git a/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts index 8ef38761c19..bbdf9605b93 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts @@ -5,6 +5,7 @@ import { INestApplication, StreamableFile } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; import { TestApiClient, UserAndAccountTestFactory, cleanupCollections, courseFactory } from '@shared/testing'; +import { readFile } from 'node:fs/promises'; const createStudent = () => { const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({}, [Permission.COURSE_VIEW]); @@ -51,6 +52,7 @@ describe('Course Controller (API)', () => { return { student, course, teacher }; }; + it('should find courses as student', async () => { const { student, course } = setup(); await em.persistAndFlush([student.account, student.user, course]); @@ -65,6 +67,7 @@ describe('Course Controller (API)', () => { expect(data[0].startDate).toBe(course.startDate); expect(data[0].untilDate).toBe(course.untilDate); }); + it('should find courses as teacher', async () => { const { teacher, course } = setup(); await em.persistAndFlush([teacher.account, teacher.user, course]); @@ -96,6 +99,7 @@ describe('Course Controller (API)', () => { return { course, teacher, teacherUnkownToCourse, substitutionTeacher, student1 }; }; + it('should find course export', async () => { const { teacher, course } = setup(); await em.persistAndFlush([teacher.account, teacher.user, course]); @@ -114,4 +118,27 @@ describe('Course Controller (API)', () => { expect(response.header['content-disposition']).toBe('attachment;'); }); }); + + describe('[POST] /courses/import', () => { + const setup = async () => { + const teacher = createTeacher(); + const course = await readFile('./apps/server/test/assets/common-cartridge/us_history_since_1877.imscc'); + const courseFileName = 'us_history_since_1877.imscc'; + + await em.persistAndFlush([teacher.account, teacher.user]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacher.account); + + return { loggedInClient, course, courseFileName }; + }; + + it('should import course', async () => { + const { loggedInClient, course, courseFileName } = await setup(); + + const response = await loggedInClient.postWithAttachment('import', 'file', course, courseFileName); + + expect(response.statusCode).toEqual(201); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/controller/course.controller.ts b/apps/server/src/modules/learnroom/controller/course.controller.ts index 7f026002ea7..48398397c3a 100644 --- a/apps/server/src/modules/learnroom/controller/course.controller.ts +++ b/apps/server/src/modules/learnroom/controller/course.controller.ts @@ -1,13 +1,35 @@ import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; -import { Controller, Get, NotFoundException, Param, Query, Res, StreamableFile } from '@nestjs/common'; +import { + Controller, + Get, + NotFoundException, + Param, + Post, + Query, + Res, + StreamableFile, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { ApiTags } from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + ApiBadRequestResponse, + ApiBody, + ApiConsumes, + ApiCreatedResponse, + ApiInternalServerErrorResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { PaginationParams } from '@shared/controller/'; import { Response } from 'express'; import { CourseMapper } from '../mapper/course.mapper'; +import { CourseImportUc } from '../uc'; import { CourseExportUc } from '../uc/course-export.uc'; import { CourseUc } from '../uc/course.uc'; -import { CourseMetadataListResponse, CourseQueryParams, CourseUrlParams } from './dto'; +import { CommonCartridgeFileValidatorPipe } from '../utils'; +import { CourseImportBodyParams, CourseMetadataListResponse, CourseQueryParams, CourseUrlParams } from './dto'; @ApiTags('Courses') @Authenticate('jwt') @@ -16,6 +38,7 @@ export class CourseController { constructor( private readonly courseUc: CourseUc, private readonly courseExportUc: CourseExportUc, + private readonly courseImportUc: CourseImportUc, private readonly configService: ConfigService ) {} @@ -47,4 +70,20 @@ export class CourseController { }); return new StreamableFile(result); } + + @Post('import') + @UseInterceptors(FileInterceptor('file')) + @ApiOperation({ summary: 'Imports a course from a Common Cartridge file.' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ type: CourseImportBodyParams, required: true }) + @ApiCreatedResponse({ description: 'Course was successfully imported.' }) + @ApiBadRequestResponse({ description: 'Request data has invalid format.' }) + @ApiInternalServerErrorResponse({ description: 'Internal server error.' }) + public async importCourse( + @CurrentUser() currentUser: ICurrentUser, + @UploadedFile(CommonCartridgeFileValidatorPipe) + file: Express.Multer.File + ): Promise { + await this.courseImportUc.importFromCommonCartridge(currentUser.userId, file.buffer); + } } diff --git a/apps/server/src/modules/learnroom/controller/dto/course-import.body.params.ts b/apps/server/src/modules/learnroom/controller/dto/course-import.body.params.ts new file mode 100644 index 00000000000..e31448af2d0 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/course-import.body.params.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CourseImportBodyParams { + @ApiProperty({ + type: String, + format: 'binary', + required: true, + description: 'The Common Cartridge file to import.', + }) + file!: Express.Multer.File; +} diff --git a/apps/server/src/modules/learnroom/controller/dto/index.ts b/apps/server/src/modules/learnroom/controller/dto/index.ts index eb17ba3a672..3be2cba46f4 100644 --- a/apps/server/src/modules/learnroom/controller/dto/index.ts +++ b/apps/server/src/modules/learnroom/controller/dto/index.ts @@ -1,5 +1,7 @@ -export * from './course.url.params'; +export * from './course-import.body.params'; export * from './course-metadata.response'; +export * from './course.query.params'; +export * from './course.url.params'; export * from './dashboard.response'; export * from './dashboard.url.params'; export * from './lesson'; @@ -10,4 +12,3 @@ export * from './patch-visibility.params'; export * from './room-element.url.params'; export * from './room.url.params'; export * from './single-column-board'; -export * from './course.query.params'; diff --git a/apps/server/src/modules/learnroom/index.ts b/apps/server/src/modules/learnroom/index.ts index 94cbf86ff33..14ba48b81a6 100644 --- a/apps/server/src/modules/learnroom/index.ts +++ b/apps/server/src/modules/learnroom/index.ts @@ -1,9 +1,10 @@ +export * from './learnroom.config'; export * from './learnroom.module'; export { CommonCartridgeExportService, CourseCopyService, - CourseService, - RoomsService, CourseGroupService, + CourseService, DashboardService, + RoomsService, } from './service'; diff --git a/apps/server/src/modules/learnroom/learnroom-api.module.ts b/apps/server/src/modules/learnroom/learnroom-api.module.ts index a2a407daf21..8721ad28c14 100644 --- a/apps/server/src/modules/learnroom/learnroom-api.module.ts +++ b/apps/server/src/modules/learnroom/learnroom-api.module.ts @@ -12,6 +12,7 @@ import { RoomBoardResponseMapper } from './mapper/room-board-response.mapper'; import { CourseCopyUC, CourseExportUc, + CourseImportUc, CourseUc, DashboardUc, LessonCopyUC, @@ -33,6 +34,7 @@ import { CourseCopyUC, RoomsAuthorisationService, CourseExportUc, + CourseImportUc, // FIXME Refactor UCs to use services and remove these imports { provide: 'DASHBOARD_REPO', diff --git a/apps/server/src/modules/learnroom/learnroom.config.ts b/apps/server/src/modules/learnroom/learnroom.config.ts new file mode 100644 index 00000000000..39bb9baf386 --- /dev/null +++ b/apps/server/src/modules/learnroom/learnroom.config.ts @@ -0,0 +1,4 @@ +export interface LearnroomConfig { + FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED: boolean; + FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE: number; +} diff --git a/apps/server/src/modules/learnroom/learnroom.module.ts b/apps/server/src/modules/learnroom/learnroom.module.ts index d6516fb7f84..267cb01e074 100644 --- a/apps/server/src/modules/learnroom/learnroom.module.ts +++ b/apps/server/src/modules/learnroom/learnroom.module.ts @@ -2,6 +2,8 @@ import { BoardModule } from '@modules/board'; import { CopyHelperModule } from '@modules/copy-helper'; import { LessonModule } from '@modules/lesson'; import { TaskModule } from '@modules/task'; +import { ContextExternalToolModule } from '@modules/tool/context-external-tool'; +import { ToolConfigModule } from '@modules/tool/tool-config.module'; import { Module } from '@nestjs/common'; import { BoardRepo, @@ -13,18 +15,18 @@ import { UserRepo, } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { ContextExternalToolModule } from '@modules/tool/context-external-tool'; import { BoardCopyService, ColumnBoardTargetService, CommonCartridgeExportService, + CommonCartridgeImportService, CourseCopyService, CourseGroupService, CourseService, DashboardService, RoomsService, } from './service'; -import { ToolConfigModule } from '../tool/tool-config.module'; +import { CommonCartridgeFileValidatorPipe } from './utils'; @Module({ imports: [ @@ -51,16 +53,19 @@ import { ToolConfigModule } from '../tool/tool-config.module'; RoomsService, CourseService, CommonCartridgeExportService, + CommonCartridgeImportService, ColumnBoardTargetService, CourseGroupService, CourseGroupRepo, DashboardService, + CommonCartridgeFileValidatorPipe, ], exports: [ CourseCopyService, CourseService, RoomsService, CommonCartridgeExportService, + CommonCartridgeImportService, CourseGroupService, DashboardService, ], diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-import.service.spec.ts b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.spec.ts new file mode 100644 index 00000000000..303bb4340a5 --- /dev/null +++ b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.spec.ts @@ -0,0 +1,65 @@ +import { MikroORM } from '@mikro-orm/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities, userFactory } from '@shared/testing'; +import { readFile } from 'fs/promises'; +import { CommonCartridgeImportService } from './common-cartridge-import.service'; + +describe('CommonCartridgeImportService', () => { + let orm: MikroORM; + let moduleRef: TestingModule; + let sut: CommonCartridgeImportService; + + beforeEach(async () => { + orm = await setupEntities(); + moduleRef = await Test.createTestingModule({ + providers: [CommonCartridgeImportService], + }).compile(); + + sut = moduleRef.get(CommonCartridgeImportService); + }); + + afterAll(async () => { + await moduleRef.close(); + await orm.close(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('createCourse', () => { + describe('when the common cartridge is valid', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const buffer = await readFile('./apps/server/test/assets/common-cartridge/us_history_since_1877.imscc'); + + return { user, buffer }; + }; + + it('should return course with name from the common cartridge file', async () => { + const { user, buffer } = await setup(); + + const result = sut.createCourse(user, buffer); + + expect(result.name).toBe('201510-AMH-2020-70C-12218-US History Since 1877'); + }); + + it('should return course with teachers set', async () => { + const { user, buffer } = await setup(); + + const result = sut.createCourse(user, buffer); + + expect(result.teachers).toHaveLength(1); + expect(result.teachers[0]).toStrictEqual(user); + }); + + it('should return course with school set', async () => { + const { user, buffer } = await setup(); + + const result = sut.createCourse(user, buffer); + + expect(result.school).toStrictEqual(user.school); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts new file mode 100644 index 00000000000..ec822130b15 --- /dev/null +++ b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { Course, User } from '@shared/domain/entity'; +import { CommonCartridgeFileParser } from '@src/modules/common-cartridge/import'; + +@Injectable() +export class CommonCartridgeImportService { + public createCourse(user: User, file: Buffer): Course { + const parser = new CommonCartridgeFileParser(file); + const course = new Course({ teachers: [user], school: user.school, name: parser.manifest.getTitle() }); + + return course; + } +} diff --git a/apps/server/src/modules/learnroom/service/course.service.spec.ts b/apps/server/src/modules/learnroom/service/course.service.spec.ts index 923bc66c808..47a412fb3e3 100644 --- a/apps/server/src/modules/learnroom/service/course.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course.service.spec.ts @@ -152,4 +152,21 @@ describe('CourseService', () => { expect(courseRepo.findAllByUserId).toBeCalledWith(userId); }); }); + + describe('create', () => { + const setup = () => { + const course = courseFactory.buildWithId(); + courseRepo.createCourse.mockResolvedValueOnce(); + + return { course }; + }; + + it('should call createCourse from course repository', async () => { + const { course } = setup(); + + await expect(courseService.create(course)).resolves.not.toThrow(); + + expect(courseRepo.createCourse).toBeCalledWith(course); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/service/course.service.ts b/apps/server/src/modules/learnroom/service/course.service.ts index bcaa21b357f..0c81f4d66ef 100644 --- a/apps/server/src/modules/learnroom/service/course.service.ts +++ b/apps/server/src/modules/learnroom/service/course.service.ts @@ -65,6 +65,10 @@ export class CourseService { return courses; } + async create(course: Course): Promise { + await this.repo.createCourse(course); + } + private getCoursesId(courses: Course[]): EntityId[] { return courses.map((course) => course.id); } diff --git a/apps/server/src/modules/learnroom/service/index.ts b/apps/server/src/modules/learnroom/service/index.ts index f5e9336abcf..d647afbcf7a 100644 --- a/apps/server/src/modules/learnroom/service/index.ts +++ b/apps/server/src/modules/learnroom/service/index.ts @@ -1,8 +1,9 @@ export * from './board-copy.service'; -export * from './course-copy.service'; export * from './column-board-target.service'; export * from './common-cartridge-export.service'; +export * from './common-cartridge-import.service'; +export * from './course-copy.service'; export * from './course.service'; -export * from './rooms.service'; export * from './coursegroup.service'; export * from './dashboard.service'; +export * from './rooms.service'; diff --git a/apps/server/src/modules/learnroom/uc/course-import.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course-import.uc.spec.ts new file mode 100644 index 00000000000..b6e4dbe844e --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/course-import.uc.spec.ts @@ -0,0 +1,127 @@ +import { faker } from '@faker-js/faker'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { MikroORM } from '@mikro-orm/core'; +import { NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission } from '@shared/domain/interface'; +import { courseFactory, setupEntities, userFactory } from '@shared/testing'; +import { AuthorizationService } from '@src/modules/authorization'; +import { LearnroomConfig } from '../learnroom.config'; +import { CommonCartridgeImportService, CourseService } from '../service'; +import { CourseImportUc } from './course-import.uc'; + +describe('CourseImportUc', () => { + let module: TestingModule; + let sut: CourseImportUc; + let orm: MikroORM; + let configServiceMock: DeepMocked>; + let authorizationServiceMock: DeepMocked; + let courseServiceMock: DeepMocked; + let courseImportServiceMock: DeepMocked; + + beforeAll(async () => { + orm = await setupEntities(); + module = await Test.createTestingModule({ + providers: [ + CourseImportUc, + { + provide: ConfigService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: CourseService, + useValue: createMock(), + }, + { + provide: CommonCartridgeImportService, + useValue: createMock(), + }, + ], + }).compile(); + + sut = module.get(CourseImportUc); + configServiceMock = module.get(ConfigService); + authorizationServiceMock = module.get(AuthorizationService); + courseServiceMock = module.get(CourseService); + courseImportServiceMock = module.get(CommonCartridgeImportService); + }); + + afterAll(async () => { + await module.close(); + await orm.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('importFromCommonCartridge', () => { + describe('when the feature is enabled', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const course = courseFactory.buildWithId(); + const file = Buffer.from(''); + + configServiceMock.getOrThrow.mockReturnValue(true); + authorizationServiceMock.getUserWithPermissions.mockResolvedValue(user); + courseImportServiceMock.createCourse.mockReturnValue(course); + + return { user, course, file }; + }; + + it('should check the permissions', async () => { + const { user, file } = setup(); + + await sut.importFromCommonCartridge(user.id, file); + + expect(authorizationServiceMock.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.COURSE_CREATE]); + }); + + it('should create the course', async () => { + const { user, course, file } = setup(); + + await sut.importFromCommonCartridge(user.id, file); + + expect(courseServiceMock.create).toHaveBeenCalledWith(course); + }); + }); + + describe('when user has insufficient permissions', () => { + const setup = () => { + configServiceMock.getOrThrow.mockReturnValue(true); + authorizationServiceMock.checkAllPermissions.mockImplementation(() => { + throw new Error(); + }); + }; + + it('should throw', async () => { + setup(); + + await expect(sut.importFromCommonCartridge(faker.string.uuid(), Buffer.from(''))).rejects.toThrow(); + }); + }); + + describe('when the feature is disabled', () => { + const setup = () => { + configServiceMock.getOrThrow.mockReturnValue(false); + }; + + it('should throw', async () => { + setup(); + + await expect(sut.importFromCommonCartridge(faker.string.uuid(), Buffer.from(''))).rejects.toThrow( + NotFoundException + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/uc/course-import.uc.ts b/apps/server/src/modules/learnroom/uc/course-import.uc.ts new file mode 100644 index 00000000000..824848644e4 --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/course-import.uc.ts @@ -0,0 +1,31 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Permission } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { AuthorizationService } from '@src/modules/authorization'; +import { LearnroomConfig } from '../learnroom.config'; +import { CommonCartridgeImportService, CourseService } from '../service'; + +@Injectable() +export class CourseImportUc { + public constructor( + private readonly courseService: CourseService, + private readonly configService: ConfigService, + private readonly authorizationService: AuthorizationService, + private readonly courseImportService: CommonCartridgeImportService + ) {} + + public async importFromCommonCartridge(userId: EntityId, file: Buffer): Promise { + if (!this.configService.getOrThrow('FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED')) { + throw new NotFoundException(); + } + + const user = await this.authorizationService.getUserWithPermissions(userId); + + this.authorizationService.checkAllPermissions(user, [Permission.COURSE_CREATE]); + + const course = this.courseImportService.createCourse(user, file); + + await this.courseService.create(course); + } +} diff --git a/apps/server/src/modules/learnroom/uc/course.uc.ts b/apps/server/src/modules/learnroom/uc/course.uc.ts index fb901bb8c10..c988b087338 100644 --- a/apps/server/src/modules/learnroom/uc/course.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course.uc.ts @@ -7,9 +7,9 @@ import { CourseRepo } from '@shared/repo'; @Injectable() export class CourseUc { - constructor(private readonly courseRepo: CourseRepo) {} + public constructor(private readonly courseRepo: CourseRepo) {} - findAllByUser(userId: EntityId, options?: PaginationParams): Promise> { + public findAllByUser(userId: EntityId, options?: PaginationParams): Promise> { return this.courseRepo.findAllByUserId(userId, {}, { pagination: options, order: { updatedAt: SortOrder.desc } }); } } diff --git a/apps/server/src/modules/learnroom/uc/index.ts b/apps/server/src/modules/learnroom/uc/index.ts index 68311bc9268..4a42d6cff64 100644 --- a/apps/server/src/modules/learnroom/uc/index.ts +++ b/apps/server/src/modules/learnroom/uc/index.ts @@ -1,8 +1,9 @@ +export * from './course-copy.uc'; +export * from './course-export.uc'; +export * from './course-import.uc'; export * from './course.uc'; -export * from './rooms.uc'; export * from './dashboard.uc'; -export * from './course-copy.uc'; export * from './lesson-copy.uc'; -export * from './course-export.uc'; export * from './room-board-dto.factory'; export * from './rooms.authorisation.service'; +export * from './rooms.uc'; diff --git a/apps/server/src/modules/learnroom/utils/common-cartridge-file-validator.pipe.spec.ts b/apps/server/src/modules/learnroom/utils/common-cartridge-file-validator.pipe.spec.ts new file mode 100644 index 00000000000..76466b78e49 --- /dev/null +++ b/apps/server/src/modules/learnroom/utils/common-cartridge-file-validator.pipe.spec.ts @@ -0,0 +1,118 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import AdmZip from 'adm-zip'; +import { readFile } from 'node:fs/promises'; +import { CommonCartridgeFileValidatorPipe } from './common-cartridge-file-validator.pipe'; + +describe('CommonCartridgeFileValidatorPipe', () => { + let module: TestingModule; + let sut: CommonCartridgeFileValidatorPipe; + let configServiceMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + CommonCartridgeFileValidatorPipe, + { + provide: ConfigService, + useValue: createMock(), + }, + ], + }).compile(); + sut = module.get(CommonCartridgeFileValidatorPipe); + configServiceMock = module.get(ConfigService); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('transform', () => { + describe('when no file is provided', () => { + const setup = () => { + return { file: undefined as unknown as Express.Multer.File }; + }; + + it('should throw', () => { + const { file } = setup(); + + expect(() => sut.transform(file)).toThrow('No file uploaded'); + }); + }); + + describe('when the file is too big', () => { + const setup = () => { + configServiceMock.getOrThrow.mockReturnValue(1000); + + return { file: { size: 1001 } as unknown as Express.Multer.File }; + }; + + it('should throw', () => { + const { file } = setup(); + + expect(() => sut.transform(file)).toThrow('File is too large'); + }); + }); + + describe('when the file is not a zip archive', () => { + const setup = () => { + configServiceMock.getOrThrow.mockReturnValue(1000); + + return { + file: { size: 1000, buffer: Buffer.from('') } as unknown as Express.Multer.File, + }; + }; + + it('should throw', () => { + const { file } = setup(); + + expect(() => sut.transform(file)).toThrow('Invalid or unsupported zip format. No END header found'); + }); + }); + + describe('when the file does not contain a manifest file', () => { + const setup = () => { + const buffer = new AdmZip().toBuffer(); + + configServiceMock.get.mockReturnValue(1000); + + return { + file: { size: 1000, buffer } as unknown as Express.Multer.File, + }; + }; + + it('should throw', () => { + const { file } = setup(); + + expect(() => sut.transform(file)).toThrow('No manifest file found in the archive'); + }); + }); + + describe('when the file is valid', () => { + const setup = async () => { + const buffer = await readFile('./apps/server/test/assets/common-cartridge/us_history_since_1877.imscc'); + + configServiceMock.getOrThrow.mockReturnValue(1000); + + return { + file: { size: 1000, buffer } as unknown as Express.Multer.File, + }; + }; + + it('should return the file', async () => { + const { file } = await setup(); + + expect(sut.transform(file)).toBe(file); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/utils/common-cartridge-file-validator.pipe.ts b/apps/server/src/modules/learnroom/utils/common-cartridge-file-validator.pipe.ts new file mode 100644 index 00000000000..09cbe069ccb --- /dev/null +++ b/apps/server/src/modules/learnroom/utils/common-cartridge-file-validator.pipe.ts @@ -0,0 +1,47 @@ +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import AdmZip from 'adm-zip'; +import { LearnroomConfig } from '../learnroom.config'; + +@Injectable() +export class CommonCartridgeFileValidatorPipe implements PipeTransform { + constructor(private readonly configService: ConfigService) {} + + public transform(value: Express.Multer.File): Express.Multer.File { + this.checkValue(value); + this.checkSize(value); + this.checkFileType(value); + this.checkForManifestFile(new AdmZip(value.buffer)); + + return value; + } + + private checkValue(value: Express.Multer.File): void { + if (!value) { + throw new BadRequestException('No file uploaded'); + } + } + + private checkSize(value: Express.Multer.File): void { + if (value.size > this.configService.getOrThrow('FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE')) { + throw new BadRequestException('File is too large'); + } + } + + private checkFileType(value: Express.Multer.File): void { + try { + // checks if the file is a valid zip file + // eslint-disable-next-line no-new + new AdmZip(value.buffer); + } catch (error) { + throw new BadRequestException(error); + } + } + + private checkForManifestFile(archive: AdmZip): void { + const manifest = archive.getEntry('imsmanifest.xml') || archive.getEntry('manifest.xml'); + if (!manifest) { + throw new BadRequestException('No manifest file found in the archive'); + } + } +} diff --git a/apps/server/src/modules/learnroom/utils/index.ts b/apps/server/src/modules/learnroom/utils/index.ts new file mode 100644 index 00000000000..b31e3aa0710 --- /dev/null +++ b/apps/server/src/modules/learnroom/utils/index.ts @@ -0,0 +1 @@ +export * from './common-cartridge-file-validator.pipe'; diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 92e0a512ef4..465129084ef 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -9,6 +9,7 @@ import type { SchoolConfig } from '@modules/school'; import type { UserConfig } from '@modules/user'; import type { CoreModuleConfig } from '@src/core'; import { MailConfig } from '@src/infra/mail/interfaces/mail-config'; +import { LearnroomConfig } from '../learnroom'; export enum NodeEnvType { TEST = 'test', @@ -27,6 +28,7 @@ export interface ServerConfig SchoolConfig, MailConfig, XApiKeyConfig, + LearnroomConfig, AuthenticationConfig, SchulconnexClientConfig { NODE_ENV: string; @@ -56,6 +58,12 @@ const config: ServerConfig = { BLOCKLIST_OF_EMAIL_DOMAINS: (Configuration.get('BLOCKLIST_OF_EMAIL_DOMAINS') as string) .split(',') .map((domain) => domain.trim()), + FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED: Configuration.get( + 'FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED' + ) as boolean, + FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE: Configuration.get( + 'FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE' + ) as number, SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS: Configuration.get( 'SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS' ) as number, diff --git a/apps/server/src/shared/testing/test-api-client.ts b/apps/server/src/shared/testing/test-api-client.ts index e264d54ec10..39d61d31047 100644 --- a/apps/server/src/shared/testing/test-api-client.ts +++ b/apps/server/src/shared/testing/test-api-client.ts @@ -81,6 +81,21 @@ export class TestApiClient { return testRequestInstance; } + public postWithAttachment( + subPath: string | undefined, + fieldName: string, + data: Buffer, + fileName: string + ): supertest.Test { + const path = this.getPath(subPath); + const testRequestInstance = supertest(this.app.getHttpServer()) + .post(path) + .set('authorization', this.formattedJwt) + .attach(fieldName, data, fileName); + + return testRequestInstance; + } + public async login(account: Account): Promise { const path = testReqestConst.loginPath; const params: { username: string; password: string } = { diff --git a/apps/server/test/assets/common-cartridge/us_history_since_1877.imscc b/apps/server/test/assets/common-cartridge/us_history_since_1877.imscc new file mode 100644 index 00000000000..6557fb2686a Binary files /dev/null and b/apps/server/test/assets/common-cartridge/us_history_since_1877.imscc differ diff --git a/config/default.schema.json b/config/default.schema.json index 876bcd6dd97..ffadc78f5df 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -315,12 +315,7 @@ "H5P_EDITOR": { "type": "object", "description": "Properties of the H5P server microservice and library management job", - "required": [ - "S3_ENDPOINT", - "S3_REGION", - "S3_BUCKET_CONTENT", - "S3_BUCKET_LIBRARIES" - ], + "required": ["S3_ENDPOINT", "S3_REGION", "S3_BUCKET_CONTENT", "S3_BUCKET_LIBRARIES"], "default": {}, "properties": { "S3_ENDPOINT": { @@ -1180,6 +1175,16 @@ "default": false, "description": "Toggle for the IMSCC course download feature." }, + "FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED": { + "type": "boolean", + "default": false, + "description": "Toggle for the Common Cartridge course import feature." + }, + "FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE": { + "type": "integer", + "default": 2000000000, + "description": "The maximum file upload size in bytes for the Common Cartridge file during import." + }, "GHOST_BASE_URL": { "type": "string", "format": "uri", diff --git a/config/development.json b/config/development.json index 60b76513405..4c40c42d589 100644 --- a/config/development.json +++ b/config/development.json @@ -70,6 +70,7 @@ }, "HYDRA_URI": "http://localhost:9001", "FEATURE_IMSCC_COURSE_EXPORT_ENABLED": true, + "FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED": true, "NEST_LOG_LEVEL": "debug", "SESSION_SECRET": "dev-session-secret", "FEATURE_COURSE_SHARE": true, diff --git a/config/test.json b/config/test.json index 4e0ddd6628b..eb4d6715824 100644 --- a/config/test.json +++ b/config/test.json @@ -44,6 +44,7 @@ "CALENDAR_URI": "https://schul.tech:3000", "HYDRA_URI": "http://hydra:9000", "FEATURE_IMSCC_COURSE_EXPORT_ENABLED": true, + "FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED": true, "SESSION": { "SAME_SITE": "lax", "SECURE": false, diff --git a/package-lock.json b/package-lock.json index 55333175e3c..74f8a4b2e82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,7 @@ "i18next-fs-backend": "^2.1.5", "ioredis": "^5.3.2", "jose": "^1.28.1", + "jsdom": "^23.2.0", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^2.0.5", "ldapjs": "git://github.com/hpi-schul-cloud/node-ldapjs.git", @@ -441,6 +442,16 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-2.0.2.tgz", + "integrity": "sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ==", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^2.3.1", + "is-potential-custom-element-name": "^1.0.1" + } + }, "node_modules/@aws-crypto/crc32": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", @@ -8086,6 +8097,14 @@ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big-integer": { "version": "1.6.51", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", @@ -10010,6 +10029,18 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", @@ -10021,6 +10052,17 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssstyle": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", + "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/d": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", @@ -10060,6 +10102,41 @@ "node": ">=0.10" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz", @@ -10096,6 +10173,11 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -13795,6 +13877,17 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-entities": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.2.tgz", @@ -13824,6 +13917,45 @@ "entities": "^2.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -14490,6 +14622,11 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -16838,6 +16975,107 @@ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, + "node_modules/jsdom": { + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", + "integrity": "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==", + "dependencies": { + "@asamuzakjp/dom-selector": "^2.0.1", + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.16.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -17821,6 +18059,11 @@ "node": ">=10.13.0" } }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -21128,9 +21371,9 @@ } }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "engines": { "node": ">=6" } @@ -22266,6 +22509,11 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==" + }, "node_modules/rss-parser": { "version": "3.13.0", "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", @@ -22437,6 +22685,17 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -23740,6 +23999,11 @@ "node": ">=0.10" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + }, "node_modules/synckit": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.4.tgz", @@ -24212,6 +24476,28 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", @@ -25361,6 +25647,17 @@ "node": ">=6.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -25486,6 +25783,36 @@ "acorn": "^8" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", @@ -25827,6 +26154,14 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "engines": { + "node": ">=18" + } + }, "node_modules/xml2js": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", @@ -25856,6 +26191,11 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + }, "node_modules/xmldoc": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-1.3.0.tgz", @@ -26287,6 +26627,16 @@ } } }, + "@asamuzakjp/dom-selector": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-2.0.2.tgz", + "integrity": "sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ==", + "requires": { + "bidi-js": "^1.0.3", + "css-tree": "^2.3.1", + "is-potential-custom-element-name": "^1.0.1" + } + }, "@aws-crypto/crc32": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", @@ -32020,6 +32370,14 @@ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" }, + "bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "requires": { + "require-from-string": "^2.0.2" + } + }, "big-integer": { "version": "1.6.51", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", @@ -33550,11 +33908,28 @@ } } }, + "css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "requires": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + } + }, "css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" }, + "cssstyle": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", + "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", + "requires": { + "rrweb-cssom": "^0.6.0" + } + }, "d": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", @@ -33588,6 +33963,34 @@ "assert-plus": "^1.0.0" } }, + "data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "requires": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "dependencies": { + "tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "requires": { + "punycode": "^2.3.1" + } + }, + "whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "requires": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + } + } + } + }, "date-fns": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz", @@ -33606,6 +34009,11 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, + "decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, "dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -36326,6 +36734,14 @@ "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", "dev": true }, + "html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "requires": { + "whatwg-encoding": "^3.1.1" + } + }, "html-entities": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.2.tgz", @@ -36348,6 +36764,33 @@ "entities": "^2.0.0" } }, + "http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "requires": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "dependencies": { + "agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "requires": { + "debug": "^4.3.4" + } + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + } + } + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -36794,6 +37237,11 @@ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true }, + "is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, "is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -38586,6 +39034,78 @@ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, + "jsdom": { + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", + "integrity": "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==", + "requires": { + "@asamuzakjp/dom-selector": "^2.0.1", + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.16.0", + "xml-name-validator": "^5.0.0" + }, + "dependencies": { + "agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "requires": { + "debug": "^4.3.4" + } + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "requires": { + "agent-base": "^7.0.2", + "debug": "4" + } + }, + "tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "requires": { + "punycode": "^2.3.1" + } + }, + "whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "requires": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + } + } + } + }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -39358,6 +39878,11 @@ "integrity": "sha512-xbEFXCYVWrSx/gEKS1VPlg84h/4L20znVIulKw6kMfmBUAZNAnF00eczz9ICMl+/hjQGo5KSXRxbL/47X3rmMw==", "dev": true }, + "mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==" + }, "media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -41861,9 +42386,9 @@ } }, "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, "qs": { "version": "6.11.0", @@ -42718,6 +43243,11 @@ "fsevents": "~2.3.2" } }, + "rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==" + }, "rss-parser": { "version": "3.13.0", "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", @@ -42843,6 +43373,14 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" }, + "saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "requires": { + "xmlchars": "^2.2.0" + } + }, "schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -43894,6 +44432,11 @@ "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", "dev": true }, + "symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + }, "synckit": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.4.tgz", @@ -44258,6 +44801,24 @@ "nopt": "~1.0.10" } }, + "tough-cookie": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "requires": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "dependencies": { + "universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==" + } + } + }, "tr46": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", @@ -45119,6 +45680,14 @@ } } }, + "w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "requires": { + "xml-name-validator": "^5.0.0" + } + }, "walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -45211,6 +45780,29 @@ "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", "dev": true }, + "whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "requires": { + "iconv-lite": "0.6.3" + }, + "dependencies": { + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, + "whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" + }, "whatwg-url": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", @@ -45467,6 +46059,11 @@ "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", "requires": {} }, + "xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==" + }, "xml2js": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", @@ -45489,6 +46086,11 @@ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" }, + "xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + }, "xmldoc": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-1.3.0.tgz", diff --git a/package.json b/package.json index e6e66cfdc3a..4cff9ea0c6b 100644 --- a/package.json +++ b/package.json @@ -188,6 +188,7 @@ "i18next-fs-backend": "^2.1.5", "ioredis": "^5.3.2", "jose": "^1.28.1", + "jsdom": "^23.2.0", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^2.0.5", "ldapjs": "git://github.com/hpi-schul-cloud/node-ldapjs.git", diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index 1724beaa2ff..f1fbc872afc 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -53,6 +53,7 @@ const exposedVars = [ 'GHOST_BASE_URL', 'FEATURE_CONSENT_NECESSARY', 'FEATURE_IMSCC_COURSE_EXPORT_ENABLED', + 'FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED', 'FEATURE_SCHOOL_SANIS_USER_MIGRATION_ENABLED', 'FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED', 'FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED',