diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 23ce88b904c..7d0314208c6 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger import { ContentElementType } from '@shared/domain'; import { InputFormat } from '@shared/domain/types'; import { Type } from 'class-transformer'; -import { IsDate, IsEnum, IsMongoId, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator'; +import { IsDate, IsEnum, IsMongoId, IsOptional, IsString, ValidateNested } from 'class-validator'; export abstract class ElementContentBody { @IsEnum(ContentElementType) @@ -34,7 +34,7 @@ export class FileElementContentBody extends ElementContentBody { } export class LinkContentBody { - @IsUrl() + @IsString() @ApiProperty({}) url!: string; diff --git a/apps/server/src/modules/meta-tag-extractor/controller/api-test/meta-tag-extractor-get-data.api.spec.ts b/apps/server/src/modules/meta-tag-extractor/controller/api-test/meta-tag-extractor-get-data.api.spec.ts index c80d47df66d..2a6c2a93d90 100644 --- a/apps/server/src/modules/meta-tag-extractor/controller/api-test/meta-tag-extractor-get-data.api.spec.ts +++ b/apps/server/src/modules/meta-tag-extractor/controller/api-test/meta-tag-extractor-get-data.api.spec.ts @@ -5,7 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; import { MetaTagExtractorService } from '../../service'; -const URL = 'https://test.de'; +const URL = 'https://best-example.de/my-article'; const mockedResponse = { url: URL, @@ -13,7 +13,7 @@ const mockedResponse = { description: 'with great description', }; -describe(`get data (api)`, () => { +describe(`get meta tags (api)`, () => { let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; @@ -24,7 +24,7 @@ describe(`get data (api)`, () => { }) .overrideProvider(MetaTagExtractorService) .useValue({ - fetchMetaData: () => mockedResponse, + getMetaData: () => mockedResponse, }) .compile(); @@ -63,7 +63,7 @@ describe(`get data (api)`, () => { const response = await loggedInClient.post(undefined, { url: URL }); - expect(response?.body).toEqual(mockedResponse); + expect(response?.body).toEqual(expect.objectContaining(mockedResponse)); }); }); diff --git a/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.spec.ts b/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.spec.ts index 29dfbd94c72..0f527d1139d 100644 --- a/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.spec.ts +++ b/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.spec.ts @@ -8,13 +8,19 @@ describe(MetaTagExtractorResponse.name, () => { title: 'Testbild', description: 'Here we describe what this page is about.', imageUrl: 'https://www.abc.de/test.png', + type: 'unknown', + parentTitle: 'Math', + parentType: 'course', }; - const errorResponse = new MetaTagExtractorResponse(properties); - expect(errorResponse.url).toEqual(properties.url); - expect(errorResponse.title).toEqual(properties.title); - expect(errorResponse.description).toEqual(properties.description); - expect(errorResponse.imageUrl).toEqual(properties.imageUrl); + const response = new MetaTagExtractorResponse(properties); + expect(response.url).toEqual(properties.url); + expect(response.title).toEqual(properties.title); + expect(response.description).toEqual(properties.description); + expect(response.imageUrl).toEqual(properties.imageUrl); + expect(response.type).toEqual(properties.type); + expect(response.parentTitle).toEqual(properties.parentTitle); + expect(response.parentType).toEqual(properties.parentType); }); }); }); diff --git a/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.ts b/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.ts index a2f5acd8465..16863f0e16a 100644 --- a/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.ts +++ b/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.ts @@ -1,13 +1,17 @@ import { ApiProperty } from '@nestjs/swagger'; import { DecodeHtmlEntities } from '@shared/controller'; import { IsString, IsUrl } from 'class-validator'; +import { MetaDataEntityType } from '../../types'; export class MetaTagExtractorResponse { - constructor({ url, title, description, imageUrl }: MetaTagExtractorResponse) { + constructor({ url, title, description, imageUrl, type, parentTitle, parentType }: MetaTagExtractorResponse) { this.url = url; this.title = title; this.description = description; this.imageUrl = imageUrl; + this.type = type; + this.parentTitle = parentTitle; + this.parentType = parentType; } @ApiProperty() @@ -25,4 +29,16 @@ export class MetaTagExtractorResponse { @ApiProperty() @IsString() imageUrl?: string; + + @ApiProperty() + @IsString() + type: MetaDataEntityType; + + @ApiProperty() + @DecodeHtmlEntities() + parentTitle?: string; + + @ApiProperty() + @IsString() + parentType?: MetaDataEntityType; } diff --git a/apps/server/src/modules/meta-tag-extractor/controller/meta-tag-extractor.controller.ts b/apps/server/src/modules/meta-tag-extractor/controller/meta-tag-extractor.controller.ts index 8133c4c0b83..79f798c9e29 100644 --- a/apps/server/src/modules/meta-tag-extractor/controller/meta-tag-extractor.controller.ts +++ b/apps/server/src/modules/meta-tag-extractor/controller/meta-tag-extractor.controller.ts @@ -1,7 +1,6 @@ +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; import { Body, Controller, InternalServerErrorException, Post, UnauthorizedException } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { ICurrentUser } from '@src/modules/authentication'; -import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; import { MetaTagExtractorUc } from '../uc'; import { MetaTagExtractorResponse } from './dto'; import { GetMetaTagDataBody } from './post-link-url.body.params'; @@ -17,11 +16,11 @@ export class MetaTagExtractorController { @ApiResponse({ status: 401, type: UnauthorizedException }) @ApiResponse({ status: 500, type: InternalServerErrorException }) @Post('') - async getData( + async getMetaTags( @CurrentUser() currentUser: ICurrentUser, @Body() bodyParams: GetMetaTagDataBody ): Promise { - const result = await this.metaTagExtractorUc.fetchMetaData(currentUser.userId, bodyParams.url); + const result = await this.metaTagExtractorUc.getMetaData(currentUser.userId, bodyParams.url); const imageUrl = result.image?.url; const response = new MetaTagExtractorResponse({ ...result, imageUrl }); return response; diff --git a/apps/server/src/modules/meta-tag-extractor/controller/post-link-url.body.params.ts b/apps/server/src/modules/meta-tag-extractor/controller/post-link-url.body.params.ts index 1e9cd1f7f34..ac6baeebbe8 100644 --- a/apps/server/src/modules/meta-tag-extractor/controller/post-link-url.body.params.ts +++ b/apps/server/src/modules/meta-tag-extractor/controller/post-link-url.body.params.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsUrl } from 'class-validator'; +import { IsString } from 'class-validator'; export class GetMetaTagDataBody { - @IsUrl() + @IsString() @ApiProperty({ required: true, nullable: false, diff --git a/apps/server/src/modules/meta-tag-extractor/interface/url-handler.ts b/apps/server/src/modules/meta-tag-extractor/interface/url-handler.ts new file mode 100644 index 00000000000..fc09d2cd40e --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/interface/url-handler.ts @@ -0,0 +1,6 @@ +import { MetaData } from '../types'; + +export interface UrlHandler { + doesUrlMatch(url: string): boolean; + getMetaData(url: string): Promise; +} diff --git a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor-api.module.ts b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor-api.module.ts index d9095315e87..acc5eb31776 100644 --- a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor-api.module.ts +++ b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor-api.module.ts @@ -1,6 +1,6 @@ +import { AuthorizationModule } from '@modules/authorization'; import { forwardRef, Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; -import { AuthorizationModule } from '@src/modules/authorization'; import { MetaTagExtractorController } from './controller'; import { MetaTagExtractorModule } from './meta-tag-extractor.module'; import { MetaTagExtractorUc } from './uc'; diff --git a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts index a85e71c526f..d7a1e0faeff 100644 --- a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts +++ b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts @@ -5,20 +5,37 @@ import { ConsoleWriterModule } from '@infra/console'; import { createConfigModuleOptions } from '@src/config'; import { LoggerModule } from '@src/core/logger'; import { AuthenticationModule } from '../authentication/authentication.module'; +import { BoardModule } from '../board'; +import { LearnroomModule } from '../learnroom'; +import { LessonModule } from '../lesson'; +import { TaskModule } from '../task'; import { UserModule } from '../user'; import metaTagExtractorConfig from './meta-tag-extractor.config'; import { MetaTagExtractorService } from './service'; +import { MetaTagInternalUrlService } from './service/meta-tag-internal-url.service'; +import { BoardUrlHandler, CourseUrlHandler, LessonUrlHandler, TaskUrlHandler } from './service/url-handler'; @Module({ imports: [ AuthenticationModule, + BoardModule, ConsoleWriterModule, HttpModule, + LearnroomModule, + LessonModule, LoggerModule, + TaskModule, UserModule, ConfigModule.forRoot(createConfigModuleOptions(metaTagExtractorConfig)), ], - providers: [MetaTagExtractorService], + providers: [ + MetaTagExtractorService, + MetaTagInternalUrlService, + TaskUrlHandler, + LessonUrlHandler, + CourseUrlHandler, + BoardUrlHandler, + ], exports: [MetaTagExtractorService], }) export class MetaTagExtractorModule {} diff --git a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.spec.ts index af1a256d121..06fa3b09170 100644 --- a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.spec.ts +++ b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.spec.ts @@ -1,35 +1,52 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities } from '@shared/testing'; +import ogs from 'open-graph-scraper'; import { ImageObject } from 'open-graph-scraper/dist/lib/types'; import { MetaTagExtractorService } from './meta-tag-extractor.service'; +import { MetaTagInternalUrlService } from './meta-tag-internal-url.service'; -let ogsResponseMock = {}; -let ogsRejectMock: Error | undefined; - -jest.mock('open-graph-scraper', () => () => { - if (ogsRejectMock) { - return Promise.reject(ogsRejectMock); - } +jest.mock('open-graph-scraper', () => { + return { + __esModule: true, + default: jest.fn(), + }; +}); - return Promise.resolve({ +const mockOgsResolve = (result: Record) => { + const mockedOgs = ogs as jest.Mock; + mockedOgs.mockResolvedValueOnce({ error: false, html: '', response: {}, - result: ogsResponseMock, + result, }); -}); +}; + +const mockOgsReject = (error: Error) => { + const mockedOgs = ogs as jest.Mock; + mockedOgs.mockRejectedValueOnce(error); +}; describe(MetaTagExtractorService.name, () => { let module: TestingModule; + let metaTagInternalUrlService: DeepMocked; let service: MetaTagExtractorService; beforeAll(async () => { module = await Test.createTestingModule({ - providers: [MetaTagExtractorService], + providers: [ + MetaTagExtractorService, + { + provide: MetaTagInternalUrlService, + useValue: createMock(), + }, + ], }).compile(); + metaTagInternalUrlService = module.get(MetaTagInternalUrlService); service = module.get(MetaTagExtractorService); - await setupEntities(); }); @@ -38,8 +55,8 @@ describe(MetaTagExtractorService.name, () => { }); beforeEach(() => { - ogsResponseMock = {}; - ogsRejectMock = undefined; + Configuration.set('SC_DOMAIN', 'localhost'); + metaTagInternalUrlService.tryInternalLinkMetaTags.mockResolvedValue(undefined); }); afterEach(() => { @@ -48,26 +65,28 @@ describe(MetaTagExtractorService.name, () => { describe('create', () => { describe('when url points to webpage', () => { + it('should thrown an error if url is an empty string', async () => { + const url = ''; + + await expect(service.getMetaData(url)).rejects.toThrow(); + }); + it('should return also the original url', async () => { + const ogTitle = 'My Title'; const url = 'https://de.wikipedia.org'; + mockOgsResolve({ url, ogTitle }); - const result = await service.fetchMetaData(url); + const result = await service.getMetaData(url); expect(result).toEqual(expect.objectContaining({ url })); }); - it('should thrown an error if url is an empty string', async () => { - const url = ''; - - await expect(service.fetchMetaData(url)).rejects.toThrow(); - }); - it('should return ogTitle as title', async () => { const ogTitle = 'My Title'; const url = 'https://de.wikipedia.org'; - ogsResponseMock = { ogTitle }; + mockOgsResolve({ ogTitle }); - const result = await service.fetchMetaData(url); + const result = await service.getMetaData(url); expect(result).toEqual(expect.objectContaining({ title: ogTitle })); }); @@ -91,9 +110,9 @@ describe(MetaTagExtractorService.name, () => { }, ]; const url = 'https://de.wikipedia.org'; - ogsResponseMock = { ogImage }; + mockOgsResolve({ url, ogImage }); - const result = await service.fetchMetaData(url); + const result = await service.getMetaData(url); expect(result).toEqual(expect.objectContaining({ image: ogImage[1] })); }); @@ -102,9 +121,10 @@ describe(MetaTagExtractorService.name, () => { describe('when url points to a file', () => { it('should return filename as title', async () => { const url = 'https://de.wikipedia.org/abc.jpg'; - ogsRejectMock = new Error('no open graph data included... probably not a webpage'); - const result = await service.fetchMetaData(url); + mockOgsReject(new Error('no open graph data included... probably not a webpage')); + + const result = await service.getMetaData(url); expect(result).toEqual(expect.objectContaining({ title: 'abc.jpg' })); }); }); @@ -112,9 +132,10 @@ describe(MetaTagExtractorService.name, () => { describe('when url is invalid', () => { it('should return url as it is', async () => { const url = 'not-a-real-domain'; - ogsRejectMock = new Error('no open graph data included... probably not a webpage'); - const result = await service.fetchMetaData(url); + mockOgsReject(new Error('no open graph data included... probably not a webpage')); + + const result = await service.getMetaData(url); expect(result).toEqual(expect.objectContaining({ url, title: '', description: '' })); }); }); diff --git a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.ts b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.ts index 46c30c17702..d64c3a42a58 100644 --- a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.ts +++ b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.ts @@ -2,24 +2,29 @@ import { Injectable } from '@nestjs/common'; import ogs from 'open-graph-scraper'; import { ImageObject } from 'open-graph-scraper/dist/lib/types'; import { basename } from 'path'; - -export type MetaData = { - title: string; - description: string; - url: string; - image?: ImageObject; -}; +import type { MetaData } from '../types'; +import { MetaTagInternalUrlService } from './meta-tag-internal-url.service'; @Injectable() export class MetaTagExtractorService { - async fetchMetaData(url: string): Promise { + constructor(private readonly internalLinkMataTagService: MetaTagInternalUrlService) {} + + async getMetaData(url: string): Promise { if (url.length === 0) { throw new Error(`MetaTagExtractorService requires a valid URL. Given URL: ${url}`); } - const metaData = (await this.tryExtractMetaTags(url)) ?? this.tryFilenameAsFallback(url); + const metaData = + (await this.tryInternalLinkMetaTags(url)) ?? + (await this.tryExtractMetaTags(url)) ?? + this.tryFilenameAsFallback(url) ?? + this.getDefaultMetaData(url); + + return metaData; + } - return metaData ?? { url, title: '', description: '' }; + private async tryInternalLinkMetaTags(url: string): Promise { + return this.internalLinkMataTagService.tryInternalLinkMetaTags(url); } private async tryExtractMetaTags(url: string): Promise { @@ -35,6 +40,7 @@ export class MetaTagExtractorService { description, image, url, + type: 'external', }; } catch (error) { return undefined; @@ -49,12 +55,17 @@ export class MetaTagExtractorService { title, description: '', url, + type: 'unknown', }; } catch (error) { return undefined; } } + private getDefaultMetaData(url: string): MetaData { + return { url, title: '', description: '', type: 'unknown' }; + } + private pickImage(images: ImageObject[], minWidth = 400): ImageObject | undefined { const sortedImages = [...images]; sortedImages.sort((a, b) => (a.width && b.width ? Number(a.width) - Number(b.width) : 0)); diff --git a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.spec.ts new file mode 100644 index 00000000000..04d2f8b0c77 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.spec.ts @@ -0,0 +1,136 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities } from '@shared/testing'; +import { MetaData } from '../types'; +import { MetaTagExtractorService } from './meta-tag-extractor.service'; +import { MetaTagInternalUrlService } from './meta-tag-internal-url.service'; +import { BoardUrlHandler, CourseUrlHandler, LessonUrlHandler, TaskUrlHandler } from './url-handler'; + +const INTERNAL_DOMAIN = 'my-school-cloud.org'; +const INTERNAL_URL = `https://${INTERNAL_DOMAIN}/my-article`; +const UNKNOWN_INTERNAL_URL = `https://${INTERNAL_DOMAIN}/playground/23hafe23234`; +const EXTERNAL_URL = 'https://de.wikipedia.org/example-article'; + +describe(MetaTagExtractorService.name, () => { + let module: TestingModule; + let taskUrlHandler: DeepMocked; + let lessonUrlHandler: DeepMocked; + let courseUrlHandler: DeepMocked; + let boardUrlHandler: DeepMocked; + let service: MetaTagInternalUrlService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + MetaTagInternalUrlService, + { + provide: TaskUrlHandler, + useValue: createMock(), + }, + { + provide: LessonUrlHandler, + useValue: createMock(), + }, + { + provide: CourseUrlHandler, + useValue: createMock(), + }, + { + provide: BoardUrlHandler, + useValue: createMock(), + }, + ], + }).compile(); + + taskUrlHandler = module.get(TaskUrlHandler); + lessonUrlHandler = module.get(LessonUrlHandler); + courseUrlHandler = module.get(CourseUrlHandler); + boardUrlHandler = module.get(BoardUrlHandler); + service = module.get(MetaTagInternalUrlService); + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => {}); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('isInternalUrl', () => { + const setup = () => { + Configuration.set('SC_DOMAIN', INTERNAL_DOMAIN); + taskUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + lessonUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + courseUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + boardUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + }; + + it('should return true for internal urls', () => { + setup(); + + expect(service.isInternalUrl(INTERNAL_URL)).toBe(true); + }); + + it('should return false for external urls', () => { + setup(); + + expect(service.isInternalUrl(EXTERNAL_URL)).toBe(false); + }); + }); + + describe('tryInternalLinkMetaTags', () => { + const setup = () => { + Configuration.set('SC_DOMAIN', INTERNAL_DOMAIN); + taskUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + lessonUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + boardUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + const mockedMetaTags: MetaData = { + title: 'My Title', + url: INTERNAL_URL, + description: '', + type: 'course', + }; + + return { mockedMetaTags }; + }; + + describe('when url matches to a handler', () => { + it('should return the handlers meta tags', async () => { + const { mockedMetaTags } = setup(); + courseUrlHandler.doesUrlMatch.mockReturnValueOnce(true); + courseUrlHandler.getMetaData.mockResolvedValueOnce(mockedMetaTags); + + const result = await service.tryInternalLinkMetaTags(INTERNAL_URL); + + expect(result).toEqual(mockedMetaTags); + }); + }); + + describe('when url matches to none of the handlers', () => { + it('should return default meta tags', async () => { + setup(); + courseUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + + const result = await service.tryInternalLinkMetaTags(UNKNOWN_INTERNAL_URL); + + expect(result).toEqual(expect.objectContaining({ type: 'unknown' })); + }); + }); + + describe('when url is external', () => { + it('should return undefined', async () => { + setup(); + courseUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + + const result = await service.tryInternalLinkMetaTags(EXTERNAL_URL); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.ts b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.ts new file mode 100644 index 00000000000..5c0d5efca5c --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.ts @@ -0,0 +1,51 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { Injectable } from '@nestjs/common'; +import type { UrlHandler } from '../interface/url-handler'; +import { MetaData } from '../types'; +import { BoardUrlHandler, CourseUrlHandler, LessonUrlHandler, TaskUrlHandler } from './url-handler'; + +@Injectable() +export class MetaTagInternalUrlService { + private handlers: UrlHandler[] = []; + + constructor( + private readonly taskUrlHandler: TaskUrlHandler, + private readonly lessonUrlHandler: LessonUrlHandler, + private readonly courseUrlHandler: CourseUrlHandler, + private readonly boardUrlHandler: BoardUrlHandler + ) { + this.handlers = [this.taskUrlHandler, this.lessonUrlHandler, this.courseUrlHandler, this.boardUrlHandler]; + } + + async tryInternalLinkMetaTags(url: string): Promise { + if (this.isInternalUrl(url)) { + return this.composeMetaTags(url); + } + return Promise.resolve(undefined); + } + + isInternalUrl(url: string) { + let domain = Configuration.get('SC_DOMAIN') as string; + domain = domain === '' ? 'nothing-configured-for-internal-url.de' : domain; + const isInternal = url.toLowerCase().includes(domain.toLowerCase()); + return isInternal; + } + + private async composeMetaTags(url: string): Promise { + const urlObject = new URL(url); + + const handler = this.handlers.find((h) => h.doesUrlMatch(url)); + if (handler) { + const result = await handler.getMetaData(url); + return result; + } + + const title = urlObject.pathname; + return Promise.resolve({ + title, + description: '', + url, + type: 'unknown', + }); + } +} diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.spec.ts new file mode 100644 index 00000000000..b6900cfd492 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.spec.ts @@ -0,0 +1,59 @@ +import { AbstractUrlHandler } from './abstract-url-handler'; + +class DummyHandler extends AbstractUrlHandler { + patterns: RegExp[] = [/\/dummy\/([0-9a-z]+)$/i]; + + extractId(url: string): string | undefined { + return super.extractId(url); + } +} + +describe(AbstractUrlHandler.name, () => { + const setup = () => { + const id = 'af322312feae'; + const url = `https://localhost/dummy/${id}`; + const invalidUrl = `https://localhost/wrong/${id}`; + const handler = new DummyHandler(); + return { id, url, invalidUrl, handler }; + }; + + describe('extractId', () => { + describe('when no id was extracted', () => { + it('should return undefined', () => { + const { invalidUrl, handler } = setup(); + + const result = handler.extractId(invalidUrl); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe('doesUrlMatch', () => { + it('should be true for valid urls', () => { + const { url, handler } = setup(); + + const result = handler.doesUrlMatch(url); + + expect(result).toBe(true); + }); + + it('should be false for invalid urls', () => { + const { invalidUrl, handler } = setup(); + + const result = handler.doesUrlMatch(invalidUrl); + + expect(result).toBe(false); + }); + }); + + describe('getDefaultMetaData', () => { + it('should return meta data of type unknown', () => { + const { url, handler } = setup(); + + const result = handler.getDefaultMetaData(url); + + expect(result).toEqual(expect.objectContaining({ type: 'unknown', url })); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.ts new file mode 100644 index 00000000000..fb618c3bf36 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.ts @@ -0,0 +1,35 @@ +import { basename } from 'node:path'; +import { MetaData } from '../../types'; + +export abstract class AbstractUrlHandler { + protected abstract patterns: RegExp[]; + + protected extractId(url: string): string | undefined { + const results: RegExpMatchArray = this.patterns + .map((pattern: RegExp) => pattern.exec(url)) + .filter((result) => result !== null) + .find((result) => (result?.length ?? 0) >= 2) as RegExpMatchArray; + + if (results && results[1]) { + return results[1]; + } + return undefined; + } + + doesUrlMatch(url: string): boolean { + const doesMatch = this.patterns.some((pattern) => pattern.test(url)); + return doesMatch; + } + + getDefaultMetaData(url: string, partial: Partial = {}): MetaData { + const urlObject = new URL(url); + const title = basename(urlObject.pathname); + return { + title, + description: '', + url, + type: 'unknown', + ...partial, + }; + } +} diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.spec.ts new file mode 100644 index 00000000000..f7775b58f1f --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.spec.ts @@ -0,0 +1,70 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ColumnBoardService } from '@modules/board'; +import { CourseService } from '@modules/learnroom'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ColumnBoard } from '@shared/domain'; +import { setupEntities } from '@shared/testing'; +import { BoardUrlHandler } from './board-url-handler'; + +describe(BoardUrlHandler.name, () => { + let module: TestingModule; + let columnBoardService: DeepMocked; + let boardUrlHandler: BoardUrlHandler; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + BoardUrlHandler, + { + provide: ColumnBoardService, + useValue: createMock(), + }, + { + provide: CourseService, + useValue: createMock(), + }, + ], + }).compile(); + + columnBoardService = module.get(ColumnBoardService); + boardUrlHandler = module.get(BoardUrlHandler); + await setupEntities(); + }); + + describe('getMetaData', () => { + describe('when url fits', () => { + it('should call courseService with the correct id', async () => { + const id = 'af322312feae'; + const url = `https://localhost/rooms/${id}/board`; + + await boardUrlHandler.getMetaData(url); + + expect(columnBoardService.findById).toHaveBeenCalledWith(id); + }); + + it('should take the title from the board name', async () => { + const id = 'af322312feae'; + const url = `https://localhost/rooms/${id}/board`; + const boardName = 'My Board'; + columnBoardService.findById.mockResolvedValue({ + title: boardName, + context: { type: 'course', id: 'a-board-id' }, + } as ColumnBoard); + + const result = await boardUrlHandler.getMetaData(url); + + expect(result).toEqual(expect.objectContaining({ title: boardName, type: 'board' })); + }); + }); + + describe('when url does not fit', () => { + it('should return undefined', async () => { + const url = `https://localhost/invalid/ef2345abe4e3b`; + + const result = await boardUrlHandler.getMetaData(url); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.ts new file mode 100644 index 00000000000..013631244dd --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.ts @@ -0,0 +1,37 @@ +import { ColumnBoardService } from '@modules/board'; +import { CourseService } from '@modules/learnroom'; +import { Injectable } from '@nestjs/common'; +import { BoardExternalReferenceType } from '@shared/domain'; +import type { UrlHandler } from '../../interface/url-handler'; +import { MetaData } from '../../types'; +import { AbstractUrlHandler } from './abstract-url-handler'; + +@Injectable() +export class BoardUrlHandler extends AbstractUrlHandler implements UrlHandler { + patterns: RegExp[] = [/\/rooms\/(.*?)\/board\/?$/i]; + + constructor(private readonly columnBoardService: ColumnBoardService, private readonly courseService: CourseService) { + super(); + } + + async getMetaData(url: string): Promise { + const id = this.extractId(url); + if (id === undefined) { + return undefined; + } + + const metaData = this.getDefaultMetaData(url, { type: 'board' }); + + const columnBoard = await this.columnBoardService.findById(id); + if (columnBoard) { + metaData.title = columnBoard.title; + if (columnBoard.context.type === BoardExternalReferenceType.Course) { + const course = await this.courseService.findById(columnBoard.context.id); + metaData.parentType = 'course'; + metaData.parentTitle = course.name; + } + } + + return metaData; + } +} diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.spec.ts new file mode 100644 index 00000000000..75a43876de4 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.spec.ts @@ -0,0 +1,62 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { CourseService } from '@modules/learnroom'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Course } from '@shared/domain'; +import { setupEntities } from '@shared/testing'; +import { CourseUrlHandler } from './course-url-handler'; + +describe(CourseUrlHandler.name, () => { + let module: TestingModule; + let courseService: DeepMocked; + let courseUrlHandler: CourseUrlHandler; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + CourseUrlHandler, + { + provide: CourseService, + useValue: createMock(), + }, + ], + }).compile(); + + courseService = module.get(CourseService); + courseUrlHandler = module.get(CourseUrlHandler); + await setupEntities(); + }); + + describe('getMetaData', () => { + describe('when url fits', () => { + it('should call courseService with the correct id', async () => { + const id = 'af322312feae'; + const url = `https://localhost/rooms/${id}`; + + await courseUrlHandler.getMetaData(url); + + expect(courseService.findById).toHaveBeenCalledWith(id); + }); + + it('should take the title from the course name', async () => { + const id = 'af322312feae'; + const url = `https://localhost/rooms/${id}`; + const courseName = 'My Course'; + courseService.findById.mockResolvedValue({ name: courseName } as Course); + + const result = await courseUrlHandler.getMetaData(url); + + expect(result).toEqual(expect.objectContaining({ title: courseName, type: 'course' })); + }); + }); + + describe('when url does not fit', () => { + it('should return undefined', async () => { + const url = `https://localhost/invalid/ef2345abe4e3b`; + + const result = await courseUrlHandler.getMetaData(url); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.ts new file mode 100644 index 00000000000..def041886f1 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.ts @@ -0,0 +1,29 @@ +import { CourseService } from '@modules/learnroom'; +import { Injectable } from '@nestjs/common'; +import type { UrlHandler } from '../../interface/url-handler'; +import { MetaData } from '../../types'; +import { AbstractUrlHandler } from './abstract-url-handler'; + +@Injectable() +export class CourseUrlHandler extends AbstractUrlHandler implements UrlHandler { + patterns: RegExp[] = [/\/rooms\/([0-9a-z]+)$/i]; + + constructor(private readonly courseService: CourseService) { + super(); + } + + async getMetaData(url: string): Promise { + const id = this.extractId(url); + if (id === undefined) { + return undefined; + } + + const metaData = this.getDefaultMetaData(url, { type: 'course' }); + const course = await this.courseService.findById(id); + if (course) { + metaData.title = course.name; + } + + return metaData; + } +} diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/index.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/index.ts new file mode 100644 index 00000000000..a29b8401da2 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/index.ts @@ -0,0 +1,4 @@ +export * from './board-url-handler'; +export * from './course-url-handler'; +export * from './lesson-url-handler'; +export * from './task-url-handler'; diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.spec.ts new file mode 100644 index 00000000000..53b59d86ab7 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.spec.ts @@ -0,0 +1,62 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { LessonService } from '@modules/lesson'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LessonEntity } from '@shared/domain'; +import { setupEntities } from '@shared/testing'; +import { LessonUrlHandler } from './lesson-url-handler'; + +describe(LessonUrlHandler.name, () => { + let module: TestingModule; + let lessonService: DeepMocked; + let lessonUrlHandler: LessonUrlHandler; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + LessonUrlHandler, + { + provide: LessonService, + useValue: createMock(), + }, + ], + }).compile(); + + lessonService = module.get(LessonService); + lessonUrlHandler = module.get(LessonUrlHandler); + await setupEntities(); + }); + + describe('getMetaData', () => { + describe('when url fits', () => { + it('should call lessonService with the correct id', async () => { + const id = 'af322312feae'; + const url = `https://localhost/topics/${id}`; + + await lessonUrlHandler.getMetaData(url); + + expect(lessonService.findById).toHaveBeenCalledWith(id); + }); + + it('should take the title from the lessons name', async () => { + const id = 'af322312feae'; + const url = `https://localhost/topics/${id}`; + const lessonName = 'My lesson'; + lessonService.findById.mockResolvedValue({ name: lessonName } as LessonEntity); + + const result = await lessonUrlHandler.getMetaData(url); + + expect(result).toEqual(expect.objectContaining({ title: lessonName, type: 'lesson' })); + }); + }); + + describe('when url does not fit', () => { + it('should return undefined', async () => { + const url = `https://localhost/invalid/ef2345abe4e3b`; + + const result = await lessonUrlHandler.getMetaData(url); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.ts new file mode 100644 index 00000000000..c5264020a50 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.ts @@ -0,0 +1,29 @@ +import { LessonService } from '@modules/lesson'; +import { Injectable } from '@nestjs/common'; +import type { UrlHandler } from '../../interface/url-handler'; +import { MetaData } from '../../types'; +import { AbstractUrlHandler } from './abstract-url-handler'; + +@Injectable() +export class LessonUrlHandler extends AbstractUrlHandler implements UrlHandler { + patterns: RegExp[] = [/\/topics\/([0-9a-z]+)$/i]; + + constructor(private readonly lessonService: LessonService) { + super(); + } + + async getMetaData(url: string): Promise { + const id = this.extractId(url); + if (id === undefined) { + return undefined; + } + + const metaData = this.getDefaultMetaData(url, { type: 'lesson' }); + const lesson = await this.lessonService.findById(id); + if (lesson) { + metaData.title = lesson.name; + } + + return metaData; + } +} diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.spec.ts new file mode 100644 index 00000000000..0423382f2a8 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.spec.ts @@ -0,0 +1,62 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { TaskService } from '@modules/task'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Task } from '@shared/domain'; +import { setupEntities } from '@shared/testing'; +import { TaskUrlHandler } from './task-url-handler'; + +describe(TaskUrlHandler.name, () => { + let module: TestingModule; + let taskService: DeepMocked; + let taskUrlHandler: TaskUrlHandler; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + TaskUrlHandler, + { + provide: TaskService, + useValue: createMock(), + }, + ], + }).compile(); + + taskService = module.get(TaskService); + taskUrlHandler = module.get(TaskUrlHandler); + await setupEntities(); + }); + + describe('getMetaData', () => { + describe('when url fits', () => { + it('should call taskService with the correct id', async () => { + const id = 'af322312feae'; + const url = `https://localhost/homework/${id}`; + + await taskUrlHandler.getMetaData(url); + + expect(taskService.findById).toHaveBeenCalledWith(id); + }); + + it('should take the title from the tasks name', async () => { + const id = 'af322312feae'; + const url = `https://localhost/homework/${id}`; + const taskName = 'My Task'; + taskService.findById.mockResolvedValue({ name: taskName } as Task); + + const result = await taskUrlHandler.getMetaData(url); + + expect(result).toEqual(expect.objectContaining({ title: taskName, type: 'task' })); + }); + }); + + describe('when url does not fit', () => { + it('should return undefined', async () => { + const url = `https://localhost/invalid/ef2345abe4e3b`; + + const result = await taskUrlHandler.getMetaData(url); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.ts new file mode 100644 index 00000000000..cb1cec86048 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.ts @@ -0,0 +1,29 @@ +import { TaskService } from '@modules/task'; +import { Injectable } from '@nestjs/common'; +import type { UrlHandler } from '../../interface/url-handler'; +import { MetaData } from '../../types'; +import { AbstractUrlHandler } from './abstract-url-handler'; + +@Injectable() +export class TaskUrlHandler extends AbstractUrlHandler implements UrlHandler { + patterns: RegExp[] = [/\/homework\/([0-9a-z]+)$/i]; + + constructor(private readonly taskService: TaskService) { + super(); + } + + async getMetaData(url: string): Promise { + const id = this.extractId(url); + if (id === undefined) { + return undefined; + } + + const metaData = this.getDefaultMetaData(url, { type: 'task' }); + const task = await this.taskService.findById(id); + if (task) { + metaData.title = task.name; + } + + return metaData; + } +} diff --git a/apps/server/src/modules/meta-tag-extractor/types/index.ts b/apps/server/src/modules/meta-tag-extractor/types/index.ts new file mode 100644 index 00000000000..776e417867e --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/types/index.ts @@ -0,0 +1 @@ +export * from './meta-data.type'; diff --git a/apps/server/src/modules/meta-tag-extractor/types/meta-data.type.ts b/apps/server/src/modules/meta-tag-extractor/types/meta-data.type.ts new file mode 100644 index 00000000000..b4da460d6e9 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/types/meta-data.type.ts @@ -0,0 +1,13 @@ +import { ImageObject } from 'open-graph-scraper/dist/lib/types'; + +export type MetaDataEntityType = 'external' | 'course' | 'board' | 'task' | 'lesson' | 'unknown'; + +export type MetaData = { + title: string; + description: string; + url: string; + image?: ImageObject; + type: MetaDataEntityType; + parentTitle?: string; + parentType?: MetaDataEntityType; +}; diff --git a/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.spec.ts b/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.spec.ts index 118b7d82633..f5aa0c6cd72 100644 --- a/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.spec.ts +++ b/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.spec.ts @@ -1,8 +1,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationService } from '@modules/authorization'; import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationService } from '@src/modules/authorization'; import { MetaTagExtractorService } from '../service'; import { MetaTagExtractorUc } from './meta-tag-extractor.uc'; @@ -42,7 +42,7 @@ describe(MetaTagExtractorUc.name, () => { jest.resetAllMocks(); }); - describe('fetchMetaData', () => { + describe('getMetaData', () => { describe('when user exists', () => { const setup = () => { const user = userFactory.build(); @@ -57,7 +57,7 @@ describe(MetaTagExtractorUc.name, () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const url = 'https://www.example.com/great-example'; - await uc.fetchMetaData(user.id, url); + await uc.getMetaData(user.id, url); expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(user.id); }); @@ -66,9 +66,9 @@ describe(MetaTagExtractorUc.name, () => { const { user } = setup(); const url = 'https://www.example.com/great-example'; - await uc.fetchMetaData(user.id, url); + await uc.getMetaData(user.id, url); - expect(metaTagExtractorService.fetchMetaData).toHaveBeenCalledWith(url); + expect(metaTagExtractorService.getMetaData).toHaveBeenCalledWith(url); }); }); @@ -84,7 +84,7 @@ describe(MetaTagExtractorUc.name, () => { const { user } = setup(); const url = 'https://www.example.com/great-example'; - await expect(uc.fetchMetaData(user.id, url)).rejects.toThrow(UnauthorizedException); + await expect(uc.getMetaData(user.id, url)).rejects.toThrow(UnauthorizedException); }); }); }); diff --git a/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.ts b/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.ts index 5daca6c962d..47ac53d88b0 100644 --- a/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.ts +++ b/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.ts @@ -1,7 +1,8 @@ +import { AuthorizationService } from '@modules/authorization'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { EntityId } from '@shared/domain'; -import { AuthorizationService } from '@src/modules/authorization'; -import { MetaData, MetaTagExtractorService } from '../service'; +import { MetaTagExtractorService } from '../service'; +import { MetaData } from '../types'; @Injectable() export class MetaTagExtractorUc { @@ -10,14 +11,14 @@ export class MetaTagExtractorUc { private readonly metaTagExtractorService: MetaTagExtractorService ) {} - async fetchMetaData(userId: EntityId, url: string): Promise { + async getMetaData(userId: EntityId, url: string): Promise { try { await this.authorizationService.getUserWithPermissions(userId); } catch (error) { throw new UnauthorizedException(); } - const result = await this.metaTagExtractorService.fetchMetaData(url); + const result = await this.metaTagExtractorService.getMetaData(url); return result; } }