fix(repository): Move backend code into subdirectory

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-10-02 20:10:32 +02:00 committed by David Mehren
parent 86584e705f
commit bf30cbcf48
272 changed files with 87 additions and 67 deletions

View file

@ -0,0 +1,83 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
BlobServiceClient,
BlockBlobClient,
ContainerClient,
} from '@azure/storage-blob';
import { Inject, Injectable } from '@nestjs/common';
import mediaConfiguration, { MediaConfig } from '../../config/media.config';
import { MediaBackendError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface';
import { BackendData } from '../media-upload.entity';
import { BackendType } from './backend-type.enum';
@Injectable()
export class AzureBackend implements MediaBackend {
private config: MediaConfig['backend']['azure'];
private client: ContainerClient;
constructor(
private readonly logger: ConsoleLoggerService,
@Inject(mediaConfiguration.KEY)
private mediaConfig: MediaConfig,
) {
this.logger.setContext(AzureBackend.name);
this.config = mediaConfig.backend.azure;
if (mediaConfig.backend.use === BackendType.AZURE) {
// only create the client if the backend is configured to azure
const blobServiceClient = BlobServiceClient.fromConnectionString(
this.config.connectionString,
);
this.client = blobServiceClient.getContainerClient(this.config.container);
}
}
async saveFile(
buffer: Buffer,
fileName: string,
): Promise<[string, BackendData]> {
const blockBlobClient: BlockBlobClient =
this.client.getBlockBlobClient(fileName);
try {
await blockBlobClient.upload(buffer, buffer.length);
const url = this.getUrl(fileName);
this.logger.log(`Uploaded ${url}`, 'saveFile');
return [url, null];
} catch (e) {
this.logger.error(
`error: ${(e as Error).message}`,
(e as Error).stack,
'saveFile',
);
throw new MediaBackendError(`Could not save '${fileName}' on Azure`);
}
}
async deleteFile(fileName: string, _: BackendData): Promise<void> {
const blockBlobClient: BlockBlobClient =
this.client.getBlockBlobClient(fileName);
try {
await blockBlobClient.delete();
const url = this.getUrl(fileName);
this.logger.log(`Deleted ${url}`, 'deleteFile');
return;
} catch (e) {
this.logger.error(
`error: ${(e as Error).message}`,
(e as Error).stack,
'deleteFile',
);
throw new MediaBackendError(`Could not delete '${fileName}' on Azure`);
}
}
private getUrl(fileName: string): string {
return `${this.client.url}/${fileName}`;
}
}

View file

@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum BackendType {
FILESYSTEM = 'filesystem',
S3 = 's3',
IMGUR = 'imgur',
AZURE = 'azure',
WEBDAV = 'webdav',
}

View file

@ -0,0 +1,85 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { promises as fs } from 'fs';
import { join } from 'path';
import mediaConfiguration, { MediaConfig } from '../../config/media.config';
import { MediaBackendError } from '../../errors/errors';
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 {
uploadDirectory = './uploads';
constructor(
private readonly logger: ConsoleLoggerService,
@Inject(mediaConfiguration.KEY)
private mediaConfig: MediaConfig,
) {
this.logger.setContext(FilesystemBackend.name);
this.uploadDirectory = mediaConfig.backend.filesystem.uploadPath;
}
async saveFile(
buffer: Buffer,
fileName: string,
): Promise<[string, BackendData]> {
const filePath = this.getFilePath(fileName);
this.logger.debug(`Writing file to: ${filePath}`, 'saveFile');
await this.ensureDirectory();
try {
await fs.writeFile(filePath, buffer, null);
return ['/uploads/' + fileName, null];
} catch (e) {
this.logger.error((e as Error).message, (e as Error).stack, 'saveFile');
throw new MediaBackendError(`Could not save '${filePath}'`);
}
}
async deleteFile(fileName: string, _: BackendData): Promise<void> {
const filePath = this.getFilePath(fileName);
try {
return await fs.unlink(filePath);
} catch (e) {
this.logger.error((e as Error).message, (e as Error).stack, 'deleteFile');
throw new MediaBackendError(`Could not delete '${filePath}'`);
}
}
private getFilePath(fileName: string): string {
return join(this.uploadDirectory, fileName);
}
private async ensureDirectory(): Promise<void> {
this.logger.debug(
`Ensuring presence of directory at ${this.uploadDirectory}`,
'ensureDirectory',
);
try {
await fs.access(this.uploadDirectory);
} catch (e) {
try {
this.logger.debug(
`The directory '${this.uploadDirectory}' can't be accessed. Trying to create the directory`,
'ensureDirectory',
);
await fs.mkdir(this.uploadDirectory);
} catch (e) {
this.logger.error(
(e as Error).message,
(e as Error).stack,
'ensureDirectory',
);
throw new MediaBackendError(
`Could not create '${this.uploadDirectory}'`,
);
}
}
}
}

View file

@ -0,0 +1,101 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import fetch, { Response } from 'node-fetch';
import { URLSearchParams } from 'url';
import mediaConfiguration, { MediaConfig } from '../../config/media.config';
import { MediaBackendError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface';
import { BackendData } from '../media-upload.entity';
type UploadResult = {
data: {
link: string;
deletehash: string;
};
};
@Injectable()
export class ImgurBackend implements MediaBackend {
private config: MediaConfig['backend']['imgur'];
constructor(
private readonly logger: ConsoleLoggerService,
@Inject(mediaConfiguration.KEY)
private mediaConfig: MediaConfig,
) {
this.logger.setContext(ImgurBackend.name);
this.config = mediaConfig.backend.imgur;
}
async saveFile(
buffer: Buffer,
fileName: string,
): Promise<[string, BackendData]> {
const params = new URLSearchParams();
params.append('image', buffer.toString('base64'));
params.append('type', 'base64');
try {
const result = (await fetch('https://api.imgur.com/3/image', {
method: 'POST',
body: params,
// eslint-disable-next-line @typescript-eslint/naming-convention
headers: { Authorization: `Client-ID ${this.config.clientID}` },
})
.then((res) => ImgurBackend.checkStatus(res))
.then((res) => res.json())) as UploadResult;
this.logger.debug(`Response: ${JSON.stringify(result)}`, 'saveFile');
this.logger.log(`Uploaded ${fileName}`, 'saveFile');
return [result.data.link, result.data.deletehash];
} catch (e) {
this.logger.error(
`error: ${(e as Error).message}`,
(e as Error).stack,
'saveFile',
);
throw new MediaBackendError(`Could not save '${fileName}' on imgur`);
}
}
async deleteFile(fileName: string, backendData: BackendData): Promise<void> {
if (backendData === null) {
throw new MediaBackendError(
`We don't have any delete tokens for '${fileName}' and therefore can't delete this image on imgur`,
);
}
try {
const result = await fetch(
`https://api.imgur.com/3/image/${backendData}`,
{
method: 'POST',
// eslint-disable-next-line @typescript-eslint/naming-convention
headers: { Authorization: `Client-ID ${this.config.clientID}` },
},
).then((res) => ImgurBackend.checkStatus(res));
this.logger.debug(`Response: ${result.toString()}`, 'deleteFile');
this.logger.log(`Deleted ${fileName}`, 'deleteFile');
return;
} catch (e) {
this.logger.error(
`error: ${(e as Error).message}`,
(e as Error).stack,
'deleteFile',
);
throw new MediaBackendError(`Could not delete '${fileName}' on imgur`);
}
}
private static checkStatus(res: Response): Response {
if (res.ok) {
// res.status >= 200 && res.status < 300
return res;
} else {
throw new MediaBackendError(res.statusText);
}
}
}

View file

@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Client } from 'minio';
import { URL } from 'url';
import mediaConfiguration, { MediaConfig } from '../../config/media.config';
import { MediaBackendError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface';
import { BackendData } from '../media-upload.entity';
import { BackendType } from './backend-type.enum';
@Injectable()
export class S3Backend implements MediaBackend {
private config: MediaConfig['backend']['s3'];
private client: Client;
constructor(
private readonly logger: ConsoleLoggerService,
@Inject(mediaConfiguration.KEY)
private mediaConfig: MediaConfig,
) {
this.logger.setContext(S3Backend.name);
if (mediaConfig.backend.use === BackendType.S3) {
this.config = mediaConfig.backend.s3;
const url = new URL(this.config.endPoint);
const secure = url.protocol === 'https:'; // url.protocol contains a trailing ':'
const endpoint = `${url.hostname}${url.pathname}`;
let port = parseInt(url.port);
if (isNaN(port)) {
port = secure ? 443 : 80;
}
this.client = new Client({
endPoint: endpoint.substr(0, endpoint.length - 1), // remove trailing '/'
port: port,
useSSL: secure,
accessKey: this.config.accessKeyId,
secretKey: this.config.secretAccessKey,
});
}
}
async saveFile(
buffer: Buffer,
fileName: string,
): Promise<[string, BackendData]> {
try {
await this.client.putObject(this.config.bucket, fileName, buffer);
this.logger.log(`Uploaded file ${fileName}`, 'saveFile');
return [this.getUrl(fileName), null];
} catch (e) {
this.logger.error((e as Error).message, (e as Error).stack, 'saveFile');
throw new MediaBackendError(`Could not save '${fileName}' on S3`);
}
}
async deleteFile(fileName: string, _: BackendData): Promise<void> {
try {
await this.client.removeObject(this.config.bucket, fileName);
const url = this.getUrl(fileName);
this.logger.log(`Deleted ${url}`, 'deleteFile');
return;
} catch (e) {
this.logger.error((e as Error).message, (e as Error).stack, 'saveFile');
throw new MediaBackendError(`Could not delete '${fileName}' on S3`);
}
}
private getUrl(fileName: string): string {
const url = new URL(this.config.endPoint);
const port = url.port !== '' ? `:${url.port}` : '';
const bucket = this.config.bucket;
return `${url.protocol}//${url.hostname}${port}${url.pathname}${bucket}/${fileName}`;
}
}

View file

@ -0,0 +1,139 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import fetch, { Response } from 'node-fetch';
import { URL } from 'url';
import mediaConfiguration, { MediaConfig } from '../../config/media.config';
import { MediaBackendError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface';
import { BackendData } from '../media-upload.entity';
import { BackendType } from './backend-type.enum';
@Injectable()
export class WebdavBackend implements MediaBackend {
private config: MediaConfig['backend']['webdav'];
private authHeader: string;
private baseUrl: string;
constructor(
private readonly logger: ConsoleLoggerService,
@Inject(mediaConfiguration.KEY)
private mediaConfig: MediaConfig,
) {
this.logger.setContext(WebdavBackend.name);
if (mediaConfig.backend.use === BackendType.WEBDAV) {
this.config = mediaConfig.backend.webdav;
const url = new URL(this.config.connectionString);
const port = url.port !== '' ? `:${url.port}` : '';
this.baseUrl = `${url.protocol}//${url.hostname}${port}${url.pathname}`;
if (this.config.uploadDir && this.config.uploadDir !== '') {
this.baseUrl = WebdavBackend.joinURL(
this.baseUrl,
this.config.uploadDir,
);
}
this.authHeader = WebdavBackend.generateBasicAuthHeader(
url.username,
url.password,
);
fetch(this.baseUrl, {
method: 'PROPFIND',
headers: {
Accept: 'text/plain', // eslint-disable-line @typescript-eslint/naming-convention
Authorization: this.authHeader, // eslint-disable-line @typescript-eslint/naming-convention
Depth: '0', // eslint-disable-line @typescript-eslint/naming-convention
},
})
.then((response) => {
if (!response.ok) {
throw new Error(`Can't access ${this.baseUrl}`);
}
})
.catch(() => {
throw new Error(`Can't access ${this.baseUrl}`);
});
}
}
async saveFile(
buffer: Buffer,
fileName: string,
): Promise<[string, BackendData]> {
try {
const contentLength = buffer.length;
await fetch(WebdavBackend.joinURL(this.baseUrl, '/', fileName), {
method: 'PUT',
body: buffer,
headers: {
Authorization: this.authHeader, // eslint-disable-line @typescript-eslint/naming-convention
'Content-Type': 'application/octet-stream', // eslint-disable-line @typescript-eslint/naming-convention
'Content-Length': `${contentLength}`, // eslint-disable-line @typescript-eslint/naming-convention
// eslint-disable-next-line @typescript-eslint/naming-convention
'If-None-Match': '*', // Don't overwrite already existing files
},
}).then((res) => WebdavBackend.checkStatus(res));
this.logger.log(`Uploaded file ${fileName}`, 'saveFile');
return [this.getUrl(fileName), null];
} catch (e) {
this.logger.error((e as Error).message, (e as Error).stack, 'saveFile');
throw new MediaBackendError(`Could not save '${fileName}' on WebDav`);
}
}
async deleteFile(fileName: string, _: BackendData): Promise<void> {
try {
await fetch(WebdavBackend.joinURL(this.baseUrl, '/', fileName), {
method: 'DELETE',
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Authorization: this.authHeader,
},
}).then((res) => WebdavBackend.checkStatus(res));
const url = this.getUrl(fileName);
this.logger.log(`Deleted ${url}`, 'deleteFile');
return;
} catch (e) {
this.logger.error((e as Error).message, (e as Error).stack, 'saveFile');
throw new MediaBackendError(`Could not delete '${fileName}' on WebDav`);
}
}
private getUrl(fileName: string): string {
return WebdavBackend.joinURL(this.config.publicUrl, '/', fileName);
}
private static generateBasicAuthHeader(
username: string,
password: string,
): string {
const encoded = Buffer.from(`${username}:${password}`).toString('base64');
return `Basic ${encoded}`;
}
private static joinURL(...urlParts: Array<string>): string {
return urlParts.reduce((output, next, index) => {
if (
index === 0 ||
next !== '/' ||
(next === '/' && output[output.length - 1] !== '/')
) {
output += next;
}
return output;
}, '');
}
private static checkStatus(res: Response): Response {
if (res.ok) {
// res.status >= 200 && res.status < 300
return res;
} else {
throw new MediaBackendError(res.statusText);
}
}
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
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.
* @throws {MediaBackendError} - there was an error saving the file
* @return Tuple of file URL and internal backend data, which should be saved.
*/
saveFile(buffer: Buffer, fileName: string): Promise<[string, BackendData]>;
/**
* Delete a file from the backend
* @param fileName String to identify the file
* @param backendData Internal backend data
* @throws {MediaBackendError} - there was an error deleting the file
*/
deleteFile(fileName: string, backendData: BackendData): Promise<void>;
}

View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { BaseDto } from '../utils/base.dto.';
export class MediaUploadUrlDto extends BaseDto {
@IsString()
@ApiProperty()
link: string;
}

View file

@ -0,0 +1,46 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDate, IsOptional, IsString } from 'class-validator';
import { BaseDto } from '../utils/base.dto.';
export class MediaUploadDto extends BaseDto {
/**
* The link to the media file.
* @example "https://example.com/uploads/testfile123.jpg"
*/
@IsString()
@ApiProperty()
url: string;
/**
* The publicId of the note to which the uploaded file is linked to.
* @example "noteId" TODO how looks a note id?
*/
@IsString()
@IsOptional()
@ApiProperty()
notePublicId: string | null;
/**
* The date when the upload objects was created.
* @example "2020-12-01 12:23:34"
*/
@IsDate()
@Type(() => Date)
@ApiProperty()
createdAt: Date;
/**
* The username of the user which uploaded the media file.
* @example "testuser5"
*/
@IsString()
@ApiProperty()
username: string;
}

View file

@ -0,0 +1,82 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
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, (note) => note.mediaUploads, {
nullable: true,
})
note: Promise<Note | null>;
@ManyToOne((_) => User, (user) => user.mediaUploads, {
nullable: false,
})
user: Promise<User>;
@Column({
nullable: false,
})
backendType: string;
@Column()
fileUrl: string;
@Column({
nullable: true,
type: 'text',
})
backendData: BackendData | null;
@CreateDateColumn()
createdAt: Date;
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
/**
* Create a new media upload enity
* @param id the id of the upload
* @param note the note the upload should be associated with. This is required despite the fact the note field is optional, because it's possible to delete a note without also deleting the associated media uploads, but a note is required for the initial creation.
* @param user the user that owns the upload
* @param extension which file extension the upload has
* @param backendType on which type of media backend the upload is saved
* @param backendData the backend data returned by the media backend
* @param fileUrl the url where the upload can be accessed
*/
public static create(
id: string,
note: Note,
user: User,
extension: string,
backendType: BackendType,
fileUrl: string,
): Omit<MediaUpload, 'createdAt'> {
const upload = new MediaUpload();
upload.id = id;
upload.note = Promise.resolve(note);
upload.user = Promise.resolve(user);
upload.backendType = backendType;
upload.backendData = null;
upload.fileUrl = fileUrl;
return upload;
}
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggerModule } from '../logger/logger.module';
import { NotesModule } from '../notes/notes.module';
import { UsersModule } from '../users/users.module';
import { AzureBackend } from './backends/azure-backend';
import { FilesystemBackend } from './backends/filesystem-backend';
import { ImgurBackend } from './backends/imgur-backend';
import { S3Backend } from './backends/s3-backend';
import { WebdavBackend } from './backends/webdav-backend';
import { MediaUpload } from './media-upload.entity';
import { MediaService } from './media.service';
@Module({
imports: [
TypeOrmModule.forFeature([MediaUpload]),
NotesModule,
UsersModule,
LoggerModule,
ConfigModule,
],
providers: [
MediaService,
FilesystemBackend,
AzureBackend,
ImgurBackend,
S3Backend,
WebdavBackend,
],
exports: [MediaService],
})
export class MediaModule {}

View file

@ -0,0 +1,391 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { promises as fs } from 'fs';
import { Repository } from 'typeorm';
import appConfigMock from '../../src/config/mock/app.config.mock';
import { AuthToken } from '../auth/auth-token.entity';
import { Author } from '../authors/author.entity';
import authConfigMock from '../config/mock/auth.config.mock';
import databaseConfigMock from '../config/mock/database.config.mock';
import mediaConfigMock from '../config/mock/media.config.mock';
import noteConfigMock from '../config/mock/note.config.mock';
import { ClientError, NotInDBError } from '../errors/errors';
import { eventModuleConfig } from '../events';
import { Group } from '../groups/group.entity';
import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module';
import { Alias } from '../notes/alias.entity';
import { Note } from '../notes/note.entity';
import { NotesModule } from '../notes/notes.module';
import { Tag } from '../notes/tag.entity';
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
import { Edit } from '../revisions/edit.entity';
import { Revision } from '../revisions/revision.entity';
import { Session } from '../users/session.entity';
import { User } from '../users/user.entity';
import { UsersModule } from '../users/users.module';
import { BackendType } from './backends/backend-type.enum';
import { FilesystemBackend } from './backends/filesystem-backend';
import { BackendData, MediaUpload } from './media-upload.entity';
import { MediaService } from './media.service';
describe('MediaService', () => {
let service: MediaService;
let noteRepo: Repository<Note>;
let userRepo: Repository<User>;
let mediaRepo: Repository<MediaUpload>;
class CreateQueryBuilderClass {
leftJoinAndSelect: () => CreateQueryBuilderClass;
where: () => CreateQueryBuilderClass;
orWhere: () => CreateQueryBuilderClass;
setParameter: () => CreateQueryBuilderClass;
getOne: () => MediaUpload;
getMany: () => MediaUpload[];
}
let createQueryBuilderFunc: CreateQueryBuilderClass;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MediaService,
{
provide: getRepositoryToken(MediaUpload),
useClass: Repository,
},
FilesystemBackend,
],
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [
mediaConfigMock,
appConfigMock,
databaseConfigMock,
authConfigMock,
noteConfigMock,
],
}),
LoggerModule,
NotesModule,
UsersModule,
EventEmitterModule.forRoot(eventModuleConfig),
],
})
.overrideProvider(getRepositoryToken(Edit))
.useValue({})
.overrideProvider(getRepositoryToken(AuthToken))
.useValue({})
.overrideProvider(getRepositoryToken(Identity))
.useValue({})
.overrideProvider(getRepositoryToken(Note))
.useClass(Repository)
.overrideProvider(getRepositoryToken(Revision))
.useValue({})
.overrideProvider(getRepositoryToken(User))
.useClass(Repository)
.overrideProvider(getRepositoryToken(Tag))
.useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission))
.useValue({})
.overrideProvider(getRepositoryToken(NoteUserPermission))
.useValue({})
.overrideProvider(getRepositoryToken(MediaUpload))
.useClass(Repository)
.overrideProvider(getRepositoryToken(Group))
.useValue({})
.overrideProvider(getRepositoryToken(Session))
.useValue({})
.overrideProvider(getRepositoryToken(Author))
.useValue({})
.overrideProvider(getRepositoryToken(Alias))
.useValue({})
.compile();
service = module.get<MediaService>(MediaService);
noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note));
userRepo = module.get<Repository<User>>(getRepositoryToken(User));
mediaRepo = module.get<Repository<MediaUpload>>(
getRepositoryToken(MediaUpload),
);
const user = User.create('test123', 'Test 123') as User;
const note = Note.create(user) as Note;
const mediaUpload = MediaUpload.create(
'test',
note,
user,
'.jpg',
BackendType.FILESYSTEM,
'test/test',
) as MediaUpload;
const createQueryBuilder = {
leftJoinAndSelect: () => createQueryBuilder,
where: () => createQueryBuilder,
orWhere: () => createQueryBuilder,
setParameter: () => createQueryBuilder,
getOne: () => mediaUpload,
getMany: () => [mediaUpload],
};
createQueryBuilderFunc = createQueryBuilder;
jest
.spyOn(mediaRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('saveFile', () => {
let user: User;
let note: Note;
beforeEach(() => {
user = User.create('hardcoded', 'Testy') as User;
const alias = 'alias';
note = Note.create(user, alias) as Note;
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user);
const createQueryBuilder = {
leftJoinAndSelect: () => createQueryBuilder,
where: () => createQueryBuilder,
orWhere: () => createQueryBuilder,
setParameter: () => createQueryBuilder,
getOne: () => note,
};
jest
.spyOn(noteRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
});
it('works', async () => {
const testImage = await fs.readFile('test/public-api/fixtures/test.png');
let fileId = '';
jest
.spyOn(mediaRepo, 'save')
.mockImplementationOnce(async (entry: MediaUpload) => {
fileId = entry.id;
return entry;
});
jest
.spyOn(service.mediaBackend, 'saveFile')
.mockImplementationOnce(
async (
buffer: Buffer,
fileName: string,
): Promise<[string, BackendData]> => {
expect(buffer).toEqual(testImage);
return [fileName, null];
},
);
const upload = await service.saveFile(testImage, user, note);
expect(upload.fileUrl).toEqual(fileId);
});
describe('fails:', () => {
it('MIME type not identifiable', async () => {
await expect(
service.saveFile(Buffer.alloc(1), user, note),
).rejects.toThrow(ClientError);
});
it('MIME type not supported', async () => {
const testText = await fs.readFile('test/public-api/fixtures/test.zip');
await expect(service.saveFile(testText, user, note)).rejects.toThrow(
ClientError,
);
});
});
});
describe('deleteFile', () => {
it('works', async () => {
const mockMediaUploadEntry = {
id: 'testMediaUpload',
backendData: 'testBackendData',
user: Promise.resolve({
username: 'hardcoded',
} as User),
} as MediaUpload;
jest
.spyOn(service.mediaBackend, 'deleteFile')
.mockImplementationOnce(
async (fileName: string, backendData: BackendData): Promise<void> => {
expect(fileName).toEqual(mockMediaUploadEntry.id);
expect(backendData).toEqual(mockMediaUploadEntry.backendData);
},
);
jest
.spyOn(mediaRepo, 'remove')
.mockImplementationOnce(async (entry, _) => {
expect(entry).toEqual(mockMediaUploadEntry);
return entry;
});
await service.deleteFile(mockMediaUploadEntry);
});
});
describe('findUploadByFilename', () => {
it('works', async () => {
const testFileName = 'testFilename';
const username = 'hardcoded';
const backendData = 'testBackendData';
const mockMediaUploadEntry = {
id: 'testMediaUpload',
backendData: backendData,
user: Promise.resolve({
username: username,
} as User),
} as MediaUpload;
jest
.spyOn(mediaRepo, 'findOne')
.mockResolvedValueOnce(mockMediaUploadEntry);
const mediaUpload = await service.findUploadByFilename(testFileName);
expect((await mediaUpload.user).username).toEqual(username);
expect(mediaUpload.backendData).toEqual(backendData);
});
it("fails: can't find mediaUpload", async () => {
const testFileName = 'testFilename';
jest.spyOn(mediaRepo, 'findOne').mockResolvedValueOnce(null);
await expect(service.findUploadByFilename(testFileName)).rejects.toThrow(
NotInDBError,
);
});
});
describe('listUploadsByUser', () => {
describe('works', () => {
const username = 'hardcoded';
it('with one upload from user', async () => {
const mockMediaUploadEntry = {
id: 'testMediaUpload',
backendData: 'testBackendData',
user: Promise.resolve({
username: username,
} as User),
} as MediaUpload;
createQueryBuilderFunc.getMany = () => [mockMediaUploadEntry];
expect(
await service.listUploadsByUser({ username: 'hardcoded' } as User),
).toEqual([mockMediaUploadEntry]);
});
it('without uploads from user', async () => {
createQueryBuilderFunc.getMany = () => [];
const mediaList = await service.listUploadsByUser({
username: username,
} as User);
expect(mediaList).toEqual([]);
});
it('with error (null as return value of find)', async () => {
createQueryBuilderFunc.getMany = () => [];
const mediaList = await service.listUploadsByUser({
username: username,
} as User);
expect(mediaList).toEqual([]);
});
});
});
describe('listUploadsByNote', () => {
describe('works', () => {
it('with one upload to note', async () => {
const mockMediaUploadEntry = {
id: 'testMediaUpload',
backendData: 'testBackendData',
note: Promise.resolve({
id: 123,
} as Note),
} as MediaUpload;
const createQueryBuilder = {
where: () => createQueryBuilder,
getMany: async () => {
return [mockMediaUploadEntry];
},
};
jest
.spyOn(mediaRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
const mediaList = await service.listUploadsByNote({
id: 123,
} as Note);
expect(mediaList).toEqual([mockMediaUploadEntry]);
});
it('without uploads to note', async () => {
const createQueryBuilder = {
where: () => createQueryBuilder,
getMany: async () => {
return [];
},
};
jest
.spyOn(mediaRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
const mediaList = await service.listUploadsByNote({
id: 123,
} as Note);
expect(mediaList).toEqual([]);
});
it('with error (null as return value of find)', async () => {
const createQueryBuilder = {
where: () => createQueryBuilder,
getMany: async () => {
return null;
},
};
jest
.spyOn(mediaRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
const mediaList = await service.listUploadsByNote({
id: 123,
} as Note);
expect(mediaList).toEqual([]);
});
});
});
describe('removeNoteFromMediaUpload', () => {
it('works', async () => {
const mockNote = {} as Note;
mockNote.aliases = Promise.resolve([
Alias.create('test', mockNote, true) as Alias,
]);
const mockMediaUploadEntry = {
id: 'testMediaUpload',
backendData: 'testBackendData',
note: Promise.resolve(mockNote),
user: Promise.resolve({
username: 'hardcoded',
} as User),
} as MediaUpload;
jest
.spyOn(mediaRepo, 'save')
.mockImplementationOnce(async (entry: MediaUpload) => {
expect(await entry.note).toBeNull();
return entry;
});
await service.removeNoteFromMediaUpload(mockMediaUploadEntry);
expect(mediaRepo.save).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,233 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import * as FileType from 'file-type';
import { Repository } from 'typeorm';
import mediaConfiguration, { MediaConfig } from '../config/media.config';
import { ClientError, NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { Note } from '../notes/note.entity';
import { NotesService } from '../notes/notes.service';
import { User } from '../users/user.entity';
import { UsersService } from '../users/users.service';
import { AzureBackend } from './backends/azure-backend';
import { BackendType } from './backends/backend-type.enum';
import { FilesystemBackend } from './backends/filesystem-backend';
import { ImgurBackend } from './backends/imgur-backend';
import { S3Backend } from './backends/s3-backend';
import { WebdavBackend } from './backends/webdav-backend';
import { MediaBackend } from './media-backend.interface';
import { MediaUploadDto } from './media-upload.dto';
import { MediaUpload } from './media-upload.entity';
@Injectable()
export class MediaService {
mediaBackend: MediaBackend;
mediaBackendType: BackendType;
constructor(
private readonly logger: ConsoleLoggerService,
@InjectRepository(MediaUpload)
private mediaUploadRepository: Repository<MediaUpload>,
private notesService: NotesService,
private usersService: UsersService,
private moduleRef: ModuleRef,
@Inject(mediaConfiguration.KEY)
private mediaConfig: MediaConfig,
) {
this.logger.setContext(MediaService.name);
this.mediaBackendType = this.chooseBackendType();
this.mediaBackend = this.getBackendFromType(this.mediaBackendType);
}
private static isAllowedMimeType(mimeType: string): boolean {
const allowedTypes = [
'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);
}
/**
* @async
* Save the given buffer to the configured MediaBackend and create a MediaUploadEntity to track where the file is, who uploaded it and to which note.
* @param {Buffer} fileBuffer - the buffer of the file to save.
* @param {User} user - the user who uploaded this file
* @param {Note} note - the note which will be associated with the new file.
* @return {string} the url of the saved file
* @throws {ClientError} the MIME type of the file is not supported.
* @throws {NotInDBError} - the note or user is not in the database
* @throws {MediaBackendError} - there was an error saving the file
*/
async saveFile(
fileBuffer: Buffer,
user: User,
note: Note,
): Promise<MediaUpload> {
this.logger.debug(
`Saving file for note '${note.id}' and user '${user.username}'`,
'saveFile',
);
const fileTypeResult = await FileType.fromBuffer(fileBuffer);
if (!fileTypeResult) {
throw new ClientError('Could not detect file type.');
}
if (!MediaService.isAllowedMimeType(fileTypeResult.mime)) {
throw new ClientError('MIME type not allowed.');
}
const randomBytes = crypto.randomBytes(16);
const id = randomBytes.toString('hex') + '.' + fileTypeResult.ext;
this.logger.debug(`Generated filename: '${id}'`, 'saveFile');
const [url, backendData] = await this.mediaBackend.saveFile(fileBuffer, id);
const mediaUpload = MediaUpload.create(
id,
note,
user,
fileTypeResult.ext,
this.mediaBackendType,
url,
);
mediaUpload.backendData = backendData;
return await this.mediaUploadRepository.save(mediaUpload);
}
/**
* @async
* Try to delete the specified file.
* @param {MediaUpload} mediaUpload - the name of the file to delete.
* @throws {MediaBackendError} - there was an error deleting the file
*/
async deleteFile(mediaUpload: MediaUpload): Promise<void> {
await this.mediaBackend.deleteFile(mediaUpload.id, mediaUpload.backendData);
await this.mediaUploadRepository.remove(mediaUpload);
}
/**
* @async
* Find a file entry by its filename.
* @param {string} filename - the name of the file entry to find
* @return {MediaUpload} the file entry, that was searched for
* @throws {NotInDBError} - the file entry specified is not in the database
* @throws {MediaBackendError} - there was an error retrieving the url
*/
async findUploadByFilename(filename: string): Promise<MediaUpload> {
const mediaUpload = await this.mediaUploadRepository.findOne({
where: { id: filename },
relations: ['user'],
});
if (mediaUpload === null) {
throw new NotInDBError(
`MediaUpload with filename '${filename}' not found`,
);
}
return mediaUpload;
}
/**
* @async
* List all uploads by a specific user
* @param {User} user - the specific user
* @return {MediaUpload[]} arary of media uploads owned by the user
*/
async listUploadsByUser(user: User): Promise<MediaUpload[]> {
const mediaUploads = await this.mediaUploadRepository
.createQueryBuilder('media')
.where('media.userId = :userId', { userId: user.id })
.getMany();
if (mediaUploads === null) {
return [];
}
return mediaUploads;
}
/**
* @async
* List all uploads by a specific note
* @param {Note} note - the specific user
* @return {MediaUpload[]} arary of media uploads owned by the user
*/
async listUploadsByNote(note: Note): Promise<MediaUpload[]> {
const mediaUploads = await this.mediaUploadRepository
.createQueryBuilder('upload')
.where('upload.note = :note', { note: note.id })
.getMany();
if (mediaUploads === null) {
return [];
}
return mediaUploads;
}
/**
* @async
* Set the note of a mediaUpload to null
* @param {MediaUpload} mediaUpload - the media upload to be changed
*/
async removeNoteFromMediaUpload(mediaUpload: MediaUpload): Promise<void> {
this.logger.debug(
'Setting note to null for mediaUpload: ' + mediaUpload.id,
'removeNoteFromMediaUpload',
);
mediaUpload.note = Promise.resolve(null);
await this.mediaUploadRepository.save(mediaUpload);
}
private chooseBackendType(): BackendType {
switch (this.mediaConfig.backend.use) {
case 'filesystem':
return BackendType.FILESYSTEM;
case 'azure':
return BackendType.AZURE;
case 'imgur':
return BackendType.IMGUR;
case 's3':
return BackendType.S3;
case 'webdav':
return BackendType.WEBDAV;
default:
throw new Error(
`Unexpected media backend ${this.mediaConfig.backend.use}`,
);
}
}
private getBackendFromType(type: BackendType): MediaBackend {
switch (type) {
case BackendType.FILESYSTEM:
return this.moduleRef.get(FilesystemBackend);
case BackendType.S3:
return this.moduleRef.get(S3Backend);
case BackendType.AZURE:
return this.moduleRef.get(AzureBackend);
case BackendType.IMGUR:
return this.moduleRef.get(ImgurBackend);
case BackendType.WEBDAV:
return this.moduleRef.get(WebdavBackend);
}
}
async toMediaUploadDto(mediaUpload: MediaUpload): Promise<MediaUploadDto> {
return {
url: mediaUpload.fileUrl,
notePublicId: (await mediaUpload.note)?.publicId ?? null,
createdAt: mediaUpload.createdAt,
username: (await mediaUpload.user).username,
};
}
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
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;
}