Merge pull request #534 from codimd/media-controller

This commit is contained in:
David Mehren 2020-10-19 21:07:13 +02:00 committed by GitHub
commit e2696e647b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 698 additions and 32 deletions

View file

@ -115,6 +115,16 @@ entity "Group" {
*canEdit : boolean *canEdit : boolean
} }
entity "MediaUpload" {
*id : text <<unique>>
--
*noteId : uuid <<FK Note>>
*userId : uuid <<FK User>>
*backendType: text
backendData: text
*createdAt : date
}
Note "1" - "1..*" Revision Note "1" - "1..*" Revision
Revision "0..*" - "0..*" Authorship Revision "0..*" - "0..*" Authorship
(Revision, Authorship) .. RevisionAuthorship (Revision, Authorship) .. RevisionAuthorship
@ -129,4 +139,6 @@ authToken "1..*" -- "1" User
seesion "1..*" -- "1" User seesion "1..*" -- "1" User
Note "0..*" -- "0..*" User : color Note "0..*" -- "0..*" User : color
(Note, User) .. AuthorColors (Note, User) .. AuthorColors
MediaUpload "0..*" -- "1" Note
MediaUpload "0..*" -- "1" User
@enduml @enduml

View file

@ -454,27 +454,112 @@ paths:
content: content:
text/plain: text/plain:
example: my-note example: my-note
/media/upload: /media:
post: post:
tags: tags:
- media - media
summary: Uploads an image to the backend storage summary: Uploads a media file to the backend storage
description: Uploads an image to be processed by the backend. description: Uploads a file to be processed by the backend.
requestBody: requestBody:
required: true required: true
description: The binary image to upload. description: The binary file to upload.
content: content:
image/*: application/pdf:
schema: schema:
type: string type: string
format: binary 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: responses:
'200': '200':
description: The image was uploaded successfully. description: The file was uploaded successfully.
content:
application/json:
schema:
type: object
properties:
link:
type: string
'401': '401':
"$ref": "#/components/responses/UnauthorizedError" "$ref": "#/components/responses/UnauthorizedError"
'403': '403':
"$ref": "#/components/responses/ForbiddenError" "$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: /monitoring:
get: get:
tags: tags:

View file

@ -25,11 +25,13 @@
"@nestjs/common": "^7.0.0", "@nestjs/common": "^7.0.0",
"@nestjs/core": "^7.0.0", "@nestjs/core": "^7.0.0",
"@nestjs/platform-express": "^7.0.0", "@nestjs/platform-express": "^7.0.0",
"@nestjs/serve-static": "^2.1.3",
"@nestjs/swagger": "^4.5.12", "@nestjs/swagger": "^4.5.12",
"@nestjs/typeorm": "^7.1.0", "@nestjs/typeorm": "^7.1.0",
"class-transformer": "^0.2.3", "class-transformer": "^0.2.3",
"class-validator": "^0.12.2", "class-validator": "^0.12.2",
"connect-typeorm": "^1.1.4", "connect-typeorm": "^1.1.4",
"file-type": "^15.0.1",
"raw-body": "^2.4.1", "raw-body": "^2.4.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",

View file

@ -29,8 +29,10 @@ export class MeController {
} }
@Get() @Get()
getMe(): UserInfoDto { async getMe(): Promise<UserInfoDto> {
return this.usersService.getUserInfo(); return this.usersService.toUserDto(
await this.usersService.getUserByUsername('hardcoded'),
);
} }
@Get('history') @Get('history')

View file

@ -1,5 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { LoggerModule } from '../../../logger/logger.module'; 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'; import { MediaController } from './media.controller';
describe('Media Controller', () => { describe('Media Controller', () => {
@ -8,8 +19,25 @@ describe('Media Controller', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [MediaController], controllers: [MediaController],
imports: [LoggerModule], imports: [LoggerModule, MediaModule, NotesModule],
}).compile(); })
.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>(MediaController); controller = module.get<MediaController>(MediaController);
}); });

View file

@ -1,21 +1,75 @@
import { import {
BadRequestException,
Controller, Controller,
Delete,
Headers,
NotFoundException,
Param,
Post, Post,
UnauthorizedException,
UploadedFile, UploadedFile,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import {
ClientError,
NotInDBError,
PermissionError,
} from '../../../errors/errors';
import { ConsoleLoggerService } from '../../../logger/console-logger.service'; 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') @Controller('media')
export class MediaController { export class MediaController {
constructor(private readonly logger: ConsoleLoggerService) { constructor(
private readonly logger: ConsoleLoggerService,
private mediaService: MediaService,
private notesService: NotesService,
) {
this.logger.setContext(MediaController.name); this.logger.setContext(MediaController.name);
} }
@Post('upload') @Post()
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
uploadImage(@UploadedFile() file) { async uploadMedia(
this.logger.debug('Recieved file: ' + file); @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;
}
} }
} }

View file

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HistoryModule } from '../../history/history.module'; import { HistoryModule } from '../../history/history.module';
import { LoggerModule } from '../../logger/logger.module'; import { LoggerModule } from '../../logger/logger.module';
import { MediaModule } from '../../media/media.module';
import { MonitoringModule } from '../../monitoring/monitoring.module'; import { MonitoringModule } from '../../monitoring/monitoring.module';
import { NotesModule } from '../../notes/notes.module'; import { NotesModule } from '../../notes/notes.module';
import { RevisionsModule } from '../../revisions/revisions.module'; import { RevisionsModule } from '../../revisions/revisions.module';
@ -18,6 +19,7 @@ import { MonitoringController } from './monitoring/monitoring.controller';
RevisionsModule, RevisionsModule,
MonitoringModule, MonitoringModule,
LoggerModule, LoggerModule,
MediaModule,
], ],
controllers: [ controllers: [
MeController, MeController,

View file

@ -1,10 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';
import { PublicApiModule } from './api/public/public-api.module'; import { PublicApiModule } from './api/public/public-api.module';
import { AuthorsModule } from './authors/authors.module'; import { AuthorsModule } from './authors/authors.module';
import { GroupsModule } from './groups/groups.module'; import { GroupsModule } from './groups/groups.module';
import { HistoryModule } from './history/history.module'; import { HistoryModule } from './history/history.module';
import { LoggerModule } from './logger/logger.module'; import { LoggerModule } from './logger/logger.module';
import { MediaModule } from './media/media.module';
import { MonitoringModule } from './monitoring/monitoring.module'; import { MonitoringModule } from './monitoring/monitoring.module';
import { NotesModule } from './notes/notes.module'; import { NotesModule } from './notes/notes.module';
import { PermissionsModule } from './permissions/permissions.module'; import { PermissionsModule } from './permissions/permissions.module';
@ -19,6 +22,11 @@ import { UsersModule } from './users/users.module';
autoLoadEntities: true, autoLoadEntities: true,
synchronize: true, synchronize: true,
}), }),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..'),
// TODO: Get uploads directory from config
renderPath: 'uploads',
}),
NotesModule, NotesModule,
UsersModule, UsersModule,
RevisionsModule, RevisionsModule,
@ -29,6 +37,7 @@ import { UsersModule } from './users/users.module';
PermissionsModule, PermissionsModule,
GroupsModule, GroupsModule,
LoggerModule, LoggerModule,
MediaModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],

11
src/errors/errors.ts Normal file
View file

@ -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';
}

View file

@ -0,0 +1,6 @@
export enum BackendType {
FILEYSTEM = 'filesystem',
S3 = 's3',
IMGUR = 'imgur',
AZURE = 'azure',
}

View file

@ -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<void> {
return fs.unlink(FilesystemBackend.getFilePath(fileName));
}
getFileURL(fileName: string, _: BackendData): Promise<string> {
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);
}
}

View file

@ -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<string>;
/**
* Delete a file from the backend
* @param fileName String to identify the file
* @param backendData Internal backend data
*/
deleteFile(fileName: string, backendData: BackendData): Promise<void>;
}

