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',