diff --git a/src/api/private/media/media.controller.spec.ts b/src/api/private/media/media.controller.spec.ts new file mode 100644 index 000000000..61e60dbf7 --- /dev/null +++ b/src/api/private/media/media.controller.spec.ts @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { MediaController } from './media.controller'; +import { LoggerModule } from '../../../logger/logger.module'; +import { ConfigModule } from '@nestjs/config'; +import mediaConfigMock from '../../../config/mock/media.config.mock'; +import appConfigMock from '../../../config/mock/app.config.mock'; +import authConfigMock from '../../../config/mock/auth.config.mock'; +import customizationConfigMock from '../../../config/mock/customization.config.mock'; +import externalConfigMock from '../../../config/mock/external-services.config.mock'; +import { MediaModule } from '../../../media/media.module'; +import { NotesModule } from '../../../notes/notes.module'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AuthorColor } from '../../../notes/author-color.entity'; +import { Authorship } from '../../../revisions/authorship.entity'; +import { AuthToken } from '../../../auth/auth-token.entity'; +import { Identity } from '../../../users/identity.entity'; +import { MediaUpload } from '../../../media/media-upload.entity'; +import { Note } from '../../../notes/note.entity'; +import { Revision } from '../../../revisions/revision.entity'; +import { User } from '../../../users/user.entity'; +import { Tag } from '../../../notes/tag.entity'; +import { NoteGroupPermission } from '../../../permissions/note-group-permission.entity'; +import { NoteUserPermission } from '../../../permissions/note-user-permission.entity'; +import { Group } from '../../../groups/group.entity'; + +describe('MediaController', () => { + let controller: MediaController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + MediaModule, + NotesModule, + LoggerModule, + ConfigModule.forRoot({ + isGlobal: true, + load: [ + appConfigMock, + mediaConfigMock, + authConfigMock, + customizationConfigMock, + externalConfigMock, + ], + }), + ], + controllers: [MediaController], + }) + .overrideProvider(getRepositoryToken(AuthorColor)) + .useValue({}) + .overrideProvider(getRepositoryToken(Authorship)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthToken)) + .useValue({}) + .overrideProvider(getRepositoryToken(Identity)) + .useValue({}) + .overrideProvider(getRepositoryToken(MediaUpload)) + .useValue({}) + .overrideProvider(getRepositoryToken(Note)) + .useValue({}) + .overrideProvider(getRepositoryToken(Revision)) + .useValue({}) + .overrideProvider(getRepositoryToken(User)) + .useValue({}) + .overrideProvider(getRepositoryToken(Tag)) + .useValue({}) + .overrideProvider(getRepositoryToken(NoteGroupPermission)) + .useValue({}) + .overrideProvider(getRepositoryToken(NoteUserPermission)) + .useValue({}) + .overrideProvider(getRepositoryToken(Group)) + .useValue({}) + .compile(); + + controller = module.get(MediaController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/api/private/media/media.controller.ts b/src/api/private/media/media.controller.ts new file mode 100644 index 000000000..7b5fde80a --- /dev/null +++ b/src/api/private/media/media.controller.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + BadRequestException, + Controller, + Headers, + HttpCode, + InternalServerErrorException, + Post, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { ConsoleLoggerService } from '../../../logger/console-logger.service'; +import { MediaService } from '../../../media/media.service'; +import { MulterFile } from '../../../media/multer-file.interface'; +import { MediaUploadUrlDto } from '../../../media/media-upload-url.dto'; +import { + ClientError, + MediaBackendError, + NotInDBError, +} from '../../../errors/errors'; +import { FileInterceptor } from '@nestjs/platform-express'; + +@Controller('media') +export class MediaController { + constructor( + private readonly logger: ConsoleLoggerService, + private mediaService: MediaService, + ) { + this.logger.setContext(MediaController.name); + } + + @Post() + @UseInterceptors(FileInterceptor('file')) + @HttpCode(201) + async uploadMedia( + @UploadedFile() file: MulterFile, + @Headers('HedgeDoc-Note') noteId: string, + ): Promise { + // ToDo: Get real userName + const username = 'hardcoded'; + this.logger.debug( + `Recieved filename '${file.originalname}' for note '${noteId}' from user '${username}'`, + 'uploadImage', + ); + try { + const url = await this.mediaService.saveFile( + file.buffer, + username, + noteId, + ); + return this.mediaService.toMediaUploadUrlDto(url); + } catch (e) { + if (e instanceof ClientError || e instanceof NotInDBError) { + throw new BadRequestException(e.message); + } + if (e instanceof MediaBackendError) { + throw new InternalServerErrorException( + 'There was an error in the media backend', + ); + } + throw e; + } + } +} diff --git a/src/api/private/private-api.module.ts b/src/api/private/private-api.module.ts index bd51ad1e5..729c65306 100644 --- a/src/api/private/private-api.module.ts +++ b/src/api/private/private-api.module.ts @@ -14,9 +14,10 @@ import { FrontendConfigModule } from '../../frontend-config/frontend-config.modu import { HistoryController } from './me/history/history.controller'; import { HistoryModule } from '../../history/history.module'; import { NotesModule } from '../../notes/notes.module'; +import { MediaModule } from '../../media/media.module'; +import { MediaController } from './media/media.controller'; import { NotesController } from './notes/notes.controller'; import { PermissionsModule } from '../../permissions/permissions.module'; -import { MediaModule } from '../../media/media.module'; import { RevisionsModule } from '../../revisions/revisions.module'; @Module({ @@ -34,6 +35,7 @@ import { RevisionsModule } from '../../revisions/revisions.module'; controllers: [ TokensController, ConfigController, + MediaController, HistoryController, NotesController, ], diff --git a/test/private-api/fixtures/test.png b/test/private-api/fixtures/test.png new file mode 100644 index 000000000..5f04fc472 Binary files /dev/null and b/test/private-api/fixtures/test.png differ diff --git a/test/private-api/fixtures/test.png.license b/test/private-api/fixtures/test.png.license new file mode 100644 index 000000000..078e5a9ac --- /dev/null +++ b/test/private-api/fixtures/test.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + +SPDX-License-Identifier: CC0-1.0 diff --git a/test/private-api/fixtures/test.zip b/test/private-api/fixtures/test.zip new file mode 100644 index 000000000..021e7f702 Binary files /dev/null and b/test/private-api/fixtures/test.zip differ diff --git a/test/private-api/fixtures/test.zip.license b/test/private-api/fixtures/test.zip.license new file mode 100644 index 000000000..078e5a9ac --- /dev/null +++ b/test/private-api/fixtures/test.zip.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + +SPDX-License-Identifier: CC0-1.0 diff --git a/test/private-api/media.e2e-spec.ts b/test/private-api/media.e2e-spec.ts new file mode 100644 index 000000000..2fc5f7c9a --- /dev/null +++ b/test/private-api/media.e2e-spec.ts @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable +@typescript-eslint/no-unsafe-assignment, +@typescript-eslint/no-unsafe-member-access +*/ + +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { Test } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { promises as fs } from 'fs'; +import * as request from 'supertest'; +import mediaConfigMock from '../../src/config/mock/media.config.mock'; +import appConfigMock from '../../src/config/mock/app.config.mock'; +import authConfigMock from '../../src/config/mock/auth.config.mock'; +import customizationConfigMock from '../../src/config/mock/customization.config.mock'; +import externalConfigMock from '../../src/config/mock/external-services.config.mock'; +import { GroupsModule } from '../../src/groups/groups.module'; +import { LoggerModule } from '../../src/logger/logger.module'; +import { NestConsoleLoggerService } from '../../src/logger/nest-console-logger.service'; +import { MediaModule } from '../../src/media/media.module'; +import { NotesModule } from '../../src/notes/notes.module'; +import { NotesService } from '../../src/notes/notes.service'; +import { PermissionsModule } from '../../src/permissions/permissions.module'; +import { AuthModule } from '../../src/auth/auth.module'; +import { join } from 'path'; +import { PrivateApiModule } from '../../src/api/private/private-api.module'; +import { UsersService } from '../../src/users/users.service'; + +describe('Media', () => { + let app: NestExpressApplication; + let uploadPath: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [ + mediaConfigMock, + appConfigMock, + authConfigMock, + customizationConfigMock, + externalConfigMock, + ], + }), + PrivateApiModule, + MediaModule, + TypeOrmModule.forRoot({ + type: 'sqlite', + database: './hedgedoc-e2e-private-media.sqlite', + autoLoadEntities: true, + dropSchema: true, + synchronize: true, + }), + NotesModule, + PermissionsModule, + GroupsModule, + LoggerModule, + AuthModule, + ], + }).compile(); + const config = moduleRef.get(ConfigService); + uploadPath = config.get('mediaConfig').backend.filesystem.uploadPath; + app = moduleRef.createNestApplication(); + app.useStaticAssets(uploadPath, { + prefix: '/uploads', + }); + await app.init(); + const logger = await app.resolve(NestConsoleLoggerService); + logger.log('Switching logger', 'AppBootstrap'); + app.useLogger(logger); + const notesService: NotesService = moduleRef.get('NotesService'); + await notesService.createNote('test content', 'test_upload_media'); + const userService: UsersService = moduleRef.get('UsersService'); + await userService.createUser('hardcoded', 'Testy'); + }); + + describe('POST /media', () => { + it('works', async () => { + const uploadResponse = await request(app.getHttpServer()) + .post('/media') + .attach('file', 'test/private-api/fixtures/test.png') + .set('HedgeDoc-Note', 'test_upload_media') + .expect('Content-Type', /json/) + .expect(201); + const path: string = uploadResponse.body.link; + const testImage = await fs.readFile('test/private-api/fixtures/test.png'); + const downloadResponse = await request(app.getHttpServer()).get(path); + expect(downloadResponse.body).toEqual(testImage); + // Remove /upload/ from path as we just need the filename. + const fileName = path.replace('/uploads/', ''); + // delete the file afterwards + await fs.unlink(join(uploadPath, fileName)); + }); + describe('fails:', () => { + it('MIME type not supported', async () => { + await request(app.getHttpServer()) + .post('/media') + .attach('file', 'test/private-api/fixtures/test.zip') + .set('HedgeDoc-Note', 'test_upload_media') + .expect(400); + expect(await fs.access(uploadPath)).toBeFalsy(); + }); + it('note does not exist', async () => { + await request(app.getHttpServer()) + .post('/media') + .attach('file', 'test/private-api/fixtures/test.zip') + .set('HedgeDoc-Note', 'i_dont_exist') + .expect(400); + expect(await fs.access(uploadPath)).toBeFalsy(); + }); + it('mediaBackend error', async () => { + await fs.rmdir(uploadPath); + await fs.mkdir(uploadPath, { + mode: '444', + }); + await request(app.getHttpServer()) + .post('/media') + .attach('file', 'test/private-api/fixtures/test.png') + .set('HedgeDoc-Note', 'test_upload_media') + .expect('Content-Type', /json/) + .expect(500); + await fs.rmdir(uploadPath); + }); + }); + }); + + afterAll(async () => { + // Delete the upload folder + await fs.rmdir(uploadPath); + }); +});