View file

@ -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;
}
}

20
src/media/media.module.ts Normal file
View file

@ -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 {}

View file

@ -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>(MediaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

110
src/media/media.service.ts Normal file
View file

@ -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<MediaUpload>,
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<MediaUpload> {
const mediaUpload = await this.mediaUploadRepository.findOne(filename, {
relations: ['user'],
});
if (mediaUpload === undefined) {
throw new NotInDBError(
`MediaUpload with filename '${filename}' not found`,
);
}
return mediaUpload;
}
}

View file

@ -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;
}

View file

@ -1,6 +1,7 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service'; import { ConsoleLoggerService } from '../logger/console-logger.service';
import { Revision } from '../revisions/revision.entity'; import { Revision } from '../revisions/revision.entity';
import { RevisionsService } from '../revisions/revisions.service'; import { RevisionsService } from '../revisions/revisions.service';
@ -132,8 +133,19 @@ export class NotesService {
} }
async getNoteByIdOrAlias(noteIdOrAlias: string): Promise<Note> { async getNoteByIdOrAlias(noteIdOrAlias: string): Promise<Note> {
this.logger.debug(
`Trying to find note '${noteIdOrAlias}'`,
'getNoteByIdOrAlias',
);
const note = await this.noteRepository.findOne({ const note = await this.noteRepository.findOne({
where: [{ id: noteIdOrAlias }, { alias: noteIdOrAlias }], where: [
{
id: noteIdOrAlias,
},
{
alias: noteIdOrAlias,
},
],
relations: [ relations: [
'authorColors', 'authorColors',
'owner', 'owner',
@ -142,8 +154,9 @@ export class NotesService {
], ],
}); });
if (note === undefined) { if (note === undefined) {
//TODO: Improve error handling throw new NotInDBError(
throw new Error('Note not found'); `Note with id/alias '${noteIdOrAlias}' not found.`,
);
} }
return note; return note;
} }

