From 6cc406281ca3d4435fd0b1dfe764ac7128c177cb Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Fri, 16 Apr 2021 13:22:03 +0200 Subject: [PATCH] MediaBackend: Add WebdavBackend Signed-off-by: Philip Molares --- src/media/backends/webdav-backend.ts | 139 +++++++++++++++++++++++++++ src/media/media.module.ts | 2 + src/media/media.service.ts | 5 + 3 files changed, 146 insertions(+) create mode 100644 src/media/backends/webdav-backend.ts diff --git a/src/media/backends/webdav-backend.ts b/src/media/backends/webdav-backend.ts new file mode 100644 index 000000000..5ac985201 --- /dev/null +++ b/src/media/backends/webdav-backend.ts @@ -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 mediaConfiguration from '../../config/media.config'; +import { ConsoleLoggerService } from '../../logger/console-logger.service'; +import { MediaBackend } from '../media-backend.interface'; +import { BackendData } from '../media-upload.entity'; +import { MediaConfig } from '../../config/media.config'; +import { MediaBackendError } from '../../errors/errors'; +import { BackendType } from './backend-type.enum'; +import fetch, { Response } from 'node-fetch'; + +@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 { + 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}`, 'saveFile'); + 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 { + 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); + } + } +} diff --git a/src/media/media.module.ts b/src/media/media.module.ts index eed3262da..8997ba75f 100644 --- a/src/media/media.module.ts +++ b/src/media/media.module.ts @@ -16,6 +16,7 @@ import { MediaService } from './media.service'; import { S3Backend } from './backends/s3-backend'; import { ImgurBackend } from './backends/imgur-backend'; import { AzureBackend } from './backends/azure-backend'; +import { WebdavBackend } from './backends/webdav-backend'; @Module({ imports: [ @@ -31,6 +32,7 @@ import { AzureBackend } from './backends/azure-backend'; AzureBackend, ImgurBackend, S3Backend, + WebdavBackend, ], exports: [MediaService], }) diff --git a/src/media/media.service.ts b/src/media/media.service.ts index 8a71f2678..7cb1b36d6 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -25,6 +25,7 @@ import { ImgurBackend } from './backends/imgur-backend'; import { User } from '../users/user.entity'; import { MediaUploadDto } from './media-upload.dto'; import { Note } from '../notes/note.entity'; +import { WebdavBackend } from './backends/webdav-backend'; @Injectable() export class MediaService { @@ -203,6 +204,8 @@ export class MediaService { return BackendType.IMGUR; case 's3': return BackendType.S3; + case 'webdav': + return BackendType.WEBDAV; } } @@ -216,6 +219,8 @@ export class MediaService { return this.moduleRef.get(AzureBackend); case BackendType.IMGUR: return this.moduleRef.get(ImgurBackend); + case BackendType.WEBDAV: + return this.moduleRef.get(WebdavBackend); } }