diff --git a/apps/server/src/modules/board/board-api.module.ts b/apps/server/src/modules/board/board-api.module.ts index 98d6de3acfc..f53defc4c28 100644 --- a/apps/server/src/modules/board/board-api.module.ts +++ b/apps/server/src/modules/board/board-api.module.ts @@ -2,6 +2,7 @@ import { AuthorizationModule } from '@modules/authorization'; import { forwardRef, Module } from '@nestjs/common'; import { CourseRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; +import { BoardModule } from './board.module'; import { BoardController, BoardSubmissionController, @@ -9,7 +10,6 @@ import { ColumnController, ElementController, } from './controller'; -import { BoardModule } from './board.module'; import { BoardNodePermissionService } from './service'; import { BoardUc, CardUc, ColumnUc, ElementUc, SubmissionItemUc } from './uc'; import { RoomMemberModule } from '../room-member'; diff --git a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts index d16fb5f05c3..66b7cb56a8d 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts @@ -90,7 +90,7 @@ describe(`content element update content (api)`, () => { }; }; - it('should return status 201', async () => { + it('should return status 200', async () => { const { loggedInClient, richTextElement } = await setup(); const response = await loggedInClient.patch(`${richTextElement.id}/content`, { @@ -100,7 +100,7 @@ describe(`content element update content (api)`, () => { }, }); - expect(response.statusCode).toEqual(201); + expect(response.statusCode).toEqual(200); }); it('should actually change content of the element', async () => { @@ -159,7 +159,7 @@ describe(`content element update content (api)`, () => { expect(result.alternativeText).toEqual('rich text 1 some more text'); }); - it('should return status 201', async () => { + it('should return status 200', async () => { const { loggedInClient, submissionContainerElement } = await setup(); const response = await loggedInClient.patch(`${submissionContainerElement.id}/content`, { data: { @@ -168,7 +168,7 @@ describe(`content element update content (api)`, () => { }, }); - expect(response.statusCode).toEqual(201); + expect(response.statusCode).toEqual(200); }); it('should not change dueDate when not proviced in submission container element without dueDate', async () => { diff --git a/apps/server/src/modules/board/controller/dto/element/link-element.response.ts b/apps/server/src/modules/board/controller/dto/element/link-element.response.ts index 2c647477f22..c2793d63ad0 100644 --- a/apps/server/src/modules/board/controller/dto/element/link-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/link-element.response.ts @@ -3,10 +3,11 @@ import { ContentElementType } from '../../../domain'; import { TimestampsResponse } from '../timestamps.response'; export class LinkElementContent { - constructor({ url, title, description, imageUrl }: LinkElementContent) { + constructor({ url, title, description, originalImageUrl, imageUrl }: LinkElementContent) { this.url = url; this.title = title; this.description = description; + this.originalImageUrl = originalImageUrl; this.imageUrl = imageUrl; } @@ -19,6 +20,9 @@ export class LinkElementContent { @ApiPropertyOptional() description?: string; + @ApiPropertyOptional() + originalImageUrl?: string; + @ApiPropertyOptional() imageUrl?: string; } 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 bcf677cbc46..efebfda510c 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 @@ -52,6 +52,11 @@ export class LinkContentBody { @IsOptional() @ApiProperty({}) imageUrl?: string; + + @IsString() + @IsOptional() + @ApiProperty({}) + originalImageUrl?: string; } export class LinkElementContentBody extends ElementContentBody { diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index fc572d39e46..a03bcea0126 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -74,7 +74,7 @@ export class ElementController { DrawingElementContentBody ) @ApiResponse({ - status: 201, + status: 200, schema: { oneOf: [ { $ref: getSchemaPath(ExternalToolElementResponse) }, @@ -89,7 +89,7 @@ export class ElementController { @ApiResponse({ status: 400, type: ApiValidationError }) @ApiResponse({ status: 403, type: ForbiddenException }) @ApiResponse({ status: 404, type: NotFoundException }) - @HttpCode(201) + @HttpCode(200) @Patch(':contentElementId/content') async updateElement( @Param() urlParams: ContentElementUrlParams, diff --git a/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts index dd1a28aa874..3c3cb0cce0b 100644 --- a/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts @@ -22,6 +22,7 @@ export class LinkElementResponseMapper implements BaseResponseMapper { url: element.url, title: element.title, description: element.description, + originalImageUrl: element.originalImageUrl, imageUrl: element.imageUrl, }), }); diff --git a/apps/server/src/modules/board/domain/link-element.do.spec.ts b/apps/server/src/modules/board/domain/link-element.do.spec.ts index c900cdba889..dc60e977adb 100644 --- a/apps/server/src/modules/board/domain/link-element.do.spec.ts +++ b/apps/server/src/modules/board/domain/link-element.do.spec.ts @@ -21,6 +21,7 @@ describe('LinkElement', () => { title: 'Example', description: 'Example description', imageUrl: 'https://example.com/image.jpg', + originalImageUrl: 'https://example.com/image.jpg', }); }); @@ -68,6 +69,15 @@ describe('LinkElement', () => { expect(linkElement.imageUrl).toBe('https://newurl.com/newimage.jpg'); }); + it('should return originalImageUrl', () => { + expect(linkElement.originalImageUrl).toBe('https://example.com/image.jpg'); + }); + + it('should set originalImageUrl', () => { + linkElement.originalImageUrl = 'https://newurl.com/newimage.jpg'; + expect(linkElement.originalImageUrl).toBe('https://newurl.com/newimage.jpg'); + }); + it('should not have child', () => { expect(linkElement.canHaveChild()).toBe(false); }); diff --git a/apps/server/src/modules/board/domain/link-element.do.ts b/apps/server/src/modules/board/domain/link-element.do.ts index dfaf1746cd2..05c2a110ead 100644 --- a/apps/server/src/modules/board/domain/link-element.do.ts +++ b/apps/server/src/modules/board/domain/link-element.do.ts @@ -34,6 +34,14 @@ export class LinkElement extends BoardNode { this.props.imageUrl = value; } + get originalImageUrl(): string { + return this.props.originalImageUrl ?? ''; + } + + set originalImageUrl(value: string) { + this.props.originalImageUrl = value; + } + canHaveChild(): boolean { return false; } diff --git a/apps/server/src/modules/board/domain/types/board-node-props.ts b/apps/server/src/modules/board/domain/types/board-node-props.ts index f862def454c..492584f77de 100644 --- a/apps/server/src/modules/board/domain/types/board-node-props.ts +++ b/apps/server/src/modules/board/domain/types/board-node-props.ts @@ -49,6 +49,7 @@ export interface LinkElementProps extends BoardNodeProps { title: string; url: string; description?: string; + originalImageUrl?: string; imageUrl?: string; } diff --git a/apps/server/src/modules/board/repo/entity/board-node.entity.ts b/apps/server/src/modules/board/repo/entity/board-node.entity.ts index 3385f29b8a8..22755ede655 100644 --- a/apps/server/src/modules/board/repo/entity/board-node.entity.ts +++ b/apps/server/src/modules/board/repo/entity/board-node.entity.ts @@ -80,6 +80,9 @@ export class BoardNodeEntity extends BaseEntityWithTimestamps implements BoardNo @Property({ type: 'string', nullable: true }) imageUrl: string | undefined; + @Property({ type: 'string', nullable: true }) + originalImageUrl: string | undefined; + // FileElement // -------------------------------------------------------------------------- @Property({ type: 'string', nullable: true }) diff --git a/apps/server/src/modules/board/service/internal/content-element-update.service.spec.ts b/apps/server/src/modules/board/service/internal/content-element-update.service.spec.ts index 7709cea2a1d..db30e2e7322 100644 --- a/apps/server/src/modules/board/service/internal/content-element-update.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/content-element-update.service.spec.ts @@ -1,17 +1,15 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { InputFormat } from '@shared/domain/types'; -import { ContentElementUpdateService } from './content-element-update.service'; import { + DrawingContentBody, + ExternalToolContentBody, FileContentBody, LinkContentBody, RichTextContentBody, - DrawingContentBody, SubmissionContainerContentBody, - ExternalToolContentBody, } from '../../controller/dto'; import { BoardNodeRepo } from '../../repo'; - import { drawingElementFactory, externalToolElementFactory, @@ -20,6 +18,7 @@ import { richTextElementFactory, submissionContainerElementFactory, } from '../../testing'; +import { ContentElementUpdateService } from './content-element-update.service'; describe('ContentElementUpdateService', () => { let module: TestingModule; diff --git a/apps/server/src/modules/board/uc/element.uc.spec.ts b/apps/server/src/modules/board/uc/element.uc.spec.ts index 471be5363a4..2cf247b80ca 100644 --- a/apps/server/src/modules/board/uc/element.uc.spec.ts +++ b/apps/server/src/modules/board/uc/element.uc.spec.ts @@ -1,22 +1,21 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Action } from '@modules/authorization'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { InputFormat } from '@shared/domain/types'; import { setupEntities, userFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { Action } from '@modules/authorization'; -import { ElementUc } from './element.uc'; -import { BoardNodePermissionService, BoardNodeAuthorizableService, BoardNodeService } from '../service'; +import { RichTextContentBody } from '../controller/dto'; import { BoardNodeFactory } from '../domain'; - +import { BoardNodeAuthorizableService, BoardNodePermissionService, BoardNodeService } from '../service'; import { boardNodeAuthorizableFactory, - richTextElementFactory, drawingElementFactory, + richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, } from '../testing'; -import { RichTextContentBody } from '../controller/dto'; +import { ElementUc } from './element.uc'; describe(ElementUc.name, () => { let module: TestingModule; 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 16863f0e16a..05cabd99f39 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 @@ -4,11 +4,21 @@ import { IsString, IsUrl } from 'class-validator'; import { MetaDataEntityType } from '../../types'; export class MetaTagExtractorResponse { - constructor({ url, title, description, imageUrl, type, parentTitle, parentType }: MetaTagExtractorResponse) { + constructor({ + url, + title, + description, + originalImageUrl, + imageUrl, + type, + parentTitle, + parentType, + }: MetaTagExtractorResponse) { this.url = url; this.title = title; this.description = description; this.imageUrl = imageUrl; + this.originalImageUrl = originalImageUrl; this.type = type; this.parentTitle = parentTitle; this.parentType = parentType; @@ -26,6 +36,10 @@ export class MetaTagExtractorResponse { @DecodeHtmlEntities() description?: string; + @ApiProperty() + @IsString() + originalImageUrl?: string; + @ApiProperty() @IsString() imageUrl?: string; 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 379258c6592..d84511885a0 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,6 +1,6 @@ -import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; import { Body, Controller, InternalServerErrorException, Post, UnauthorizedException } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@src/infra/auth-guard'; import { MetaTagExtractorUc } from '../uc'; import { MetaTagExtractorResponse } from './dto'; import { GetMetaTagDataBody } from './post-link-url.body.params'; @@ -21,8 +21,7 @@ export class MetaTagExtractorController { @Body() bodyParams: GetMetaTagDataBody ): Promise { const result = await this.metaTagExtractorUc.getMetaData(currentUser.userId, bodyParams.url); - const imageUrl = result.image?.url; - const response = new MetaTagExtractorResponse({ ...result, imageUrl }); + const response = new MetaTagExtractorResponse({ ...result }); return response; } } 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 index fc09d2cd40e..5d9b2c514d3 100644 --- a/apps/server/src/modules/meta-tag-extractor/interface/url-handler.ts +++ b/apps/server/src/modules/meta-tag-extractor/interface/url-handler.ts @@ -1,6 +1,6 @@ import { MetaData } from '../types'; export interface UrlHandler { - doesUrlMatch(url: string): boolean; - getMetaData(url: string): Promise; + doesUrlMatch(url: URL): boolean; + getMetaData(url: URL): Promise; } diff --git a/apps/server/src/modules/meta-tag-extractor/loggable/invalid-link-url.loggable.spec.ts b/apps/server/src/modules/meta-tag-extractor/loggable/invalid-link-url.loggable.spec.ts new file mode 100644 index 00000000000..651c26fbb53 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/loggable/invalid-link-url.loggable.spec.ts @@ -0,0 +1,23 @@ +import { InvalidLinkUrlLoggableException } from './invalid-link-url.loggable'; + +describe('InvalidLinkUrlLoggableException', () => { + it('should implement Loggable interface', () => { + const exception = new InvalidLinkUrlLoggableException('http://invalid.url', 'Invalid URL'); + expect(typeof exception.getLogMessage).toBe('function'); + }); + + it('should return correct log message', () => { + const url = 'http://invalid.url'; + const message = 'Invalid URL'; + const exception = new InvalidLinkUrlLoggableException(url, message); + + expect(exception.getLogMessage()).toEqual({ + type: 'INVALID_LINK_URL', + message, + stack: exception.stack, + data: { + url, + }, + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/loggable/invalid-link-url.loggable.ts b/apps/server/src/modules/meta-tag-extractor/loggable/invalid-link-url.loggable.ts new file mode 100644 index 00000000000..9704009a3ec --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/loggable/invalid-link-url.loggable.ts @@ -0,0 +1,19 @@ +import { BadRequestException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class InvalidLinkUrlLoggableException extends BadRequestException implements Loggable { + constructor(private readonly url: string, readonly message: string) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'INVALID_LINK_URL', + message: this.message, + stack: this.stack, + data: { + url: this.url, + }, + }; + } +} 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 2627e100245..c0c3bdb0699 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 @@ -11,6 +11,7 @@ import { createConfigModuleOptions } from '@src/config'; import { LoggerModule } from '@src/core/logger'; import metaTagExtractorConfig from './meta-tag-extractor.config'; import { MetaTagExtractorService } from './service'; +import { MetaTagExternalUrlService } from './service/meta-tag-external-url.service'; import { MetaTagInternalUrlService } from './service/meta-tag-internal-url.service'; import { BoardUrlHandler, CourseUrlHandler, LessonUrlHandler, TaskUrlHandler } from './service/url-handler'; @@ -28,6 +29,7 @@ import { BoardUrlHandler, CourseUrlHandler, LessonUrlHandler, TaskUrlHandler } f ], providers: [ MetaTagExtractorService, + MetaTagExternalUrlService, MetaTagInternalUrlService, TaskUrlHandler, LessonUrlHandler, diff --git a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-external-url.service.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-external-url.service.spec.ts new file mode 100644 index 00000000000..241ac82de92 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-external-url.service.spec.ts @@ -0,0 +1,228 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities } from '@shared/testing'; +import axios, { CancelTokenSource } from 'axios'; +import Stream from 'stream'; +import { MetaTagExternalUrlService } from './meta-tag-external-url.service'; + +jest.mock('axios'); + +type OgImageMockData = { name: string; width: number }; +type HtmlMockData = { title?: string; description?: string; ogImages?: OgImageMockData[] }; + +describe(MetaTagExternalUrlService.name, () => { + let module: TestingModule; + let service: MetaTagExternalUrlService; + let mockedAxios: jest.Mocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [MetaTagExternalUrlService], + }).compile(); + + service = module.get(MetaTagExternalUrlService); + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetModules(); + mockedAxios = axios as jest.Mocked; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('tryExtractMetaTags', () => { + const mockReadstream = (chunks: string[]) => { + const mockedStream = new Stream.Readable(); + mockedStream._read = jest.fn(); + + const intervalHandle = setInterval(() => { + if (chunks.length === 0) { + clearInterval(intervalHandle); + mockedStream.push(null); + } else { + mockedStream.push(chunks.shift()); + } + }, 100); + + return mockedStream; + }; + + const mockOgImages = (ogImages: OgImageMockData[]) => + ogImages + .map( + ({ name, width }) => + ` + + ` + ) + .join(''); + + const mockHtmlStart = ({ + title = 'Great Html-Page', + description = 'the description', + ogImages = [], + }: HtmlMockData = {}) => ` + + + + ${title} + + ${mockOgImages(ogImages)} + + `; + + const mockParagraph = (string = 'a', characterCount = 50000) => + `

${string.repeat(characterCount).slice(0, characterCount - 7)}

`; + + const createCancelTokenSource = () => { + const cancelTokenSource: CancelTokenSource = { + cancel: jest.fn(), + token: { + promise: new Promise(() => {}), + throwIfRequested: jest.fn().mockImplementation(() => { + mockedAxios.isCancel.mockReturnValue(true); + throw new Error('user canceled'); + }), + reason: { message: 'user canceled' }, + }, + }; + return cancelTokenSource; + }; + + describe('when html of page is short', () => { + it('should extract title and description', async () => { + const url = new URL('https://de.wikipedia.org/example-article'); + + const cancelTokenSource = createCancelTokenSource(); + jest.spyOn(axios.CancelToken, 'source').mockReturnValueOnce(cancelTokenSource); + + const title = 'Great Title'; + const description = 'Great Description'; + const mockedStream = mockReadstream([mockHtmlStart({ title, description })]); + mockedAxios.get.mockResolvedValue({ data: mockedStream }); + + const result = await service.tryExtractMetaTags(url); + + expect(result?.title).toEqual(title); + expect(result?.description).toEqual(description); + }, 60000); + + it('should not need to cancel', async () => { + const url = new URL('https://de.wikipedia.org/example-article'); + + const cancelTokenSource = createCancelTokenSource(); + jest.spyOn(axios.CancelToken, 'source').mockReturnValue(cancelTokenSource); + + const mockedStream = mockReadstream(['test']); + mockedAxios.get.mockResolvedValue({ data: mockedStream }); + + await service.tryExtractMetaTags(url); + + expect(cancelTokenSource.cancel).not.toHaveBeenCalled(); + }, 60000); + }); + + describe('when html of page is huge', () => { + it('should only take the first 50000 characters', async () => { + const url = new URL('https://de.wikipedia.org/example-article'); + + const cancelTokenSource = createCancelTokenSource(); + jest.spyOn(axios.CancelToken, 'source').mockReturnValue(cancelTokenSource); + + const title = 'Great Title'; + const description = 'Great Description'; + const mockedStream = mockReadstream([ + mockHtmlStart({ title, description }), + mockParagraph('inside,', 50000), + mockParagraph('outside,', 50000), + ]); + mockedAxios.get.mockResolvedValue({ data: mockedStream }); + + const result = await service.tryExtractMetaTags(url); + + expect(result?.title).toEqual(title); + expect(result?.description).toEqual(description); + expect(cancelTokenSource.cancel).toHaveBeenCalled(); + }); + }); + + describe('when html of page contains images', () => { + it('should return an image', async () => { + const url = new URL('https://de.wikipedia.org/example-article'); + + const cancelTokenSource = createCancelTokenSource(); + jest.spyOn(axios.CancelToken, 'source').mockReturnValue(cancelTokenSource); + + const ogImages = [{ name: 'rock.jpg', width: 2000 }]; + + const mockedStream = mockReadstream([mockHtmlStart({ ogImages })]); + mockedAxios.get.mockResolvedValue({ data: mockedStream }); + + const result = await service.tryExtractMetaTags(url); + + expect(result?.originalImageUrl).toBeDefined(); + }); + + describe('when multiple images are present', () => { + it('should return the image with the lowest but high enough resolution', async () => { + const url = new URL('https://de.wikipedia.org/example-article'); + + const cancelTokenSource = createCancelTokenSource(); + jest.spyOn(axios.CancelToken, 'source').mockReturnValue(cancelTokenSource); + + const ogImages = [ + { name: 'paper.jpg', width: 400 }, + { name: 'rock.jpg', width: 2000 }, + { name: 'scissors.jpg', width: 300 }, + ]; + + const mockedStream = mockReadstream([mockHtmlStart({ ogImages })]); + mockedAxios.get.mockResolvedValue({ data: mockedStream }); + + const result = await service.tryExtractMetaTags(url); + + expect(result?.originalImageUrl).toEqual('https://example.com/paper.jpg'); + }); + }); + }); + + describe('when html not contains images', () => { + it('should return no image', async () => { + const url = new URL('https://de.wikipedia.org/example-article'); + + const cancelTokenSource = createCancelTokenSource(); + jest.spyOn(axios.CancelToken, 'source').mockReturnValue(cancelTokenSource); + + const ogImages = []; + const mockedStream = mockReadstream([mockHtmlStart({ ogImages })]); + mockedAxios.get.mockResolvedValue({ data: mockedStream }); + + const result = await service.tryExtractMetaTags(url); + + expect(result?.originalImageUrl).toBeUndefined(); + }); + }); + + describe('when html creates an error', () => { + it('should return undefined', async () => { + const url = new URL('https://de.wikipedia.org/example-article'); + + const cancelTokenSource = createCancelTokenSource(); + jest.spyOn(axios.CancelToken, 'source').mockReturnValue(cancelTokenSource); + + const mockedStream = mockReadstream([]); + mockedAxios.get.mockResolvedValue({ data: mockedStream }); + + const result = await service.tryExtractMetaTags(url); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-external-url.service.ts b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-external-url.service.ts new file mode 100644 index 00000000000..b12bd969b1e --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-external-url.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import ogs from 'open-graph-scraper'; +import { ImageObject } from 'open-graph-scraper/dist/lib/types'; +import { MetaData } from '../types'; +import { InvalidLinkUrlLoggableException } from '../loggable/invalid-link-url.loggable'; + +@Injectable() +export class MetaTagExternalUrlService { + async tryExtractMetaTags(url: URL): Promise { + const html = await this.fetchHtmlPartly(url); + const result = await this.parseHtml(html); + if (!result) { + return undefined; + } + + const { ogTitle, ogDescription, ogImage } = result; + + return { + title: ogTitle ?? '', + description: ogDescription ?? '', + originalImageUrl: this.getImageUrl(ogImage, url), + url: url.toString(), + type: 'external', + }; + } + + private async parseHtml(html: string) { + try { + const { result } = await ogs({ html }); + return { ogImage: [], ...result }; + } catch (error) { + // unable to parse html + return undefined; + } + } + + private async fetchHtmlPartly(url: URL, maxLength = 50000): Promise { + const source = axios.CancelToken.source(); + let html = ''; + + try { + const response = await axios.get(url.toString(), { + headers: { 'User-Agent': 'Open Graph Scraper' }, + responseType: 'stream', + cancelToken: source.token, + }); + + const stream = response.data as NodeJS.ReadableStream; + stream.on('data', (chunk: Buffer) => { + html += chunk.toString('utf-8'); + if (html.length >= maxLength) { + source.cancel(`Request canceled after receiving ${maxLength} characters.`); + } + }); + + await new Promise((resolve, reject) => { + stream.on('end', resolve); + stream.on('error', reject); + }); + } catch (error) { + // mocking the internal axios cancelation mechanism (including throwing this cancelation error) was not possible... so: + // istanbul ignore next + if (!axios.isCancel(error)) { + throw new InvalidLinkUrlLoggableException(url.toString(), 'Unable to fetch html for meta tag extraction'); + } + } + return html.slice(0, maxLength); + } + + private getImageUrl(images: ImageObject[], url: URL): string | undefined { + const image = this.pickImage(images); + if (!image) { + return undefined; + } + + const baseUrl = url; + baseUrl.pathname = ''; + + const imageUrl = new URL(image.url, baseUrl.toString()); + return imageUrl.toString(); + } + + 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)); + const smallestBigEnoughImage = sortedImages.find((i) => i.width && i.width >= minWidth); + const fallbackImage = images[0] && images[0].width === undefined ? images[0] : undefined; + return smallestBigEnoughImage ?? fallbackImage; + } +} 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 06fa3b09170..b71567e85ed 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 @@ -2,36 +2,14 @@ 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 { MetaTagExternalUrlService } from './meta-tag-external-url.service'; import { MetaTagExtractorService } from './meta-tag-extractor.service'; import { MetaTagInternalUrlService } from './meta-tag-internal-url.service'; -jest.mock('open-graph-scraper', () => { - return { - __esModule: true, - default: jest.fn(), - }; -}); - -const mockOgsResolve = (result: Record) => { - const mockedOgs = ogs as jest.Mock; - mockedOgs.mockResolvedValueOnce({ - error: false, - html: '', - response: {}, - result, - }); -}; - -const mockOgsReject = (error: Error) => { - const mockedOgs = ogs as jest.Mock; - mockedOgs.mockRejectedValueOnce(error); -}; - describe(MetaTagExtractorService.name, () => { let module: TestingModule; let metaTagInternalUrlService: DeepMocked; + let metaTagExternalUrlService: DeepMocked; let service: MetaTagExtractorService; beforeAll(async () => { @@ -42,10 +20,15 @@ describe(MetaTagExtractorService.name, () => { provide: MetaTagInternalUrlService, useValue: createMock(), }, + { + provide: MetaTagExternalUrlService, + useValue: createMock(), + }, ], }).compile(); metaTagInternalUrlService = module.get(MetaTagInternalUrlService); + metaTagExternalUrlService = module.get(MetaTagExternalUrlService); service = module.get(MetaTagExtractorService); await setupEntities(); }); @@ -57,86 +40,59 @@ describe(MetaTagExtractorService.name, () => { beforeEach(() => { Configuration.set('SC_DOMAIN', 'localhost'); metaTagInternalUrlService.tryInternalLinkMetaTags.mockResolvedValue(undefined); + metaTagExternalUrlService.tryExtractMetaTags.mockResolvedValue(undefined); }); afterEach(() => { jest.resetAllMocks(); }); - describe('create', () => { - describe('when url points to webpage', () => { - it('should thrown an error if url is an empty string', async () => { + describe('getMetaData', () => { + describe('when url is an empty string', () => { + it('should throw an error', 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.getMetaData(url); - - expect(result).toEqual(expect.objectContaining({ url })); - }); - - it('should return ogTitle as title', async () => { - const ogTitle = 'My Title'; - const url = 'https://de.wikipedia.org'; - mockOgsResolve({ ogTitle }); - - const result = await service.getMetaData(url); - - expect(result).toEqual(expect.objectContaining({ title: ogTitle })); + await expect(async () => service.getMetaData(url)).rejects.toThrow(); }); + }); - it('should return ogImage as image', async () => { - const ogImage: ImageObject[] = [ - { - width: 800, - type: 'jpeg', - url: 'big-image.jpg', - }, - { - width: 500, - type: 'jpeg', - url: 'medium-image.jpg', - }, - { - width: 300, - type: 'jpeg', - url: 'small-image.jpg', - }, - ]; - const url = 'https://de.wikipedia.org'; - mockOgsResolve({ url, ogImage }); + describe('when protocol is not https', () => { + it('should fix it', async () => { + const url = 'http://www.test.de'; - const result = await service.getMetaData(url); + const metaData = await service.getMetaData(url); - expect(result).toEqual(expect.objectContaining({ image: ogImage[1] })); + expect(metaData).toEqual(expect.objectContaining({ url: 'https://www.test.de/' })); }); }); - describe('when url points to a file', () => { - it('should return filename as title', async () => { - const url = 'https://de.wikipedia.org/abc.jpg'; + describe('when it is not an internal link', () => { + describe('when no meta tags were found', () => { + it('should return a MetaData object with type unknown', async () => { + const url = 'https://www.test.de/super.pdf'; - mockOgsReject(new Error('no open graph data included... probably not a webpage')); + const result = await service.getMetaData(url); - const result = await service.getMetaData(url); - expect(result).toEqual(expect.objectContaining({ title: 'abc.jpg' })); + expect(result).toEqual({ + url, + title: 'super.pdf', + description: '', + type: 'unknown', + }); + }); }); }); - describe('when url is invalid', () => { - it('should return url as it is', async () => { - const url = 'not-a-real-domain'; - - 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: '' })); + describe('when url hostname contains IP-Adress', () => { + it.each([ + { url: 'https://127.0.0.1' }, + { url: 'https://127.0.0.1:8000' }, + { url: 'https://127.0.0.1:3000/dashboard' }, + { url: 'https://FE80:CD00:0000:0CDE:1257:0000:211E:729C' }, + { url: 'https://FE80:CD00:0000:0CDE:1257:0000:211E:729C:8000' }, + { url: 'https://FE80:CD00:0000:0CDE:1257:0000:211E:729C:8000/course' }, + ])('should return undefined for $url ', async ({ url }) => { + await expect(async () => service.getMetaData(url)).rejects.toThrow(); }); }); }); 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 d64c3a42a58..312ffe1befd 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 @@ -1,76 +1,55 @@ import { Injectable } from '@nestjs/common'; -import ogs from 'open-graph-scraper'; -import { ImageObject } from 'open-graph-scraper/dist/lib/types'; +import net from 'net'; import { basename } from 'path'; +import { InvalidLinkUrlLoggableException } from '../loggable/invalid-link-url.loggable'; import type { MetaData } from '../types'; +import { MetaTagExternalUrlService } from './meta-tag-external-url.service'; import { MetaTagInternalUrlService } from './meta-tag-internal-url.service'; @Injectable() export class MetaTagExtractorService { - constructor(private readonly internalLinkMataTagService: MetaTagInternalUrlService) {} + constructor( + private readonly internalLinkMataTagService: MetaTagInternalUrlService, + private readonly externalLinkMetaTagService: MetaTagExternalUrlService + ) {} - async getMetaData(url: string): Promise { - if (url.length === 0) { - throw new Error(`MetaTagExtractorService requires a valid URL. Given URL: ${url}`); - } + async getMetaData(urlString: string): Promise { + const url = this.parseValidUrl(urlString); const metaData = (await this.tryInternalLinkMetaTags(url)) ?? - (await this.tryExtractMetaTags(url)) ?? - this.tryFilenameAsFallback(url) ?? - this.getDefaultMetaData(url); + (await this.tryExtractMetaTagsFromExternalUrl(url)) ?? + this.useFilenameAsFallback(url); return metaData; } - private async tryInternalLinkMetaTags(url: string): Promise { - return this.internalLinkMataTagService.tryInternalLinkMetaTags(url); - } - - private async tryExtractMetaTags(url: string): Promise { - try { - const data = await ogs({ url, fetchOptions: { headers: { 'User-Agent': 'Open Graph Scraper' } } }); + parseValidUrl(url: string): URL { + const urlObject = new URL(url); - const title = data.result?.ogTitle ?? ''; - const description = data.result?.ogDescription ?? ''; - const image = data.result?.ogImage ? this.pickImage(data?.result?.ogImage) : undefined; + // enforce https + urlObject.protocol = 'https:'; - return { - title, - description, - image, - url, - type: 'external', - }; - } catch (error) { - return undefined; + if (net.isIPv4(urlObject.hostname) || net.isIPv6(urlObject.hostname)) { + throw new InvalidLinkUrlLoggableException(url, 'IP adress is not allowed as hostname'); } + return urlObject; } - private tryFilenameAsFallback(url: string): MetaData | undefined { - try { - const urlObject = new URL(url); - const title = basename(urlObject.pathname); - return { - title, - description: '', - url, - type: 'unknown', - }; - } catch (error) { - return undefined; - } + private async tryInternalLinkMetaTags(url: URL): Promise { + return this.internalLinkMataTagService.tryInternalLinkMetaTags(url); } - private getDefaultMetaData(url: string): MetaData { - return { url, title: '', description: '', type: 'unknown' }; + private async tryExtractMetaTagsFromExternalUrl(url: URL): Promise { + return this.externalLinkMetaTagService.tryExtractMetaTags(url); } - 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)); - const smallestBigEnoughImage = sortedImages.find((i) => i.width && i.width >= minWidth); - const fallbackImage = images[0] && images[0].width === undefined ? images[0] : undefined; - return smallestBigEnoughImage ?? fallbackImage; + private useFilenameAsFallback(url: URL): MetaData { + return { + title: basename(url.pathname), + description: '', + url: url.toString(), + type: 'unknown', + }; } } 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 index 04d2f8b0c77..5c290d58868 100644 --- 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 @@ -3,16 +3,15 @@ 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'; +const INTERNAL_URL = new URL(`https://${INTERNAL_DOMAIN}/my-article`); +const UNKNOWN_INTERNAL_URL = new URL(`https://${INTERNAL_DOMAIN}/playground/23hafe23234`); +const EXTERNAL_URL = new URL('https://de.wikipedia.org/example-article'); -describe(MetaTagExtractorService.name, () => { +describe(MetaTagInternalUrlService.name, () => { let module: TestingModule; let taskUrlHandler: DeepMocked; let lessonUrlHandler: DeepMocked; @@ -81,6 +80,15 @@ describe(MetaTagExtractorService.name, () => { expect(service.isInternalUrl(EXTERNAL_URL)).toBe(false); }); + + it('should return false for external urls that partially contain the domain', () => { + setup(); + + const phishingUrl = new URL(INTERNAL_URL); + phishingUrl.hostname += '.phishing.de'; + + expect(service.isInternalUrl(phishingUrl)).toBe(false); + }); }); describe('tryInternalLinkMetaTags', () => { @@ -91,7 +99,7 @@ describe(MetaTagExtractorService.name, () => { boardUrlHandler.doesUrlMatch.mockReturnValueOnce(false); const mockedMetaTags: MetaData = { title: 'My Title', - url: INTERNAL_URL, + url: INTERNAL_URL.toString(), description: '', type: 'course', }; 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 index 5c0d5efca5c..a3050e4e3b8 100644 --- 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 @@ -17,34 +17,31 @@ export class MetaTagInternalUrlService { this.handlers = [this.taskUrlHandler, this.lessonUrlHandler, this.courseUrlHandler, this.boardUrlHandler]; } - async tryInternalLinkMetaTags(url: string): Promise { + async tryInternalLinkMetaTags(url: URL): Promise { if (this.isInternalUrl(url)) { return this.composeMetaTags(url); } return Promise.resolve(undefined); } - isInternalUrl(url: string) { + isInternalUrl(url: URL) { let domain = Configuration.get('SC_DOMAIN') as string; domain = domain === '' ? 'nothing-configured-for-internal-url.de' : domain; - const isInternal = url.toLowerCase().includes(domain.toLowerCase()); + const isInternal = url.hostname.toLowerCase() === domain.toLowerCase(); return isInternal; } - private async composeMetaTags(url: string): Promise { - const urlObject = new URL(url); - + private async composeMetaTags(url: URL): Promise { 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, + title: url.pathname, description: '', - url, + url: url.toString(), 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 index b6900cfd492..b7cc04521e3 100644 --- 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 @@ -3,7 +3,7 @@ import { AbstractUrlHandler } from './abstract-url-handler'; class DummyHandler extends AbstractUrlHandler { patterns: RegExp[] = [/\/dummy\/([0-9a-z]+)$/i]; - extractId(url: string): string | undefined { + extractId(url: URL): string | undefined { return super.extractId(url); } } @@ -11,8 +11,8 @@ class DummyHandler extends AbstractUrlHandler { describe(AbstractUrlHandler.name, () => { const setup = () => { const id = 'af322312feae'; - const url = `https://localhost/dummy/${id}`; - const invalidUrl = `https://localhost/wrong/${id}`; + const url = new URL(`https://localhost/dummy/${id}`); + const invalidUrl = new URL(`https://localhost/wrong/${id}`); const handler = new DummyHandler(); return { id, url, invalidUrl, handler }; }; @@ -53,7 +53,7 @@ describe(AbstractUrlHandler.name, () => { const result = handler.getDefaultMetaData(url); - expect(result).toEqual(expect.objectContaining({ type: 'unknown', url })); + expect(result).toEqual(expect.objectContaining({ type: 'unknown', url: url.toString() })); }); }); }); 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 index fb618c3bf36..881c90b84a1 100644 --- 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 @@ -4,9 +4,9 @@ import { MetaData } from '../../types'; export abstract class AbstractUrlHandler { protected abstract patterns: RegExp[]; - protected extractId(url: string): string | undefined { + protected extractId(url: URL): string | undefined { const results: RegExpMatchArray = this.patterns - .map((pattern: RegExp) => pattern.exec(url)) + .map((pattern: RegExp) => pattern.exec(url.toString())) .filter((result) => result !== null) .find((result) => (result?.length ?? 0) >= 2) as RegExpMatchArray; @@ -16,18 +16,18 @@ export abstract class AbstractUrlHandler { return undefined; } - doesUrlMatch(url: string): boolean { - const doesMatch = this.patterns.some((pattern) => pattern.test(url)); + doesUrlMatch(url: URL): boolean { + const doesMatch = this.patterns.some((pattern) => pattern.test(url.toString())); return doesMatch; } - getDefaultMetaData(url: string, partial: Partial = {}): MetaData { + getDefaultMetaData(url: URL, partial: Partial = {}): MetaData { const urlObject = new URL(url); const title = basename(urlObject.pathname); return { title, description: '', - url, + url: url.toString(), 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 index 59e39c47c95..5160721477e 100644 --- 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 @@ -34,8 +34,8 @@ describe(BoardUrlHandler.name, () => { describe('getMetaData', () => { describe('when url fits', () => { it('should call courseService with the correct id', async () => { - const id = 'af322312feae'; - const url = `https://localhost/boards/${id}`; + const id = '671a5bdf0995ace8cbc6f899'; + const url = new URL(`https://localhost/boards/${id}`); await boardUrlHandler.getMetaData(url); @@ -43,8 +43,8 @@ describe(BoardUrlHandler.name, () => { }); it('should take the title from the board name', async () => { - const id = 'af322312feae'; - const url = `https://localhost/boards/${id}`; + const id = '671a5bdf0995ace8cbc6f899'; + const url = new URL(`https://localhost/boards/${id}`); const boardName = 'My Board'; columnBoardService.findById.mockResolvedValue({ title: boardName, @@ -57,9 +57,19 @@ describe(BoardUrlHandler.name, () => { }); }); - describe('when url does not fit', () => { + describe('when path in url does not fit', () => { it('should return undefined', async () => { - const url = `https://localhost/invalid/ef2345abe4e3b`; + const url = new URL(`https://localhost/invalid/671a5bdf0995ace8cbc6f899`); + + const result = await boardUrlHandler.getMetaData(url); + + expect(result).toBeUndefined(); + }); + }); + + describe('when mongoId in url is invalid', () => { + it('should return undefined', async () => { + const url = new URL(`https://localhost/invalid/ef2345abe4e3b`); const result = await boardUrlHandler.getMetaData(url); 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 index de2f3c714a6..79b63e2273c 100644 --- 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 @@ -7,13 +7,13 @@ import { AbstractUrlHandler } from './abstract-url-handler'; @Injectable() export class BoardUrlHandler extends AbstractUrlHandler implements UrlHandler { - patterns: RegExp[] = [/\/boards\/([0-9a-z]+)$/i]; + patterns: RegExp[] = [/\/boards\/([0-9a-f]{24})$/i]; constructor(private readonly columnBoardService: ColumnBoardService, private readonly courseService: CourseService) { super(); } - async getMetaData(url: string): Promise { + async getMetaData(url: URL): Promise { const id = this.extractId(url); if (id === undefined) { return undefined; 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 index 5d58f822191..379a0eed424 100644 --- 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 @@ -29,8 +29,8 @@ describe(CourseUrlHandler.name, () => { describe('getMetaData', () => { describe('when url fits', () => { it('should call courseService with the correct id', async () => { - const id = 'af322312feae'; - const url = `https://localhost/course-rooms/${id}`; + const id = '671a5bdf0995ace8cbc6f899'; + const url = new URL(`https://localhost/course-rooms/${id}`); await courseUrlHandler.getMetaData(url); @@ -38,8 +38,8 @@ describe(CourseUrlHandler.name, () => { }); it('should take the title from the course name', async () => { - const id = 'af322312feae'; - const url = `https://localhost/course-rooms/${id}`; + const id = '671a5bdf0995ace8cbc6f899'; + const url = new URL(`https://localhost/course-rooms/${id}`); const courseName = 'My Course'; courseService.findById.mockResolvedValue({ name: courseName } as Course); @@ -51,7 +51,7 @@ describe(CourseUrlHandler.name, () => { describe('when url does not fit', () => { it('should return undefined', async () => { - const url = `https://localhost/invalid/ef2345abe4e3b`; + const url = new URL(`https://localhost/invalid/ef2345abe4e3b`); const result = await courseUrlHandler.getMetaData(url); 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 index 3dd6373ada1..db93262988e 100644 --- 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 @@ -6,13 +6,13 @@ import { AbstractUrlHandler } from './abstract-url-handler'; @Injectable() export class CourseUrlHandler extends AbstractUrlHandler implements UrlHandler { - patterns: RegExp[] = [/\/course-rooms\/([0-9a-z]+)$/i]; + patterns: RegExp[] = [/\/course-rooms\/([0-9a-f]{24})$/i]; constructor(private readonly courseService: CourseService) { super(); } - async getMetaData(url: string): Promise { + async getMetaData(url: URL): Promise { const id = this.extractId(url); if (id === undefined) { return undefined; 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 index 06f639cdb43..aa77d0e6ef5 100644 --- 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 @@ -29,8 +29,8 @@ describe(LessonUrlHandler.name, () => { describe('getMetaData', () => { describe('when url fits', () => { it('should call lessonService with the correct id', async () => { - const id = 'af322312feae'; - const url = `https://localhost/topics/${id}`; + const id = '671a5bdf0995ace8cbc6f899'; + const url = new URL(`https://localhost/topics/${id}`); await lessonUrlHandler.getMetaData(url); @@ -38,8 +38,8 @@ describe(LessonUrlHandler.name, () => { }); it('should take the title from the lessons name', async () => { - const id = 'af322312feae'; - const url = `https://localhost/topics/${id}`; + const id = '671a5bdf0995ace8cbc6f899'; + const url = new URL(`https://localhost/topics/${id}`); const lessonName = 'My lesson'; lessonService.findById.mockResolvedValue({ name: lessonName } as LessonEntity); @@ -51,7 +51,7 @@ describe(LessonUrlHandler.name, () => { describe('when url does not fit', () => { it('should return undefined', async () => { - const url = `https://localhost/invalid/ef2345abe4e3b`; + const url = new URL(`https://localhost/invalid/ef2345abe4e3b`); const result = await lessonUrlHandler.getMetaData(url); 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 index c5264020a50..d22a74f764d 100644 --- 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 @@ -6,13 +6,13 @@ import { AbstractUrlHandler } from './abstract-url-handler'; @Injectable() export class LessonUrlHandler extends AbstractUrlHandler implements UrlHandler { - patterns: RegExp[] = [/\/topics\/([0-9a-z]+)$/i]; + patterns: RegExp[] = [/\/topics\/([0-9a-f]{24})$/i]; constructor(private readonly lessonService: LessonService) { super(); } - async getMetaData(url: string): Promise { + async getMetaData(url: URL): Promise { const id = this.extractId(url); if (id === undefined) { return undefined; 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 index 3b67a365a43..52a3a8713a2 100644 --- 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 @@ -29,8 +29,8 @@ describe(TaskUrlHandler.name, () => { describe('getMetaData', () => { describe('when url fits', () => { it('should call taskService with the correct id', async () => { - const id = 'af322312feae'; - const url = `https://localhost/homework/${id}`; + const id = '671a5bdf0995ace8cbc6f899'; + const url = new URL(`https://localhost/homework/${id}`); await taskUrlHandler.getMetaData(url); @@ -38,8 +38,8 @@ describe(TaskUrlHandler.name, () => { }); it('should take the title from the tasks name', async () => { - const id = 'af322312feae'; - const url = `https://localhost/homework/${id}`; + const id = '671a5bdf0995ace8cbc6f899'; + const url = new URL(`https://localhost/homework/${id}`); const taskName = 'My Task'; taskService.findById.mockResolvedValue({ name: taskName } as Task); @@ -51,7 +51,7 @@ describe(TaskUrlHandler.name, () => { describe('when url does not fit', () => { it('should return undefined', async () => { - const url = `https://localhost/invalid/ef2345abe4e3b`; + const url = new URL(`https://localhost/invalid/ef2345abe4e3b`); const result = await taskUrlHandler.getMetaData(url); 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 index cb1cec86048..103adc0c2bf 100644 --- 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 @@ -6,13 +6,13 @@ import { AbstractUrlHandler } from './abstract-url-handler'; @Injectable() export class TaskUrlHandler extends AbstractUrlHandler implements UrlHandler { - patterns: RegExp[] = [/\/homework\/([0-9a-z]+)$/i]; + patterns: RegExp[] = [/\/homework\/([0-9a-f]{24})$/i]; constructor(private readonly taskService: TaskService) { super(); } - async getMetaData(url: string): Promise { + async getMetaData(url: URL): Promise { const id = this.extractId(url); if (id === undefined) { return undefined; 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 index b4da460d6e9..43b7fcf33b3 100644 --- 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 @@ -1,12 +1,10 @@ -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; + originalImageUrl?: string; type: MetaDataEntityType; parentTitle?: string; parentType?: MetaDataEntityType; 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 93ff352a597..b2deb588d38 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 @@ -17,7 +17,6 @@ export class MetaTagExtractorUc { } catch (error) { throw new UnauthorizedException(); } - const result = await this.metaTagExtractorService.getMetaData(url); return result; }