diff --git a/docs/dev/db-schema.plantuml b/docs/dev/db-schema.plantuml index 56063af26..3ec0fb4a7 100644 --- a/docs/dev/db-schema.plantuml +++ b/docs/dev/db-schema.plantuml @@ -115,6 +115,16 @@ entity "Group" { *canEdit : boolean } +entity "MediaUpload" { + *id : text <> + -- + *noteId : uuid <> + *userId : uuid <> + *backendType: text + backendData: text + *createdAt : date +} + Note "1" - "1..*" Revision Revision "0..*" - "0..*" Authorship (Revision, Authorship) .. RevisionAuthorship @@ -129,4 +139,6 @@ authToken "1..*" -- "1" User seesion "1..*" -- "1" User Note "0..*" -- "0..*" User : color (Note, User) .. AuthorColors +MediaUpload "0..*" -- "1" Note +MediaUpload "0..*" -- "1" User @enduml diff --git a/docs/dev/public_api.yml b/docs/dev/public_api.yml index c6312844d..fecc46375 100644 --- a/docs/dev/public_api.yml +++ b/docs/dev/public_api.yml @@ -454,27 +454,112 @@ paths: content: text/plain: example: my-note - /media/upload: + /media: post: tags: - media - summary: Uploads an image to the backend storage - description: Uploads an image to be processed by the backend. + summary: Uploads a media file to the backend storage + description: Uploads a file to be processed by the backend. requestBody: required: true - description: The binary image to upload. + description: The binary file to upload. content: - image/*: + application/pdf: schema: type: string format: binary + image/apng: + schema: + type: string + format: binary + image/bmp: + schema: + type: string + format: binary + image/gif: + schema: + type: string + format: binary + image/heif: + schema: + type: string + format: binary + image/heic: + schema: + type: string + format: binary + image/heif-sequence: + schema: + type: string + format: binary + image/heic-sequence: + schema: + type: string + format: binary + image/jpeg: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + image/svg+xml: + schema: + type: string + format: binary + image/tiff: + schema: + type: string + format: binary + image/webp: + schema: + type: string + format: binary + parameters: + - in: header + name: HedgeDoc-Note + schema: + type: string + required: true + description: ID or alias of the parent note responses: '200': - description: The image was uploaded successfully. + description: The file was uploaded successfully. + content: + application/json: + schema: + type: object + properties: + link: + type: string '401': "$ref": "#/components/responses/UnauthorizedError" '403': "$ref": "#/components/responses/ForbiddenError" + /media/{filename}: + delete: + tags: + - media + summary: Delete the specified file + operationId: deleteMedia + parameters: + - name: filename + in: path + required: true + description: The name of the file to be deleted. + content: + text/plain: + example: e18d1b83e1821128615bad849ad0655a.jpg + responses: + '204': + "$ref": "#/components/responses/SuccessfullyDeleted" + '401': + "$ref": "#/components/responses/UnauthorizedError" + '403': + "$ref": "#/components/responses/ForbiddenError" + '404': + "$ref": "#/components/responses/NotFoundError" /monitoring: get: tags: diff --git a/package.json b/package.json index 4b6dd5c77..012173c8d 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,13 @@ "@nestjs/common": "^7.0.0", "@nestjs/core": "^7.0.0", "@nestjs/platform-express": "^7.0.0", + "@nestjs/serve-static": "^2.1.3", "@nestjs/swagger": "^4.5.12", "@nestjs/typeorm": "^7.1.0", "class-transformer": "^0.2.3", "class-validator": "^0.12.2", "connect-typeorm": "^1.1.4", + "file-type": "^15.0.1", "raw-body": "^2.4.1", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", diff --git a/src/api/public/me/me.controller.ts b/src/api/public/me/me.controller.ts index 645fcaa0b..92eb52ba7 100644 --- a/src/api/public/me/me.controller.ts +++ b/src/api/public/me/me.controller.ts @@ -29,8 +29,10 @@ export class MeController { } @Get() - getMe(): UserInfoDto { - return this.usersService.getUserInfo(); + async getMe(): Promise { + return this.usersService.toUserDto( + await this.usersService.getUserByUsername('hardcoded'), + ); } @Get('history') diff --git a/src/api/public/media/media.controller.spec.ts b/src/api/public/media/media.controller.spec.ts index fa364d9b5..c5cf4914e 100644 --- a/src/api/public/media/media.controller.spec.ts +++ b/src/api/public/media/media.controller.spec.ts @@ -1,5 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { LoggerModule } from '../../../logger/logger.module'; +import { MediaUpload } from '../../../media/media-upload.entity'; +import { MediaModule } from '../../../media/media.module'; +import { AuthorColor } from '../../../notes/author-color.entity'; +import { Note } from '../../../notes/note.entity'; +import { NotesModule } from '../../../notes/notes.module'; +import { Authorship } from '../../../revisions/authorship.entity'; +import { Revision } from '../../../revisions/revision.entity'; +import { AuthToken } from '../../../users/auth-token.entity'; +import { Identity } from '../../../users/identity.entity'; +import { User } from '../../../users/user.entity'; import { MediaController } from './media.controller'; describe('Media Controller', () => { @@ -8,8 +19,25 @@ describe('Media Controller', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [MediaController], - imports: [LoggerModule], - }).compile(); + imports: [LoggerModule, MediaModule, NotesModule], + }) + .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({}) + .compile(); controller = module.get(MediaController); }); diff --git a/src/api/public/media/media.controller.ts b/src/api/public/media/media.controller.ts index 57aa18121..109a2d1c3 100644 --- a/src/api/public/media/media.controller.ts +++ b/src/api/public/media/media.controller.ts @@ -1,21 +1,75 @@ import { + BadRequestException, Controller, + Delete, + Headers, + NotFoundException, + Param, Post, + UnauthorizedException, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; +import { + ClientError, + NotInDBError, + PermissionError, +} from '../../../errors/errors'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; +import { MediaService } from '../../../media/media.service'; +import { MulterFile } from '../../../media/multer-file.interface'; +import { NotesService } from '../../../notes/notes.service'; @Controller('media') export class MediaController { - constructor(private readonly logger: ConsoleLoggerService) { + constructor( + private readonly logger: ConsoleLoggerService, + private mediaService: MediaService, + private notesService: NotesService, + ) { this.logger.setContext(MediaController.name); } - @Post('upload') + @Post() @UseInterceptors(FileInterceptor('file')) - uploadImage(@UploadedFile() file) { - this.logger.debug('Recieved file: ' + file); + async uploadMedia( + @UploadedFile() file: MulterFile, + @Headers('HedgeDoc-Note') noteId: string, + ) { + //TODO: Get user from request + 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, username, noteId); + return { + link: url, + }; + } catch (e) { + if (e instanceof ClientError || e instanceof NotInDBError) { + throw new BadRequestException(e.message); + } + throw e; + } + } + + @Delete(':filename') + async deleteMedia(@Param('filename') filename: string) { + //TODO: Get user from request + const username = 'hardcoded'; + try { + await this.mediaService.deleteFile(filename, username); + } catch (e) { + if (e instanceof PermissionError) { + throw new UnauthorizedException(e.message); + } + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + throw e; + } } } diff --git a/src/api/public/public-api.module.ts b/src/api/public/public-api.module.ts index 26646d4cf..bc1f09c79 100644 --- a/src/api/public/public-api.module.ts +++ b/src/api/public/public-api.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { HistoryModule } from '../../history/history.module'; import { LoggerModule } from '../../logger/logger.module'; +import { MediaModule } from '../../media/media.module'; import { MonitoringModule } from '../../monitoring/monitoring.module'; import { NotesModule } from '../../notes/notes.module'; import { RevisionsModule } from '../../revisions/revisions.module'; @@ -18,6 +19,7 @@ import { MonitoringController } from './monitoring/monitoring.controller'; RevisionsModule, MonitoringModule, LoggerModule, + MediaModule, ], controllers: [ MeController, diff --git a/src/app.module.ts b/src/app.module.ts index 138c0c615..57a842aa4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; +import { ServeStaticModule } from '@nestjs/serve-static'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { join } from 'path'; import { PublicApiModule } from './api/public/public-api.module'; import { AuthorsModule } from './authors/authors.module'; import { GroupsModule } from './groups/groups.module'; import { HistoryModule } from './history/history.module'; import { LoggerModule } from './logger/logger.module'; +import { MediaModule } from './media/media.module'; import { MonitoringModule } from './monitoring/monitoring.module'; import { NotesModule } from './notes/notes.module'; import { PermissionsModule } from './permissions/permissions.module'; @@ -19,6 +22,11 @@ import { UsersModule } from './users/users.module'; autoLoadEntities: true, synchronize: true, }), + ServeStaticModule.forRoot({ + rootPath: join(__dirname, '..'), + // TODO: Get uploads directory from config + renderPath: 'uploads', + }), NotesModule, UsersModule, RevisionsModule, @@ -29,6 +37,7 @@ import { UsersModule } from './users/users.module'; PermissionsModule, GroupsModule, LoggerModule, + MediaModule, ], controllers: [], providers: [], diff --git a/src/errors/errors.ts b/src/errors/errors.ts new file mode 100644 index 000000000..5f7885ce1 --- /dev/null +++ b/src/errors/errors.ts @@ -0,0 +1,11 @@ +export class NotInDBError extends Error { + name = 'NotInDBError'; +} + +export class ClientError extends Error { + name = 'ClientError'; +} + +export class PermissionError extends Error { + name = 'PermissionError'; +} diff --git a/src/media/backends/backend-type.enum.ts b/src/media/backends/backend-type.enum.ts new file mode 100644 index 000000000..60117b256 --- /dev/null +++ b/src/media/backends/backend-type.enum.ts @@ -0,0 +1,6 @@ +export enum BackendType { + FILEYSTEM = 'filesystem', + S3 = 's3', + IMGUR = 'imgur', + AZURE = 'azure', +} diff --git a/src/media/backends/filesystem-backend.ts b/src/media/backends/filesystem-backend.ts new file mode 100644 index 000000000..5069fa969 --- /dev/null +++ b/src/media/backends/filesystem-backend.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { ConsoleLoggerService } from '../../logger/console-logger.service'; +import { MediaBackend } from '../media-backend.interface'; +import { BackendData } from '../media-upload.entity'; + +@Injectable() +export class FilesystemBackend implements MediaBackend { + constructor(private readonly logger: ConsoleLoggerService) { + this.logger.setContext(FilesystemBackend.name); + } + + async saveFile( + buffer: Buffer, + fileName: string, + ): Promise<[string, BackendData]> { + const filePath = FilesystemBackend.getFilePath(fileName); + this.logger.debug(`Writing file to: ${filePath}`, 'saveFile'); + await fs.writeFile(filePath, buffer, null); + return ['/' + filePath, null]; + } + + async deleteFile(fileName: string, _: BackendData): Promise { + return fs.unlink(FilesystemBackend.getFilePath(fileName)); + } + + getFileURL(fileName: string, _: BackendData): Promise { + const filePath = FilesystemBackend.getFilePath(fileName); + // TODO: Add server address to url + return Promise.resolve('/' + filePath); + } + + private static getFilePath(fileName: string): string { + // TODO: Get uploads directory from config + const uploadDirectory = './uploads'; + return join(uploadDirectory, fileName); + } +} diff --git a/src/media/media-backend.interface.ts b/src/media/media-backend.interface.ts new file mode 100644 index 000000000..52e6883d4 --- /dev/null +++ b/src/media/media-backend.interface.ts @@ -0,0 +1,25 @@ +import { BackendData } from './media-upload.entity'; + +export interface MediaBackend { + /** + * Saves a file according to backend internals. + * @param buffer File data + * @param fileName Name of the file to save. Can include a file extension. + * @return Tuple of file URL and internal backend data, which should be saved. + */ + saveFile(buffer: Buffer, fileName: string): Promise<[string, BackendData]>; + + /** + * Retrieve the URL of a previously saved file. + * @param fileName String to identify the file + * @param backendData Internal backend data + */ + getFileURL(fileName: string, backendData: BackendData): Promise; + + /** + * Delete a file from the backend + * @param fileName String to identify the file + * @param backendData Internal backend data + */ + deleteFile(fileName: string, backendData: BackendData): Promise; +} diff --git a/src/media/media-upload.entity.ts b/src/media/media-upload.entity.ts new file mode 100644 index 000000000..5ec8739b1 --- /dev/null +++ b/src/media/media-upload.entity.ts @@ -0,0 +1,62 @@ +import * as crypto from 'crypto'; +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryColumn, +} from 'typeorm'; +import { Note } from '../notes/note.entity'; +import { User } from '../users/user.entity'; +import { BackendType } from './backends/backend-type.enum'; + +export type BackendData = string | null; + +@Entity() +export class MediaUpload { + @PrimaryColumn() + id: string; + + @ManyToOne(_ => Note, { nullable: false }) + note: Note; + + @ManyToOne(_ => User, { nullable: false }) + user: User; + + @Column({ + nullable: false, + }) + backendType: string; + + @Column({ + nullable: true, + }) + backendData: BackendData; + + @CreateDateColumn() + createdAt: Date; + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + public static create( + note: Note, + user: User, + extension: string, + backendType: BackendType, + backendData?: string, + ): MediaUpload { + const upload = new MediaUpload(); + const randomBytes = crypto.randomBytes(16); + upload.id = randomBytes.toString('hex') + '.' + extension; + upload.note = note; + upload.user = user; + upload.backendType = backendType; + if (backendData) { + upload.backendData = backendData; + } else { + upload.backendData = null; + } + return upload; + } +} diff --git a/src/media/media.module.ts b/src/media/media.module.ts new file mode 100644 index 000000000..d24cb2bcb --- /dev/null +++ b/src/media/media.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { LoggerModule } from '../logger/logger.module'; +import { NotesModule } from '../notes/notes.module'; +import { UsersModule } from '../users/users.module'; +import { FilesystemBackend } from './backends/filesystem-backend'; +import { MediaUpload } from './media-upload.entity'; +import { MediaService } from './media.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([MediaUpload]), + NotesModule, + UsersModule, + LoggerModule, + ], + providers: [MediaService, FilesystemBackend], + exports: [MediaService], +}) +export class MediaModule {} diff --git a/src/media/media.service.spec.ts b/src/media/media.service.spec.ts new file mode 100644 index 000000000..19369f72d --- /dev/null +++ b/src/media/media.service.spec.ts @@ -0,0 +1,54 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { LoggerModule } from '../logger/logger.module'; +import { AuthorColor } from '../notes/author-color.entity'; +import { Note } from '../notes/note.entity'; +import { NotesModule } from '../notes/notes.module'; +import { Authorship } from '../revisions/authorship.entity'; +import { Revision } from '../revisions/revision.entity'; +import { AuthToken } from '../users/auth-token.entity'; +import { Identity } from '../users/identity.entity'; +import { User } from '../users/user.entity'; +import { UsersModule } from '../users/users.module'; +import { MediaUpload } from './media-upload.entity'; +import { MediaService } from './media.service'; + +describe('MediaService', () => { + let service: MediaService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MediaService, + { + provide: getRepositoryToken(MediaUpload), + useValue: {}, + }, + ], + imports: [LoggerModule, NotesModule, UsersModule], + }) + .overrideProvider(getRepositoryToken(AuthorColor)) + .useValue({}) + .overrideProvider(getRepositoryToken(MediaUpload)) + .useValue({}) + .overrideProvider(getRepositoryToken(Authorship)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthToken)) + .useValue({}) + .overrideProvider(getRepositoryToken(Identity)) + .useValue({}) + .overrideProvider(getRepositoryToken(Note)) + .useValue({}) + .overrideProvider(getRepositoryToken(Revision)) + .useValue({}) + .overrideProvider(getRepositoryToken(User)) + .useValue({}) + .compile(); + + service = module.get(MediaService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/media/media.service.ts b/src/media/media.service.ts new file mode 100644 index 000000000..92c96ee49 --- /dev/null +++ b/src/media/media.service.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { InjectRepository } from '@nestjs/typeorm'; +import * as FileType from 'file-type'; +import { Repository } from 'typeorm'; +import { ClientError, NotInDBError, PermissionError } from '../errors/errors'; +import { ConsoleLoggerService } from '../logger/console-logger.service'; +import { NotesService } from '../notes/notes.service'; +import { UsersService } from '../users/users.service'; +import { BackendType } from './backends/backend-type.enum'; +import { FilesystemBackend } from './backends/filesystem-backend'; +import { MediaUpload } from './media-upload.entity'; +import { MulterFile } from './multer-file.interface'; + +@Injectable() +export class MediaService { + constructor( + private readonly logger: ConsoleLoggerService, + @InjectRepository(MediaUpload) + private mediaUploadRepository: Repository, + private notesService: NotesService, + private usersService: UsersService, + private moduleRef: ModuleRef, + ) { + this.logger.setContext(MediaService.name); + } + + private static isAllowedMimeType(mimeType: string): boolean { + const allowedTypes = [ + 'application/pdf', + 'image/apng', + 'image/bmp', + 'image/gif', + 'image/heif', + 'image/heic', + 'image/heif-sequence', + 'image/heic-sequence', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/tiff', + 'image/webp', + ]; + return allowedTypes.includes(mimeType); + } + + public async saveFile(file: MulterFile, username: string, noteId: string) { + this.logger.debug( + `Saving '${file.originalname}' for note '${noteId}' and user '${username}'`, + 'saveFile', + ); + const note = await this.notesService.getNoteByIdOrAlias(noteId); + const user = await this.usersService.getUserByUsername(username); + const fileTypeResult = await FileType.fromBuffer(file.buffer); + if (!fileTypeResult) { + throw new ClientError('Could not detect file type.'); + } + if (!MediaService.isAllowedMimeType(fileTypeResult.mime)) { + throw new ClientError('MIME type not allowed.'); + } + //TODO: Choose backend according to config + const mediaUpload = MediaUpload.create( + note, + user, + fileTypeResult.ext, + BackendType.FILEYSTEM, + ); + this.logger.debug(`Generated filename: '${mediaUpload.id}'`, 'saveFile'); + const backend = this.moduleRef.get(FilesystemBackend); + const [url, backendData] = await backend.saveFile( + file.buffer, + mediaUpload.id, + ); + mediaUpload.backendData = backendData; + await this.mediaUploadRepository.save(mediaUpload); + return url; + } + + public async deleteFile(filename: string, username: string) { + this.logger.debug( + `Deleting '${filename}' for user '${username}'`, + 'deleteFile', + ); + const mediaUpload = await this.findUploadByFilename(filename); + if (mediaUpload.user.userName !== username) { + this.logger.warn( + `${username} tried to delete '${filename}', but is not the owner`, + 'deleteFile', + ); + throw new PermissionError( + `File '${filename}' is not owned by '${username}'`, + ); + } + const backend = this.moduleRef.get(FilesystemBackend); + await backend.deleteFile(filename, mediaUpload.backendData); + await this.mediaUploadRepository.remove(mediaUpload); + } + + public async findUploadByFilename(filename: string): Promise { + const mediaUpload = await this.mediaUploadRepository.findOne(filename, { + relations: ['user'], + }); + if (mediaUpload === undefined) { + throw new NotInDBError( + `MediaUpload with filename '${filename}' not found`, + ); + } + return mediaUpload; + } +} diff --git a/src/media/multer-file.interface.ts b/src/media/multer-file.interface.ts new file mode 100644 index 000000000..a602a5008 --- /dev/null +++ b/src/media/multer-file.interface.ts @@ -0,0 +1,32 @@ +import { Readable } from 'stream'; + +// Type from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/multer/index.d.ts +export interface MulterFile { + /** Name of the form field associated with this file. */ + fieldname: string; + /** Name of the file on the uploader's computer. */ + originalname: string; + /** + * Value of the `Content-Transfer-Encoding` header for this file. + * @deprecated since July 2015 + * @see RFC 7578, Section 4.7 + */ + encoding: string; + /** Value of the `Content-Type` header for this file. */ + mimetype: string; + /** Size of the file in bytes. */ + size: number; + /** + * A readable stream of this file. Only available to the `_handleFile` + * callback for custom `StorageEngine`s. + */ + stream: Readable; + /** `DiskStorage` only: Directory to which this file has been uploaded. */ + destination: string; + /** `DiskStorage` only: Name of this file within `destination`. */ + filename: string; + /** `DiskStorage` only: Full path to the uploaded file. */ + path: string; + /** `MemoryStorage` only: A Buffer containing the entire file. */ + buffer: Buffer; +} diff --git a/src/notes/notes.service.ts b/src/notes/notes.service.ts index 2233fc87f..f6d5e4994 100644 --- a/src/notes/notes.service.ts +++ b/src/notes/notes.service.ts @@ -1,6 +1,7 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { NotInDBError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { Revision } from '../revisions/revision.entity'; import { RevisionsService } from '../revisions/revisions.service'; @@ -132,8 +133,19 @@ export class NotesService { } async getNoteByIdOrAlias(noteIdOrAlias: string): Promise { + this.logger.debug( + `Trying to find note '${noteIdOrAlias}'`, + 'getNoteByIdOrAlias', + ); const note = await this.noteRepository.findOne({ - where: [{ id: noteIdOrAlias }, { alias: noteIdOrAlias }], + where: [ + { + id: noteIdOrAlias, + }, + { + alias: noteIdOrAlias, + }, + ], relations: [ 'authorColors', 'owner', @@ -142,8 +154,9 @@ export class NotesService { ], }); if (note === undefined) { - //TODO: Improve error handling - throw new Error('Note not found'); + throw new NotInDBError( + `Note with id/alias '${noteIdOrAlias}' not found.`, + ); } return note; } diff --git a/src/users/user.entity.ts b/src/users/user.entity.ts index 939ffd11c..9b20e6444 100644 --- a/src/users/user.entity.ts +++ b/src/users/user.entity.ts @@ -48,4 +48,20 @@ export class User { identity => identity.user, ) identities: Identity[]; + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + public static create( + userName: string, + displayName: string, + ): Pick< + User, + 'userName' | 'displayName' | 'ownedNotes' | 'authToken' | 'identities' + > { + const newUser = new User(); + newUser.userName = userName; + newUser.displayName = displayName; + return newUser; + } } diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index 3adc1fd92..4b43f74c4 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -1,5 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { LoggerModule } from '../logger/logger.module'; +import { User } from './user.entity'; import { UsersService } from './users.service'; describe('UsersService', () => { @@ -7,9 +9,18 @@ describe('UsersService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [UsersService], + providers: [ + UsersService, + { + provide: getRepositoryToken(User), + useValue: {}, + }, + ], imports: [LoggerModule], - }).compile(); + }) + .overrideProvider(getRepositoryToken(User)) + .useValue({}) + .compile(); service = module.get(UsersService); }); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index cc4fa1880..2c596905d 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,26 +1,44 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotInDBError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { UserInfoDto } from './user-info.dto'; import { User } from './user.entity'; @Injectable() export class UsersService { - constructor(private readonly logger: ConsoleLoggerService) { + constructor( + private readonly logger: ConsoleLoggerService, + @InjectRepository(User) private userRepository: Repository, + ) { this.logger.setContext(UsersService.name); } - getUserInfo(): UserInfoDto { - //TODO: Use the database - this.logger.warn('Using hardcoded data!'); - return { - displayName: 'foo', - userName: 'fooUser', - email: 'foo@example.com', - photo: '', - }; + createUser(userName: string, displayName: string): Promise { + const user = User.create(userName, displayName); + return this.userRepository.save(user); } - getPhotoUrl(user: User) { + async deleteUser(userName: string) { + //TOOD: Handle owned notes and edits + const user = await this.userRepository.findOne({ + where: { userName: userName }, + }); + await this.userRepository.delete(user); + } + + async getUserByUsername(userName: string): Promise { + const user = await this.userRepository.findOne({ + where: { userName: userName }, + }); + if (user === undefined) { + throw new NotInDBError(`User with username '${userName}' not found`); + } + return user; + } + + getPhotoUrl(user: User): string { if (user.photo) { return user.photo; } else { diff --git a/test/public-api/notes.e2e-spec.ts b/test/public-api/notes.e2e-spec.ts index a27e0ef7b..09678dbb6 100644 --- a/test/public-api/notes.e2e-spec.ts +++ b/test/public-api/notes.e2e-spec.ts @@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; import * as request from 'supertest'; import { PublicApiModule } from '../../src/api/public/public-api.module'; +import { NotInDBError } from '../../src/errors/errors'; import { GroupsModule } from '../../src/groups/groups.module'; import { LoggerModule } from '../../src/logger/logger.module'; import { NotesModule } from '../../src/notes/notes.module'; @@ -82,7 +83,7 @@ describe('Notes', () => { .delete('/notes/test3') .expect(200); return expect(notesService.getNoteByIdOrAlias('test3')).rejects.toEqual( - Error('Note not found'), + new NotInDBError("Note with id/alias 'test3' not found."), ); }); diff --git a/yarn.lock b/yarn.lock index bfe54b370..2cdf87f00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -614,6 +614,13 @@ "@angular-devkit/schematics" "9.1.7" fs-extra "9.0.0" +"@nestjs/serve-static@^2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@nestjs/serve-static/-/serve-static-2.1.3.tgz#bdcb6d3463d193153b334212facc24a9767046e9" + integrity sha512-9xyysggaOdfbABWqhty+hAkauDWv/Q8YKHm4OMXdQbQei5tquFuTjiSx8IFDOZeSOKlA9fjBq/2MXCJRSo23SQ== + dependencies: + path-to-regexp "0.1.7" + "@nestjs/swagger@^4.5.12": version "4.5.12" resolved "https://registry.yarnpkg.com/@nestjs/swagger/-/swagger-4.5.12.tgz#e8aa65fbb0033007ece1d494b002f47ff472c20b" @@ -669,6 +676,11 @@ dependencies: "@sinonjs/commons" "^1.7.0" +"@tokenizer/token@^0.1.0", "@tokenizer/token@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.1.1.tgz#f0d92c12f87079ddfd1b29f614758b9696bc29e3" + integrity sha512-XO6INPbZCxdprl+9qa/AAbFFOMzzwqYxpjPgLICrMD6C2FCw6qfJOPcBk6JqqPLSaZ/Qx87qn4rpPmPMwaAK6w== + "@types/anymatch@*": version "1.3.1" resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" @@ -737,6 +749,11 @@ resolved "https://registry.yarnpkg.com/@types/debug/-/debug-0.0.31.tgz#bac8d8aab6a823e91deb7f79083b2a35fa638f33" integrity sha512-LS1MCPaQKqspg7FvexuhmDbWUhE2yIJ+4AgVIyObfc06/UKZ8REgxGNjZc82wPLWmbeOm7S+gSsLgo75TanG4A== +"@types/debug@^4.1.5": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd" + integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ== + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" @@ -3118,6 +3135,16 @@ file-entry-cache@^5.0.1: dependencies: flat-cache "^2.0.1" +file-type@^15.0.1: + version "15.0.1" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-15.0.1.tgz#54175484953d48b970c095ba8737d4e0c3a9b407" + integrity sha512-0LieQlSA3bWUdErNrxzxfI4rhsvNAVPBO06R8pTc1hp9SE6nhqlVyvhcaXoMmtXkBTPnQenbMPLW9X76hH76oQ== + dependencies: + readable-web-to-node-stream "^2.0.0" + strtok3 "^6.0.3" + token-types "^2.0.0" + typedarray-to-buffer "^3.1.5" + file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -3659,7 +3686,7 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4: dependencies: safer-buffer ">= 2.1.2 < 3" -ieee754@^1.1.4: +ieee754@^1.1.13, ieee754@^1.1.4: version "1.1.13" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== @@ -5698,6 +5725,11 @@ pbkdf2@^3.0.3: safe-buffer "^5.0.1" sha.js "^2.4.8" +peek-readable@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-3.1.0.tgz#250b08b7de09db8573d7fd8ea475215bbff14348" + integrity sha512-KGuODSTV6hcgdZvDrIDBUkN0utcAVj1LL7FfGbM0viKTtCHmtZcuEJ+lGqsp0fTFkGqesdtemV2yUSMeyy3ddA== + performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" @@ -6028,6 +6060,11 @@ readable-stream@1.1.x: isarray "0.0.1" string_decoder "~0.10.x" +readable-web-to-node-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-2.0.0.tgz#751e632f466552ac0d5c440cc01470352f93c4b7" + integrity sha512-+oZJurc4hXpaaqsN68GoZGQAQIA3qr09Or4fqEsargABnbe5Aau8hFn6ISVleT3cpY/0n/8drn7huyyEvTbghA== + readdirp@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" @@ -6841,6 +6878,15 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= +strtok3@^6.0.3: + version "6.0.4" + resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-6.0.4.tgz#ede0d20fde5aa9fda56417c3558eaafccc724694" + integrity sha512-rqWMKwsbN9APU47bQTMEYTPcwdpKDtmf1jVhHzNW2cL1WqAxaM9iBb9t5P2fj+RV2YsErUWgQzHD5JwV0uCTEQ== + dependencies: + "@tokenizer/token" "^0.1.1" + "@types/debug" "^4.1.5" + peek-readable "^3.1.0" + superagent@^3.8.3: version "3.8.3" resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128" @@ -7103,6 +7149,14 @@ toidentifier@1.0.0: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== +token-types@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/token-types/-/token-types-2.0.0.tgz#b23618af744818299c6fbf125e0fdad98bab7e85" + integrity sha512-WWvu8sGK8/ZmGusekZJJ5NM6rRVTTDO7/bahz4NGiSDb/XsmdYBn6a1N/bymUHuWYTWeuLUg98wUzvE4jPdCZw== + dependencies: + "@tokenizer/token" "^0.1.0" + ieee754 "^1.1.13" + tough-cookie@^2.3.3: version "2.4.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"