View file

@ -48,4 +48,20 @@ export class User {
identity => identity.user, identity => identity.user,
) )
identities: Identity[]; 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;
}
} }

View file

@ -1,5 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { User } from './user.entity';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
describe('UsersService', () => { describe('UsersService', () => {
@ -7,9 +9,18 @@ describe('UsersService', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [UsersService], providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: {},
},
],
imports: [LoggerModule], imports: [LoggerModule],
}).compile(); })
.overrideProvider(getRepositoryToken(User))
.useValue({})
.compile();
service = module.get<UsersService>(UsersService); service = module.get<UsersService>(UsersService);
}); });

View file

@ -1,26 +1,44 @@
import { Injectable } from '@nestjs/common'; 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 { ConsoleLoggerService } from '../logger/console-logger.service';
import { UserInfoDto } from './user-info.dto'; import { UserInfoDto } from './user-info.dto';
import { User } from './user.entity'; import { User } from './user.entity';
@Injectable() @Injectable()
export class UsersService { export class UsersService {
constructor(private readonly logger: ConsoleLoggerService) { constructor(
private readonly logger: ConsoleLoggerService,
@InjectRepository(User) private userRepository: Repository<User>,
) {
this.logger.setContext(UsersService.name); this.logger.setContext(UsersService.name);
} }
getUserInfo(): UserInfoDto { createUser(userName: string, displayName: string): Promise<User> {
//TODO: Use the database const user = User.create(userName, displayName);
this.logger.warn('Using hardcoded data!'); return this.userRepository.save(user);
return {
displayName: 'foo',
userName: 'fooUser',
email: 'foo@example.com',
photo: '',
};
} }
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<User> {
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) { if (user.photo) {
return user.photo; return user.photo;
} else { } else {

View file

@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import * as request from 'supertest'; import * as request from 'supertest';
import { PublicApiModule } from '../../src/api/public/public-api.module'; import { PublicApiModule } from '../../src/api/public/public-api.module';
import { NotInDBError } from '../../src/errors/errors';
import { GroupsModule } from '../../src/groups/groups.module'; import { GroupsModule } from '../../src/groups/groups.module';
import { LoggerModule } from '../../src/logger/logger.module'; import { LoggerModule } from '../../src/logger/logger.module';
import { NotesModule } from '../../src/notes/notes.module'; import { NotesModule } from '../../src/notes/notes.module';
@ -82,7 +83,7 @@ describe('Notes', () => {
.delete('/notes/test3') .delete('/notes/test3')
.expect(200); .expect(200);
return expect(notesService.getNoteByIdOrAlias('test3')).rejects.toEqual( return expect(notesService.getNoteByIdOrAlias('test3')).rejects.toEqual(
Error('Note not found'), new NotInDBError("Note with id/alias 'test3' not found."),
); );
}); });

View file

@ -614,6 +614,13 @@
"@angular-devkit/schematics" "9.1.7" "@angular-devkit/schematics" "9.1.7"
fs-extra "9.0.0" 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": "@nestjs/swagger@^4.5.12":
version "4.5.12" version "4.5.12"
resolved "https://registry.yarnpkg.com/@nestjs/swagger/-/swagger-4.5.12.tgz#e8aa65fbb0033007ece1d494b002f47ff472c20b" resolved "https://registry.yarnpkg.com/@nestjs/swagger/-/swagger-4.5.12.tgz#e8aa65fbb0033007ece1d494b002f47ff472c20b"
@ -669,6 +676,11 @@
dependencies: dependencies:
"@sinonjs/commons" "^1.7.0" "@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@*": "@types/anymatch@*":
version "1.3.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" 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" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-0.0.31.tgz#bac8d8aab6a823e91deb7f79083b2a35fa638f33"
integrity sha512-LS1MCPaQKqspg7FvexuhmDbWUhE2yIJ+4AgVIyObfc06/UKZ8REgxGNjZc82wPLWmbeOm7S+gSsLgo75TanG4A== 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": "@types/eslint-visitor-keys@^1.0.0":
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" 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: dependencies:
flat-cache "^2.0.1" 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: file-uri-to-path@1.0.0:
version "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" 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: dependencies:
safer-buffer ">= 2.1.2 < 3" safer-buffer ">= 2.1.2 < 3"
ieee754@^1.1.4: ieee754@^1.1.13, ieee754@^1.1.4:
version "1.1.13" version "1.1.13"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
@ -5698,6 +5725,11 @@ pbkdf2@^3.0.3:
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
sha.js "^2.4.8" 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: performance-now@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" 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" isarray "0.0.1"
string_decoder "~0.10.x" 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: readdirp@^2.2.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" 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" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= 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: superagent@^3.8.3:
version "3.8.3" version "3.8.3"
resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128" 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" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== 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: tough-cookie@^2.3.3:
version "2.4.3" version "2.4.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"