mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-28 22:15:12 -04:00
fix(repository): Move backend code into subdirectory
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
86584e705f
commit
bf30cbcf48
272 changed files with 87 additions and 67 deletions
83
backend/src/media/backends/azure-backend.ts
Normal file
83
backend/src/media/backends/azure-backend.ts
Normal 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}`;
|
||||
}
|
||||
}
|
13
backend/src/media/backends/backend-type.enum.ts
Normal file
13
backend/src/media/backends/backend-type.enum.ts
Normal 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',
|
||||
}
|
85
backend/src/media/backends/filesystem-backend.ts
Normal file
85
backend/src/media/backends/filesystem-backend.ts
Normal 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}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
101
backend/src/media/backends/imgur-backend.ts
Normal file
101
backend/src/media/backends/imgur-backend.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
79
backend/src/media/backends/s3-backend.ts
Normal file
79
backend/src/media/backends/s3-backend.ts
Normal 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}`;
|
||||
}
|
||||
}
|
139
backend/src/media/backends/webdav-backend.ts
Normal file
139
backend/src/media/backends/webdav-backend.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
25
backend/src/media/media-backend.interface.ts
Normal file
25
backend/src/media/media-backend.interface.ts
Normal 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>;
|
||||
}
|
15
backend/src/media/media-upload-url.dto.ts
Normal file
15
backend/src/media/media-upload-url.dto.ts
Normal 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;
|
||||
}
|
46
backend/src/media/media-upload.dto.ts
Normal file
46
backend/src/media/media-upload.dto.ts
Normal 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;
|
||||
}
|
82
backend/src/media/media-upload.entity.ts
Normal file
82
backend/src/media/media-upload.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
39
backend/src/media/media.module.ts
Normal file
39
backend/src/media/media.module.ts
Normal 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 {}
|
391
backend/src/media/media.service.spec.ts
Normal file
391
backend/src/media/media.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
233
backend/src/media/media.service.ts
Normal file
233
backend/src/media/media.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
37
backend/src/media/multer-file.interface.ts
Normal file
37
backend/src/media/multer-file.interface.ts
Normal 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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue