From 6c1cda2c9a25be85548ec8d8626ceac957819866 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Fri, 16 Apr 2021 13:19:13 +0200 Subject: [PATCH 1/3] Config: Add WebDAV to media config Signed-off-by: Philip Molares --- src/config/media.config.ts | 26 +++++++++++++++++++++++++ src/media/backends/backend-type.enum.ts | 1 + 2 files changed, 27 insertions(+) diff --git a/src/config/media.config.ts b/src/config/media.config.ts index 52065c782..301834abe 100644 --- a/src/config/media.config.ts +++ b/src/config/media.config.ts @@ -28,6 +28,11 @@ export interface MediaConfig { imgur: { clientID: string; }; + webdav: { + connectionString: string; + uploadDir: string; + publicUrl: string; + }; }; } @@ -70,6 +75,21 @@ const mediaSchema = Joi.object({ }), otherwise: Joi.optional(), }), + webdav: Joi.when('use', { + is: Joi.valid(BackendType.WEBDAV), + then: Joi.object({ + connectionString: Joi.string() + .uri() + .label('HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING'), + uploadDir: Joi.string() + .optional() + .label('HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR'), + publicUrl: Joi.string() + .uri() + .label('HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL'), + }), + otherwise: Joi.optional(), + }), }, }); @@ -95,6 +115,12 @@ export default registerAs('mediaConfig', () => { imgur: { clientID: process.env.HD_MEDIA_BACKEND_IMGUR_CLIENT_ID, }, + webdav: { + connectionString: + process.env.HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING, + uploadDir: process.env.HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR, + publicUrl: process.env.HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL, + }, }, }, { diff --git a/src/media/backends/backend-type.enum.ts b/src/media/backends/backend-type.enum.ts index 90407e2c9..c35d19596 100644 --- a/src/media/backends/backend-type.enum.ts +++ b/src/media/backends/backend-type.enum.ts @@ -9,4 +9,5 @@ export enum BackendType { S3 = 's3', IMGUR = 'imgur', AZURE = 'azure', + WEBDAV = 'webdav', } From 6cc406281ca3d4435fd0b1dfe764ac7128c177cb Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Fri, 16 Apr 2021 13:22:03 +0200 Subject: [PATCH 2/3] 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); } } From 2d86b149a07d318895b04ada4d2ae7017b909b8c Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Fri, 16 Apr 2021 13:23:09 +0200 Subject: [PATCH 3/3] Documentation: Add WebDAV media guide This explains how to use the WebDAV media backend both in general and with a NextCloud in particular. Signed-off-by: Philip Molares --- docs/content/media/webdav.md | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/content/media/webdav.md diff --git a/docs/content/media/webdav.md b/docs/content/media/webdav.md new file mode 100644 index 000000000..706067ce2 --- /dev/null +++ b/docs/content/media/webdav.md @@ -0,0 +1,46 @@ +# WebDAV + +You can use any [WebDAV](https://en.wikipedia.org/wiki/WebDAV) server to handle your image uploads in HedgeDoc. + +The WebDAV server must host the files in a way that allows HedgeDoc to request and receive them. + +You just add the following lines to your configuration: +(with the appropriate substitution for ``, ``, and `` of course) +``` +HD_MEDIA_BACKEND="webdav" +HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING="" +HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR="" +HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL="" +``` + +The `` should include the username and password (if needed) in the familiar way of `schema://user:password@url`. + +With `` you can specify a folder you want to upload to, but you can also omit this (just don't spcify this value at all), if you prefer to upload directly to the root of the WebDAV server. + +Finally, `` specifies with which url HedgeDoc can access the upload. For this purpose the filename will be appended to ``. So the file `test.png` with `` `https://dav.example.com` should be accessible via `https://dav.example.com/test.png`. + +## Using Nextcloud + +If you want to use Nextcloud as a WebDAV server, follow the following instructions: + +This guide was written using Nextcloud 21 in April 2021. + +Because the username and app password will be included in the config, we suggest using a dedicated Nextcloud user for the uploads. + +In this example the username will be `TestUser`. + +1. Create an app password by going to `Settings` > `Security`. Nextcloud will generate a password for you. Let's assume it's `passw0rd`. +2. In the Files app [create a new folder](https://docs.nextcloud.com/server/latest/user_manual/en/files/access_webgui.html#creating-or-uploading-files-and-directories) that will hold your uploads (e.g `HedgeDoc`). +3. [Share](https://docs.nextcloud.com/server/latest/user_manual/en/files/sharing.html#public-link-shares) the newly created folder. The folder should (per default) be configured with the option `Read Only` (which we will assume in this guide), but `Allow upload and editing` should be fine, too. +4. Get the public link of the share. It should be in your clipboard after creation. If not you can copy it by clicking the clipboard icon at the end of the line of `Share link`. We'll assume it is `https://cloud.example.com/s/some-id` in the following. +5. Append `/download?path=%2F&files=` to this URL. To continue with our example the url should now be `https://cloud.example.com/s/some-id/download?path=%2F&files=`. +6. Get the [WebDAV url of you Nextcloud server](https://docs.nextcloud.com/server/latest/user_manual/en/files/access_webdav.html). It should be located in the Files app in the bottom left corner under `Settings` > `WebDAV`. We'll assume it is `https://cloud.example.com/remote.php/dav/files/TestUser/` in the following. +7. Add your login information to the link. This is done by adding `username:password@` in between the url schema (typically `https://`) and the rest of the url (`cloud.example.com/remote.php/dav/files/TestUser/` in our example). The WebDAV url in our example should now look like this `https://TestUser:passw0rd@cloud.example.com/remote.php/dav/files/TestUser/`. +8. Configure HedgeDoc: +``` +HD_MEDIA_BACKEND="webdav" +HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING="https://TestUser:passw0rd@cloud.example.com/remote.php/dav/files/TestUser/" +HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR="HedgeDoc" +HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL="https://cloud.example.com/s/some-id/download?path=%2F&files=" +``` +Start using image uploads backed by Nextclouds WebDAV server.