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

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

View file

@ -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>(MediaController);
});

View file

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

View file

@ -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,

View file

@ -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: [],

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 { 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<Note> {
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;
}

View file

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

View file

@ -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>(UsersService);
});

View file

@ -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<User>,
) {
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<User> {
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<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) {
return user.photo;
} else {