From 942cb44e05c3e27c2e3ac1bd72a8f9abd80ce138 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 1 Mar 2021 20:52:46 +0100 Subject: [PATCH 01/10] Utils: Extract getServerVersionFromPackageJson into own file We need this function in at least on other part of the application so extracting it into an util file was only logical. Signed-off-by: Philip Molares --- src/monitoring/monitoring.service.ts | 30 ++---------------------- src/utils/serverVersion.ts | 34 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 28 deletions(-) create mode 100644 src/utils/serverVersion.ts diff --git a/src/monitoring/monitoring.service.ts b/src/monitoring/monitoring.service.ts index a410004a1..190aef2ed 100644 --- a/src/monitoring/monitoring.service.ts +++ b/src/monitoring/monitoring.service.ts @@ -5,34 +5,8 @@ */ import { Injectable } from '@nestjs/common'; -import { promises as fs } from 'fs'; -import { join as joinPath } from 'path'; -import { ServerStatusDto, ServerVersion } from './server-status.dto'; - -let versionCache: ServerVersion; - -async function getServerVersionFromPackageJson(): Promise { - if (versionCache === null) { - const rawFileContent: string = await fs.readFile( - joinPath(__dirname, '../../package.json'), - { encoding: 'utf8' }, - ); - // TODO: Should this be validated in more detail? - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const packageInfo: { version: string } = JSON.parse(rawFileContent); - const versionParts: number[] = packageInfo.version - .split('.') - .map((x) => parseInt(x, 10)); - versionCache = { - major: versionParts[0], - minor: versionParts[1], - patch: versionParts[2], - preRelease: 'dev', // TODO: Replace this? - }; - } - - return versionCache; -} +import { ServerStatusDto } from './server-status.dto'; +import { getServerVersionFromPackageJson } from '../utils/serverVersion'; @Injectable() export class MonitoringService { diff --git a/src/utils/serverVersion.ts b/src/utils/serverVersion.ts new file mode 100644 index 000000000..21a553cc2 --- /dev/null +++ b/src/utils/serverVersion.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ServerVersion } from '../monitoring/server-status.dto'; +import { promises as fs } from 'fs'; +import { join as joinPath } from 'path'; + +let versionCache: ServerVersion; + +export async function getServerVersionFromPackageJson(): Promise { + if (versionCache === null) { + const rawFileContent: string = await fs.readFile( + joinPath(__dirname, '../../package.json'), + { encoding: 'utf8' }, + ); + // TODO: Should this be validated in more detail? + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const packageInfo: { version: string } = JSON.parse(rawFileContent); + const versionParts: number[] = packageInfo.version + .split('.') + .map((x) => parseInt(x, 10)); + versionCache = { + major: versionParts[0], + minor: versionParts[1], + patch: versionParts[2], + preRelease: 'dev', // TODO: Replace this? + }; + } + + return versionCache; +} From 381718f0ebc115ee6c5a915ad200cd58d5b685d9 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 1 Mar 2021 21:12:01 +0100 Subject: [PATCH 02/10] Config: Move config mocks in own folder To clean up the config folder, all mocks are now in it's own folder. Signed-off-by: Philip Molares --- src/api/private/me/history/history.controller.spec.ts | 2 +- src/api/public/me/me.controller.spec.ts | 10 +++------- src/api/public/media/media.controller.spec.ts | 4 ++-- src/api/public/notes/notes.controller.spec.ts | 4 ++-- src/config/{ => mock}/app.config.mock.ts | 0 src/config/{ => mock}/media.config.mock.ts | 0 src/history/history.service.spec.ts | 2 +- src/media/media.service.spec.ts | 4 ++-- src/notes/notes.service.spec.ts | 2 +- src/permissions/permissions.service.spec.ts | 2 +- src/revisions/revisions.service.spec.ts | 2 +- test/private-api/history.e2e-spec.ts | 4 ++-- test/public-api/me.e2e-spec.ts | 4 ++-- test/public-api/media.e2e-spec.ts | 4 ++-- test/public-api/notes.e2e-spec.ts | 4 ++-- 15 files changed, 22 insertions(+), 26 deletions(-) rename src/config/{ => mock}/app.config.mock.ts (100%) rename src/config/{ => mock}/media.config.mock.ts (100%) diff --git a/src/api/private/me/history/history.controller.spec.ts b/src/api/private/me/history/history.controller.spec.ts index f52891cae..caf73a292 100644 --- a/src/api/private/me/history/history.controller.spec.ts +++ b/src/api/private/me/history/history.controller.spec.ts @@ -24,7 +24,7 @@ import { NoteGroupPermission } from '../../../../permissions/note-group-permissi import { NoteUserPermission } from '../../../../permissions/note-user-permission.entity'; import { Group } from '../../../../groups/group.entity'; import { ConfigModule } from '@nestjs/config'; -import appConfigMock from '../../../../config/app.config.mock'; +import appConfigMock from '../../../../config/mock/app.config.mock'; describe('HistoryController', () => { let controller: HistoryController; diff --git a/src/api/public/me/me.controller.spec.ts b/src/api/public/me/me.controller.spec.ts index e9e10a1e2..0274ee1d8 100644 --- a/src/api/public/me/me.controller.spec.ts +++ b/src/api/public/me/me.controller.spec.ts @@ -26,8 +26,8 @@ import { Group } from '../../../groups/group.entity'; import { MediaModule } from '../../../media/media.module'; import { MediaUpload } from '../../../media/media-upload.entity'; import { ConfigModule } from '@nestjs/config'; -import mediaConfigMock from '../../../config/media.config.mock'; -import appConfigMock from '../../../config/app.config.mock'; +import mediaConfigMock from '../../../config/mock/media.config.mock'; +import appConfigMock from '../../../config/mock/app.config.mock'; describe('Me Controller', () => { let controller: MeController; @@ -38,11 +38,7 @@ describe('Me Controller', () => { imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [mediaConfigMock], - }), - ConfigModule.forRoot({ - isGlobal: true, - load: [appConfigMock], + load: [appConfigMock, mediaConfigMock], }), UsersModule, HistoryModule, diff --git a/src/api/public/media/media.controller.spec.ts b/src/api/public/media/media.controller.spec.ts index 5e99c2b5f..be1126898 100644 --- a/src/api/public/media/media.controller.spec.ts +++ b/src/api/public/media/media.controller.spec.ts @@ -7,8 +7,8 @@ import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import appConfigMock from '../../../config/app.config.mock'; -import mediaConfigMock from '../../../config/media.config.mock'; +import appConfigMock from '../../../config/mock/app.config.mock'; +import mediaConfigMock from '../../../config/mock/media.config.mock'; import { LoggerModule } from '../../../logger/logger.module'; import { MediaUpload } from '../../../media/media-upload.entity'; import { MediaModule } from '../../../media/media.module'; diff --git a/src/api/public/notes/notes.controller.spec.ts b/src/api/public/notes/notes.controller.spec.ts index dead1c5ed..789b5220a 100644 --- a/src/api/public/notes/notes.controller.spec.ts +++ b/src/api/public/notes/notes.controller.spec.ts @@ -29,8 +29,8 @@ import { GroupsModule } from '../../../groups/groups.module'; import { ConfigModule } from '@nestjs/config'; import { MediaModule } from '../../../media/media.module'; import { MediaUpload } from '../../../media/media-upload.entity'; -import appConfigMock from '../../../config/app.config.mock'; -import mediaConfigMock from '../../../config/media.config.mock'; +import appConfigMock from '../../../config/mock/app.config.mock'; +import mediaConfigMock from '../../../config/mock/media.config.mock'; describe('Notes Controller', () => { let controller: NotesController; diff --git a/src/config/app.config.mock.ts b/src/config/mock/app.config.mock.ts similarity index 100% rename from src/config/app.config.mock.ts rename to src/config/mock/app.config.mock.ts diff --git a/src/config/media.config.mock.ts b/src/config/mock/media.config.mock.ts similarity index 100% rename from src/config/media.config.mock.ts rename to src/config/mock/media.config.mock.ts diff --git a/src/history/history.service.spec.ts b/src/history/history.service.spec.ts index 46a984800..985215f99 100644 --- a/src/history/history.service.spec.ts +++ b/src/history/history.service.spec.ts @@ -31,7 +31,7 @@ import { NoteGroupPermission } from '../permissions/note-group-permission.entity import { NoteUserPermission } from '../permissions/note-user-permission.entity'; import { Group } from '../groups/group.entity'; import { ConfigModule } from '@nestjs/config'; -import appConfigMock from '../config/app.config.mock'; +import appConfigMock from '../config/mock/app.config.mock'; describe('HistoryService', () => { let service: HistoryService; diff --git a/src/media/media.service.spec.ts b/src/media/media.service.spec.ts index bab440fdc..13197b078 100644 --- a/src/media/media.service.spec.ts +++ b/src/media/media.service.spec.ts @@ -13,7 +13,7 @@ import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import mediaConfigMock from '../config/media.config.mock'; +import mediaConfigMock from '../config/mock/media.config.mock'; import { LoggerModule } from '../logger/logger.module'; import { AuthorColor } from '../notes/author-color.entity'; import { Note } from '../notes/note.entity'; @@ -34,7 +34,7 @@ import { ClientError, NotInDBError, PermissionError } from '../errors/errors'; import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity'; import { Group } from '../groups/group.entity'; -import appConfigMock from '../../src/config/app.config.mock'; +import appConfigMock from '../../src/config/mock/app.config.mock'; describe('MediaService', () => { let service: MediaService; diff --git a/src/notes/notes.service.spec.ts b/src/notes/notes.service.spec.ts index 20ebc077a..edae9cd84 100644 --- a/src/notes/notes.service.spec.ts +++ b/src/notes/notes.service.spec.ts @@ -40,7 +40,7 @@ import { NoteUserPermission } from '../permissions/note-user-permission.entity'; import { GroupsModule } from '../groups/groups.module'; import { Group } from '../groups/group.entity'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import appConfigMock from '../config/app.config.mock'; +import appConfigMock from '../config/mock/app.config.mock'; describe('NotesService', () => { let service: NotesService; diff --git a/src/permissions/permissions.service.spec.ts b/src/permissions/permissions.service.spec.ts index c612b360a..d6861c5ff 100644 --- a/src/permissions/permissions.service.spec.ts +++ b/src/permissions/permissions.service.spec.ts @@ -29,7 +29,7 @@ import { NoteUserPermission } from './note-user-permission.entity'; import { PermissionsModule } from './permissions.module'; import { GuestPermission, PermissionsService } from './permissions.service'; import { ConfigModule } from '@nestjs/config'; -import appConfigMock from '../config/app.config.mock'; +import appConfigMock from '../config/mock/app.config.mock'; describe('PermissionsService', () => { let permissionsService: PermissionsService; diff --git a/src/revisions/revisions.service.spec.ts b/src/revisions/revisions.service.spec.ts index ada09533f..c509d5fd7 100644 --- a/src/revisions/revisions.service.spec.ts +++ b/src/revisions/revisions.service.spec.ts @@ -21,7 +21,7 @@ import { NoteGroupPermission } from '../permissions/note-group-permission.entity import { NoteUserPermission } from '../permissions/note-user-permission.entity'; import { Group } from '../groups/group.entity'; import { ConfigModule } from '@nestjs/config'; -import appConfigMock from '../config/app.config.mock'; +import appConfigMock from '../config/mock/app.config.mock'; describe('RevisionsService', () => { let service: RevisionsService; diff --git a/test/private-api/history.e2e-spec.ts b/test/private-api/history.e2e-spec.ts index 20fbf7878..4123a9321 100644 --- a/test/private-api/history.e2e-spec.ts +++ b/test/private-api/history.e2e-spec.ts @@ -14,8 +14,8 @@ import { ConfigModule } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; import * as request from 'supertest'; -import mediaConfigMock from '../../src/config/media.config.mock'; -import appConfigMock from '../../src/config/app.config.mock'; +import mediaConfigMock from '../../src/config/mock/media.config.mock'; +import appConfigMock from '../../src/config/mock/app.config.mock'; import { GroupsModule } from '../../src/groups/groups.module'; import { LoggerModule } from '../../src/logger/logger.module'; import { NotesModule } from '../../src/notes/notes.module'; diff --git a/test/public-api/me.e2e-spec.ts b/test/public-api/me.e2e-spec.ts index 788530b2a..dda94556e 100644 --- a/test/public-api/me.e2e-spec.ts +++ b/test/public-api/me.e2e-spec.ts @@ -30,8 +30,8 @@ import { AuthModule } from '../../src/auth/auth.module'; import { UsersModule } from '../../src/users/users.module'; import { HistoryModule } from '../../src/history/history.module'; import { ConfigModule } from '@nestjs/config'; -import mediaConfigMock from '../../src/config/media.config.mock'; -import appConfigMock from '../../src/config/app.config.mock'; +import mediaConfigMock from '../../src/config/mock/media.config.mock'; +import appConfigMock from '../../src/config/mock/app.config.mock'; import { User } from '../../src/users/user.entity'; import { MediaService } from '../../src/media/media.service'; import { MediaModule } from '../../src/media/media.module'; diff --git a/test/public-api/media.e2e-spec.ts b/test/public-api/media.e2e-spec.ts index a3d15d622..881f8e853 100644 --- a/test/public-api/media.e2e-spec.ts +++ b/test/public-api/media.e2e-spec.ts @@ -16,8 +16,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { promises as fs } from 'fs'; import * as request from 'supertest'; import { PublicApiModule } from '../../src/api/public/public-api.module'; -import mediaConfigMock from '../../src/config/media.config.mock'; -import appConfigMock from '../../src/config/app.config.mock'; +import mediaConfigMock from '../../src/config/mock/media.config.mock'; +import appConfigMock from '../../src/config/mock/app.config.mock'; import { GroupsModule } from '../../src/groups/groups.module'; import { LoggerModule } from '../../src/logger/logger.module'; import { NestConsoleLoggerService } from '../../src/logger/nest-console-logger.service'; diff --git a/test/public-api/notes.e2e-spec.ts b/test/public-api/notes.e2e-spec.ts index 2d4b5bab2..f61374f1b 100644 --- a/test/public-api/notes.e2e-spec.ts +++ b/test/public-api/notes.e2e-spec.ts @@ -15,8 +15,8 @@ import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; import * as request from 'supertest'; import { PublicApiModule } from '../../src/api/public/public-api.module'; -import mediaConfigMock from '../../src/config/media.config.mock'; -import appConfigMock from '../../src/config/app.config.mock'; +import mediaConfigMock from '../../src/config/mock/media.config.mock'; +import appConfigMock from '../../src/config/mock/app.config.mock'; import { NotInDBError } from '../../src/errors/errors'; import { GroupsModule } from '../../src/groups/groups.module'; import { LoggerModule } from '../../src/logger/logger.module'; From 19318ae518c3e47ab15fe812d50706030004e09d Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Tue, 2 Mar 2021 10:49:39 +0100 Subject: [PATCH 03/10] Config: Extend AppConfig mock This is now more in line what you would get from the regular config code Signed-off-by: Philip Molares --- src/config/mock/app.config.mock.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/config/mock/app.config.mock.ts b/src/config/mock/app.config.mock.ts index 6e23d5dfb..2d270990a 100644 --- a/src/config/mock/app.config.mock.ts +++ b/src/config/mock/app.config.mock.ts @@ -5,8 +5,13 @@ */ import { registerAs } from '@nestjs/config'; +import { LogLevel } from 'ts-loader/dist/logger'; export default registerAs('appConfig', () => ({ + domain: 'md.example.com', + rendererOrigin: 'md-renderer.example.com', port: 3000, + loglevel: LogLevel.ERROR, + maxDocumentLength: 100000, forbiddenNoteIds: ['forbiddenNoteId'], })); From e8339e0976b0846b4f5cf4745a701fbec3502f85 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 1 Mar 2021 21:10:16 +0100 Subject: [PATCH 04/10] Config: Add two new Subconfigs CustomizationConfig holds all possible customization configs. ExternalConfig holds external services that may be configured. Signed-off-by: Philip Molares --- src/app.module.ts | 6 ++ src/config/customization.config.ts | 80 ++++++++++++++++++++++++++ src/config/external-services.config.ts | 49 ++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 src/config/customization.config.ts create mode 100644 src/config/external-services.config.ts diff --git a/src/app.module.ts b/src/app.module.ts index d9a09f527..416a3e813 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -25,9 +25,13 @@ import hstsConfig from './config/hsts.config'; import cspConfig from './config/csp.config'; import databaseConfig from './config/database.config'; import authConfig from './config/auth.config'; +import customizationConfig from './config/customization.config'; +import externalConfig from './config/external-services.config'; import { PrivateApiModule } from './api/private/private-api.module'; import { ScheduleModule } from '@nestjs/schedule'; import { RouterModule, Routes } from 'nest-router'; +import { FrontendConfigService } from './frontend-config/frontend-config.service'; +import { FrontendConfigModule } from './frontend-config/frontend-config.module'; const routes: Routes = [ { @@ -53,6 +57,8 @@ const routes: Routes = [ cspConfig, databaseConfig, authConfig, + customizationConfig, + externalConfig, ], isGlobal: true, }), diff --git a/src/config/customization.config.ts b/src/config/customization.config.ts new file mode 100644 index 000000000..401e3e2b2 --- /dev/null +++ b/src/config/customization.config.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { registerAs } from '@nestjs/config'; +import * as Joi from 'joi'; +import { buildErrorMessage } from './utils'; + +export interface CustomizationConfig { + branding: { + customName: string; + customLogo: string; + }; + specialUrls: { + privacy: string; + termsOfUse: string; + imprint: string; + }; +} + +const schema = Joi.object({ + branding: Joi.object({ + customName: Joi.string().optional().label('HD_CUSTOM_NAME'), + customLogo: Joi.string() + .uri({ + scheme: [/https?/], + }) + .optional() + .label('HD_CUSTOM_LOGO'), + }), + specialUrls: Joi.object({ + privacy: Joi.string() + .uri({ + scheme: /https?/, + }) + .optional() + .label('HD_PRIVACY_URL'), + termsOfUse: Joi.string() + .uri({ + scheme: /https?/, + }) + .optional() + .label('HD_TERMS_OF_USE_URL'), + imprint: Joi.string() + .uri({ + scheme: /https?/, + }) + .optional() + .label('HD_IMPRINT_URL'), + }), +}); + +export default registerAs('customizationConfig', () => { + const customizationConfig = schema.validate( + { + branding: { + customName: process.env.HD_CUSTOM_NAME, + customLogo: process.env.HD_CUSTOM_LOGO, + }, + specialUrls: { + privacy: process.env.HD_PRIVACY_URL, + termsOfUse: process.env.HD_TERMS_OF_USE_URL, + imprint: process.env.HD_IMPRINT_URL, + }, + }, + { + abortEarly: false, + presence: 'required', + }, + ); + if (customizationConfig.error) { + const errorMessages = customizationConfig.error.details.map( + (detail) => detail.message, + ); + throw new Error(buildErrorMessage(errorMessages)); + } + return customizationConfig.value as CustomizationConfig; +}); diff --git a/src/config/external-services.config.ts b/src/config/external-services.config.ts new file mode 100644 index 000000000..406633a4e --- /dev/null +++ b/src/config/external-services.config.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { registerAs } from '@nestjs/config'; +import * as Joi from 'joi'; +import { buildErrorMessage } from './utils'; + +export interface ExternalServicesConfig { + plantUmlServer: string; + imageProxy: string; +} + +const schema = Joi.object({ + plantUmlServer: Joi.string() + .uri({ + scheme: /https?/, + }) + .optional() + .label('HD_PLANTUML_SERVER'), + imageProxy: Joi.string() + .uri({ + scheme: /https?/, + }) + .optional() + .label('HD_IMAGE_PROXY'), +}); + +export default registerAs('externalServicesConfig', () => { + const externalConfig = schema.validate( + { + plantUmlServer: process.env.HD_PLANTUML_SERVER, + imageProxy: process.env.HD_IMAGE_PROXY, + }, + { + abortEarly: false, + presence: 'required', + }, + ); + if (externalConfig.error) { + const errorMessages = externalConfig.error.details.map( + (detail) => detail.message, + ); + throw new Error(buildErrorMessage(errorMessages)); + } + return externalConfig.value as ExternalServicesConfig; +}); From de82b72b62d5d0e993c57d7be8ba1c9fb2909caa Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 1 Mar 2021 21:12:48 +0100 Subject: [PATCH 05/10] Config: Create new config mocks for tests Signed-off-by: Philip Molares --- src/config/mock/auth.config.mock.ts | 40 +++++++++++++++++++ src/config/mock/customization.config.mock.ts | 19 +++++++++ .../mock/external-services.config.mock.ts | 12 ++++++ test/private-api/history.e2e-spec.ts | 11 ++++- 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/config/mock/auth.config.mock.ts create mode 100644 src/config/mock/customization.config.mock.ts create mode 100644 src/config/mock/external-services.config.mock.ts diff --git a/src/config/mock/auth.config.mock.ts b/src/config/mock/auth.config.mock.ts new file mode 100644 index 000000000..6a703d727 --- /dev/null +++ b/src/config/mock/auth.config.mock.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { registerAs } from '@nestjs/config'; + +export default registerAs('authConfig', () => ({ + email: { + enableLogin: true, + enableRegister: true, + }, + facebook: { + clientID: undefined, + clientSecret: undefined, + }, + twitter: { + consumerKey: undefined, + consumerSecret: undefined, + }, + github: { + clientID: undefined, + clientSecret: undefined, + }, + dropbox: { + clientID: undefined, + clientSecret: undefined, + appKey: undefined, + }, + google: { + clientID: undefined, + clientSecret: undefined, + apiKey: undefined, + }, + gitlab: [], + ldap: [], + saml: [], + oauth2: [], +})); diff --git a/src/config/mock/customization.config.mock.ts b/src/config/mock/customization.config.mock.ts new file mode 100644 index 000000000..943fd0d90 --- /dev/null +++ b/src/config/mock/customization.config.mock.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { registerAs } from '@nestjs/config'; + +export default registerAs('customizationConfig', () => ({ + branding: { + customName: 'ACME Corp', + customLogo: '', + }, + specialUrls: { + privacy: '/test/privacy', + termsOfUse: '/test/termsOfUse', + imprint: '/test/imprint', + }, +})); diff --git a/src/config/mock/external-services.config.mock.ts b/src/config/mock/external-services.config.mock.ts new file mode 100644 index 000000000..5708861a6 --- /dev/null +++ b/src/config/mock/external-services.config.mock.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { registerAs } from '@nestjs/config'; + +export default registerAs('externalServicesConfig', () => ({ + plantUmlServer: 'plantuml.example.com', + imageProxy: 'imageProxy.example.com', +})); diff --git a/test/private-api/history.e2e-spec.ts b/test/private-api/history.e2e-spec.ts index 4123a9321..c7c81f2dd 100644 --- a/test/private-api/history.e2e-spec.ts +++ b/test/private-api/history.e2e-spec.ts @@ -16,6 +16,9 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import * as request from 'supertest'; import mediaConfigMock from '../../src/config/mock/media.config.mock'; import appConfigMock from '../../src/config/mock/app.config.mock'; +import authConfigMock from '../../src/config/mock/auth.config.mock'; +import customizationConfigMock from '../../src/config/mock/customization.config.mock'; +import externalServicesConfigMock from '../../src/config/mock/external-services.config.mock'; import { GroupsModule } from '../../src/groups/groups.module'; import { LoggerModule } from '../../src/logger/logger.module'; import { NotesModule } from '../../src/notes/notes.module'; @@ -43,7 +46,13 @@ describe('History', () => { imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfigMock, mediaConfigMock], + load: [ + appConfigMock, + mediaConfigMock, + authConfigMock, + customizationConfigMock, + externalServicesConfigMock, + ], }), PrivateApiModule, NotesModule, From f63d37dbf7c7447d51d93dcc0e8130ab5faddddb Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 1 Mar 2021 21:07:43 +0100 Subject: [PATCH 06/10] Config: Add identifier to all multi auth provider to AuthConfig These are used in the /config private API call and needed to distinguish with which of the multiple auth providers a login should occur. This also fixes the types of the multiple auth provider arrays to something that works, as `[{}]` specifics exactly on object in an array. Signed-off-by: Philip Molares --- src/config/auth.config.ts | 126 ++++++++++++++++++++------------------ 1 file changed, 65 insertions(+), 61 deletions(-) diff --git a/src/config/auth.config.ts b/src/config/auth.config.ts index 8cc4132e7..26c918252 100644 --- a/src/config/auth.config.ts +++ b/src/config/auth.config.ts @@ -40,68 +40,64 @@ export interface AuthConfig { clientSecret: string; apiKey: string; }; - gitlab: [ - { - providerName: string; - baseURL: string; - clientID: string; - clientSecret: string; - scope: GitlabScope; - version: GitlabVersion; - }, - ]; + gitlab: { + identifier: string; + providerName: string; + baseURL: string; + clientID: string; + clientSecret: string; + scope: GitlabScope; + version: GitlabVersion; + }[]; // ToDo: tlsOptions exist in config.json.example. See https://nodejs.org/api/tls.html#tls_tls_connect_options_callback - ldap: [ - { - providerName: string; - url: string; - bindDn: string; - bindCredentials: string; - searchBase: string; - searchFilter: string; - searchAttributes: string[]; - usernameField: string; - useridField: string; - tlsCa: string[]; - }, - ]; - saml: [ - { - providerName: string; - idpSsoUrl: string; - idpCert: string; - clientCert: string; - issuer: string; - identifierFormat: string; - disableRequestedAuthnContext: string; - groupAttribute: string; - requiredGroups: string[]; - externalGroups: string; - attribute: { - id: string; - username: string; - email: string; - }; - }, - ]; - oauth2: [ - { - providerName: string; - baseURL: string; - userProfileURL: string; - userProfileIdAttr: string; - userProfileUsernameAttr: string; - userProfileDisplayNameAttr: string; - userProfileEmailAttr: string; - tokenURL: string; - authorizationURL: string; - clientID: string; - clientSecret: string; - scope: string; - rolesClaim: string; - accessRole: string; - }, - ]; + ldap: { + identifier: string; + providerName: string; + url: string; + bindDn: string; + bindCredentials: string; + searchBase: string; + searchFilter: string; + searchAttributes: string[]; + usernameField: string; + useridField: string; + tlsCa: string[]; + }[]; + saml: { + identifier: string; + providerName: string; + idpSsoUrl: string; + idpCert: string; + clientCert: string; + issuer: string; + identifierFormat: string; + disableRequestedAuthnContext: string; + groupAttribute: string; + requiredGroups: string[]; + externalGroups: string; + attribute: { + id: string; + username: string; + email: string; + }; + }[]; + oauth2: { + identifier: string; + providerName: string; + baseURL: string; + userProfileURL: string; + userProfileIdAttr: string; + userProfileUsernameAttr: string; + userProfileDisplayNameAttr: string; + userProfileEmailAttr: string; + tokenURL: string; + authorizationURL: string; + clientID: string; + clientSecret: string; + scope: string; + rolesClaim: string; + accessRole: string; + }[]; } const authSchema = Joi.object({ @@ -146,6 +142,7 @@ const authSchema = Joi.object({ gitlab: Joi.array() .items( Joi.object({ + identifier: Joi.string(), providerName: Joi.string().default('Gitlab').optional(), baseURL: Joi.string(), clientID: Joi.string(), @@ -165,6 +162,7 @@ const authSchema = Joi.object({ ldap: Joi.array() .items( Joi.object({ + identifier: Joi.string(), providerName: Joi.string().default('LDAP').optional(), url: Joi.string(), bindDn: Joi.string().optional(), @@ -184,6 +182,7 @@ const authSchema = Joi.object({ saml: Joi.array() .items( Joi.object({ + identifier: Joi.string(), providerName: Joi.string().default('SAML').optional(), idpSsoUrl: Joi.string(), idpCert: Joi.string(), @@ -208,6 +207,7 @@ const authSchema = Joi.object({ oauth2: Joi.array() .items( Joi.object({ + identifier: Joi.string(), providerName: Joi.string().default('OAuth2').optional(), baseURL: Joi.string(), userProfileURL: Joi.string(), @@ -246,6 +246,7 @@ export default registerAs('authConfig', () => { const gitlabs = gitlabNames.map((gitlabName) => { return { + identifier: gitlabName, providerName: process.env[`HD_AUTH_GITLAB_${gitlabName}_PROVIDER_NAME`], baseURL: process.env[`HD_AUTH_GITLAB_${gitlabName}_BASE_URL`], clientID: process.env[`HD_AUTH_GITLAB_${gitlabName}_CLIENT_ID`], @@ -257,6 +258,7 @@ export default registerAs('authConfig', () => { const ldaps = ldapNames.map((ldapName) => { return { + identifier: ldapName, providerName: process.env[`HD_AUTH_LDAP_${ldapName}_PROVIDER_NAME`], url: process.env[`HD_AUTH_LDAP_${ldapName}_URL`], bindDn: process.env[`HD_AUTH_LDAP_${ldapName}_BIND_DN`], @@ -275,6 +277,7 @@ export default registerAs('authConfig', () => { const samls = samlNames.map((samlName) => { return { + identifier: samlName, providerName: process.env[`HD_AUTH_SAML_${samlName}_PROVIDER_NAME`], idpSsoUrl: process.env[`HD_AUTH_SAML_${samlName}_IDP_SSO_URL`], idpCert: process.env[`HD_AUTH_SAML_${samlName}_IDP_CERT`], @@ -303,6 +306,7 @@ export default registerAs('authConfig', () => { const oauth2s = oauth2Names.map((oauth2Name) => { return { + identifier: oauth2Name, providerName: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_PROVIDER_NAME`], baseURL: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_BASE_URL`], userProfileURL: From c4161cec9874f093da6ff72148a7967e554bca66 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 1 Mar 2021 21:00:55 +0100 Subject: [PATCH 07/10] Config: Add rendererOrigin and maxDocumentLength to AppConfig These are used in the /config private API call. Signed-off-by: Philip Molares --- src/config/app.config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/config/app.config.ts b/src/config/app.config.ts index 28e81bdcc..c1af96dce 100644 --- a/src/config/app.config.ts +++ b/src/config/app.config.ts @@ -11,13 +11,16 @@ import { buildErrorMessage, toArrayConfig } from './utils'; export interface AppConfig { domain: string; + rendererOrigin: string; port: number; loglevel: Loglevel; forbiddenNoteIds: string[]; + maxDocumentLength: number; } const schema = Joi.object({ domain: Joi.string().label('HD_DOMAIN'), + rendererOrigin: Joi.string().optional().label('HD_RENDERER_ORIGIN'), port: Joi.number().default(3000).optional().label('PORT'), loglevel: Joi.string() .valid(...Object.values(Loglevel)) @@ -29,15 +32,22 @@ const schema = Joi.object({ .optional() .default([]) .label('HD_FORBIDDEN_NOTE_IDS'), + maxDocumentLength: Joi.number() + .default(100000) + .optional() + .label('HD_MAX_DOCUMENT_LENGTH'), }); export default registerAs('appConfig', () => { const appConfig = schema.validate( { domain: process.env.HD_DOMAIN, + rendererOrigin: process.env.HD_RENDERER_ORIGIN, port: parseInt(process.env.PORT) || undefined, loglevel: process.env.HD_LOGLEVEL, forbiddenNoteIds: toArrayConfig(process.env.HD_FORBIDDEN_NOTE_IDS, ','), + maxDocumentLength: + parseInt(process.env.HD_MAX_DOCUMENT_LENGTH) || undefined, }, { abortEarly: false, From e471342497483725c7a444b89cc78df6c4c82d35 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 1 Mar 2021 21:16:01 +0100 Subject: [PATCH 08/10] FrontendConfig: Add new service This service handles the config for the frontend. Signed-off-by: Philip Molares --- src/app.module.ts | 3 +- src/frontend-config/frontend-config.dto.ts | 280 +++++++++++++ src/frontend-config/frontend-config.module.ts | 17 + .../frontend-config.service.spec.ts | 374 ++++++++++++++++++ .../frontend-config.service.ts | 160 ++++++++ 5 files changed, 833 insertions(+), 1 deletion(-) create mode 100644 src/frontend-config/frontend-config.dto.ts create mode 100644 src/frontend-config/frontend-config.module.ts create mode 100644 src/frontend-config/frontend-config.service.spec.ts create mode 100644 src/frontend-config/frontend-config.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 416a3e813..2c7dc95e5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -76,8 +76,9 @@ const routes: Routes = [ LoggerModule, MediaModule, AuthModule, + FrontendConfigModule, ], controllers: [], - providers: [], + providers: [FrontendConfigService], }) export class AppModule {} diff --git a/src/frontend-config/frontend-config.dto.ts b/src/frontend-config/frontend-config.dto.ts new file mode 100644 index 000000000..bb67a4c09 --- /dev/null +++ b/src/frontend-config/frontend-config.dto.ts @@ -0,0 +1,280 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + IsArray, + IsBoolean, + IsDate, + IsNumber, + IsOptional, + IsString, + IsUrl, + ValidateNested, +} from 'class-validator'; +import { ServerVersion } from '../monitoring/server-status.dto'; + +export class AuthProviders { + /** + * Is Facebook available as a auth provider? + */ + @IsBoolean() + facebook: boolean; + + /** + * Is GitHub available as a auth provider? + */ + @IsBoolean() + github: boolean; + + /** + * Is Twitter available as a auth provider? + */ + @IsBoolean() + twitter: boolean; + + /** + * Is at least one GitLab server available as a auth provider? + */ + @IsBoolean() + gitlab: boolean; + + /** + * Is DropBox available as a auth provider? + */ + @IsBoolean() + dropbox: boolean; + + /** + * Is at least one LDAP server available as a auth provider? + */ + @IsBoolean() + ldap: boolean; + + /** + * Is Google available as a auth provider? + */ + @IsBoolean() + google: boolean; + + /** + * Is at least one SAML provider available as a auth provider? + */ + @IsBoolean() + saml: boolean; + + /** + * Is at least one OAuth2 provider available as a auth provider? + */ + @IsBoolean() + oauth2: boolean; + + /** + * Is internal auth available? + */ + @IsBoolean() + internal: boolean; +} + +export class BannerDto { + /** + * The text that is shown in the banner + * @example This is a test banner + */ + @IsString() + text: string; + + /** + * When the banner was last changed + * @example "2020-12-01 12:23:34" + */ + @IsDate() + updateTime: Date; +} + +export class BrandingDto { + /** + * The name to be displayed next to the HedgeDoc logo + * @example ACME Corp + */ + @IsString() + @IsOptional() + name: string; + + /** + * The logo to be displayed next to the HedgeDoc logo + * @example https://md.example.com/logo.png + */ + @IsUrl() + @IsOptional() + logo: URL; +} + +export class CustomAuthEntry { + /** + * The identifier with which the auth provider can be called + * @example gitlab + */ + @IsString() + identifier: string; + + /** + * The name given to the auth provider + * @example GitLab + */ + @IsString() + providerName: string; +} + +export class CustomAuthNamesDto { + /** + * All configured GitLab server + */ + @IsArray() + @ValidateNested({ each: true }) + gitlab: CustomAuthEntry[]; + + /** + * All configured LDAP server + */ + @IsArray() + @ValidateNested({ each: true }) + ldap: CustomAuthEntry[]; + + /** + * All configured OAuth2 provider + */ + @IsArray() + @ValidateNested({ each: true }) + oauth2: CustomAuthEntry[]; + + /** + * All configured SAML provider + */ + @IsArray() + @ValidateNested({ each: true }) + saml: CustomAuthEntry[]; +} + +export class SpecialUrlsDto { + /** + * A link to the privacy notice + * @example https://md.example.com/n/privacy + */ + @IsUrl() + @IsOptional() + privacy: URL; + + /** + * A link to the terms of use + * @example https://md.example.com/n/termsOfUse + */ + @IsUrl() + @IsOptional() + termsOfUse: URL; + + /** + * A link to the imprint + * @example https://md.example.com/n/imprint + */ + @IsUrl() + @IsOptional() + imprint: URL; +} + +export class IframeCommunicationDto { + /** + * The origin under which the editor page will be served + * @example https://md.example.com + */ + @IsUrl() + @IsOptional() + editorOrigin: URL; + + /** + * The origin under which the renderer page will be served + * @example https://md-renderer.example.com + */ + @IsUrl() + @IsOptional() + rendererOrigin: URL; +} + +export class FrontendConfigDto { + /** + * Is anonymous usage of the instance allowed? + */ + @IsBoolean() + allowAnonymous: boolean; + + /** + * Are users allowed to register on this instance? + */ + @IsBoolean() + allowRegister: boolean; + + /** + * Which auth providers are available? + */ + @ValidateNested() + authProviders: AuthProviders; + + /** + * Individual branding information + */ + @ValidateNested() + branding: BrandingDto; + + /** + * An optional banner that will be shown + */ + @ValidateNested() + banner: BannerDto; + + /** + * The custom names of auth providers, which can be specified multiple times + */ + @ValidateNested() + customAuthNames: CustomAuthNamesDto; + + /** + * Is an image proxy enabled? + */ + @IsBoolean() + useImageProxy: boolean; + + /** + * Links to some special pages + */ + @ValidateNested() + specialUrls: SpecialUrlsDto; + + /** + * The version of HedgeDoc + */ + @ValidateNested() + version: ServerVersion; + + /** + * The plantUML server that should be used to render. + */ + @IsUrl() + @IsOptional() + plantUmlServer: URL; + + /** + * The maximal length of each document + */ + @IsNumber() + maxDocumentLength: number; + + /** + * The frontend capsules the markdown rendering into a secured iframe, to increase the security. The browser will treat the iframe target as cross-origin even if they are on the same domain. + * You can go even one step further and serve the editor and the renderer on different (sub)domains to eliminate even more attack vectors by making sessions, cookies, etc. not available for the renderer, because they aren't set on the renderer origin. + * However, The editor and the renderer need to know the other's origin to communicate with each other, even if they are the same. + */ + @ValidateNested() + iframeCommunication: IframeCommunicationDto; +} diff --git a/src/frontend-config/frontend-config.module.ts b/src/frontend-config/frontend-config.module.ts new file mode 100644 index 000000000..ef6680138 --- /dev/null +++ b/src/frontend-config/frontend-config.module.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Module } from '@nestjs/common'; +import { LoggerModule } from '../logger/logger.module'; +import { ConfigModule } from '@nestjs/config'; +import { FrontendConfigService } from './frontend-config.service'; + +@Module({ + imports: [LoggerModule, ConfigModule], + providers: [FrontendConfigService], + exports: [FrontendConfigService], +}) +export class FrontendConfigModule {} diff --git a/src/frontend-config/frontend-config.service.spec.ts b/src/frontend-config/frontend-config.service.spec.ts new file mode 100644 index 000000000..117547709 --- /dev/null +++ b/src/frontend-config/frontend-config.service.spec.ts @@ -0,0 +1,374 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { FrontendConfigService } from './frontend-config.service'; +import { ConfigModule, registerAs } from '@nestjs/config'; +import { LoggerModule } from '../logger/logger.module'; +import { AuthConfig } from '../config/auth.config'; +import { GitlabScope, GitlabVersion } from '../config/gitlab.enum'; +import { getServerVersionFromPackageJson } from '../utils/serverVersion'; +import { CustomizationConfig } from '../config/customization.config'; +import { AppConfig } from '../config/app.config'; +import { ExternalServicesConfig } from '../config/external-services.config'; +import { Loglevel } from '../config/loglevel.enum'; + +describe('FrontendConfigService', () => { + const emptyAuthConfig: AuthConfig = { + email: { + enableLogin: false, + enableRegister: false, + }, + facebook: { + clientID: undefined, + clientSecret: undefined, + }, + twitter: { + consumerKey: undefined, + consumerSecret: undefined, + }, + github: { + clientID: undefined, + clientSecret: undefined, + }, + dropbox: { + clientID: undefined, + clientSecret: undefined, + appKey: undefined, + }, + google: { + clientID: undefined, + clientSecret: undefined, + apiKey: undefined, + }, + gitlab: [], + ldap: [], + saml: [], + oauth2: [], + }; + const facebook: AuthConfig['facebook'] = { + clientID: 'facebookTestId', + clientSecret: 'facebookTestSecret', + }; + const twitter: AuthConfig['twitter'] = { + consumerKey: 'twitterTestId', + consumerSecret: 'twitterTestSecret', + }; + const github: AuthConfig['github'] = { + clientID: 'githubTestId', + clientSecret: 'githubTestSecret', + }; + const dropbox: AuthConfig['dropbox'] = { + clientID: 'dropboxTestId', + clientSecret: 'dropboxTestSecret', + appKey: 'dropboxTestKey', + }; + const google: AuthConfig['google'] = { + clientID: 'googleTestId', + clientSecret: 'googleTestSecret', + apiKey: 'googleTestKey', + }; + const gitlab: AuthConfig['gitlab'] = [ + { + identifier: 'gitlabTestIdentifier', + providerName: 'gitlabTestName', + baseURL: 'gitlabTestUrl', + clientID: 'gitlabTestId', + clientSecret: 'gitlabTestSecret', + scope: GitlabScope.API, + version: GitlabVersion.V4, + }, + ]; + const ldap: AuthConfig['ldap'] = [ + { + identifier: 'ldapTestIdentifier', + providerName: 'ldapTestName', + url: 'ldapTestUrl', + bindDn: 'ldapTestBindDn', + bindCredentials: 'ldapTestBindCredentials', + searchBase: 'ldapTestSearchBase', + searchFilter: 'ldapTestSearchFilter', + searchAttributes: ['ldapTestSearchAttribute'], + usernameField: 'ldapTestUsername', + useridField: 'ldapTestUserId', + tlsCa: ['ldapTestTlsCa'], + }, + ]; + const saml: AuthConfig['saml'] = [ + { + identifier: 'samlTestIdentifier', + providerName: 'samlTestName', + idpSsoUrl: 'samlTestUrl', + idpCert: 'samlTestCert', + clientCert: 'samlTestClientCert', + issuer: 'samlTestIssuer', + identifierFormat: 'samlTestUrl', + disableRequestedAuthnContext: 'samlTestUrl', + groupAttribute: 'samlTestUrl', + requiredGroups: ['samlTestUrl'], + externalGroups: 'samlTestUrl', + attribute: { + id: 'samlTestUrl', + username: 'samlTestUrl', + email: 'samlTestUrl', + }, + }, + ]; + const oauth2: AuthConfig['oauth2'] = [ + { + identifier: 'oauth2Testidentifier', + providerName: 'oauth2TestName', + baseURL: 'oauth2TestUrl', + userProfileURL: 'oauth2TestProfileUrl', + userProfileIdAttr: 'oauth2TestProfileId', + userProfileUsernameAttr: 'oauth2TestProfileUsername', + userProfileDisplayNameAttr: 'oauth2TestProfileDisplay', + userProfileEmailAttr: 'oauth2TestProfileEmail', + tokenURL: 'oauth2TestTokenUrl', + authorizationURL: 'oauth2TestAuthUrl', + clientID: 'oauth2TestId', + clientSecret: 'oauth2TestSecret', + scope: 'oauth2TestScope', + rolesClaim: 'oauth2TestRoles', + accessRole: 'oauth2TestAccess', + }, + ]; + let index = 1; + for (const renderOrigin of [undefined, 'http://md-renderer.example.com']) { + for (const maxDocumentLength of [100000, 900]) { + for (const enableLogin of [true, false]) { + for (const enableRegister of [true, false]) { + for (const authConfigConfigured of [ + facebook, + twitter, + github, + dropbox, + google, + gitlab, + ldap, + saml, + oauth2, + ]) { + for (const customName of [undefined, 'Test Branding Name']) { + for (const customLogo of [ + undefined, + 'https://example.com/logo.png', + ]) { + for (const privacyLink of [ + undefined, + 'https://example.com/privacy', + ]) { + for (const termsOfUseLink of [ + undefined, + 'https://example.com/terms', + ]) { + for (const imprintLink of [ + undefined, + 'https://example.com/imprint', + ]) { + for (const plantUmlServer of [ + undefined, + 'https://plantuml.example.com', + ]) { + for (const imageProxy of [ + undefined, + 'https://imageProxy.example.com', + ]) { + it(`combination #${index} works`, async () => { + const appConfig: AppConfig = { + domain: 'http://md.example.com', + rendererOrigin: renderOrigin, + port: 3000, + loglevel: Loglevel.ERROR, + forbiddenNoteIds: [], + maxDocumentLength: maxDocumentLength, + }; + const authConfig: AuthConfig = { + ...emptyAuthConfig, + email: { + enableLogin, + enableRegister, + }, + ...authConfigConfigured, + }; + const customizationConfig: CustomizationConfig = { + branding: { + customName: customName, + customLogo: customLogo, + }, + specialUrls: { + privacy: privacyLink, + termsOfUse: termsOfUseLink, + imprint: imprintLink, + }, + }; + const externalServicesConfig: ExternalServicesConfig = { + plantUmlServer: plantUmlServer, + imageProxy: imageProxy, + }; + const module: TestingModule = await Test.createTestingModule( + { + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [ + registerAs('appConfig', () => appConfig), + registerAs( + 'authConfig', + () => authConfig, + ), + registerAs( + 'customizationConfig', + () => customizationConfig, + ), + registerAs( + 'externalServicesConfig', + () => externalServicesConfig, + ), + ], + }), + LoggerModule, + ], + providers: [FrontendConfigService], + }, + ).compile(); + + const service = module.get(FrontendConfigService); + const config = await service.getFrontendConfig(); + expect(config.allowRegister).toEqual( + enableRegister, + ); + expect(config.authProviders.dropbox).toEqual( + !!authConfig.dropbox.clientID, + ); + expect(config.authProviders.facebook).toEqual( + !!authConfig.facebook.clientID, + ); + expect(config.authProviders.github).toEqual( + !!authConfig.github.clientID, + ); + expect(config.authProviders.google).toEqual( + !!authConfig.google.clientID, + ); + expect(config.authProviders.internal).toEqual( + enableLogin, + ); + expect(config.authProviders.twitter).toEqual( + !!authConfig.twitter.consumerKey, + ); + expect(config.authProviders.gitlab).toEqual( + authConfig.gitlab.length !== 0, + ); + expect(config.authProviders.ldap).toEqual( + authConfig.ldap.length !== 0, + ); + expect(config.authProviders.saml).toEqual( + authConfig.saml.length !== 0, + ); + expect(config.authProviders.oauth2).toEqual( + authConfig.oauth2.length !== 0, + ); + expect(config.allowAnonymous).toEqual(false); + expect(config.banner.text).toEqual(''); + expect(config.banner.updateTime).toEqual( + new Date(0), + ); + expect(config.branding.name).toEqual(customName); + expect(config.branding.logo).toEqual( + customLogo ? new URL(customLogo) : undefined, + ); + expect( + config.customAuthNames.gitlab.length, + ).toEqual(authConfig.gitlab.length); + if (config.customAuthNames.gitlab.length === 1) { + expect( + config.customAuthNames.gitlab[0].identifier, + ).toEqual(authConfig.gitlab[0].identifier); + expect( + config.customAuthNames.gitlab[0].providerName, + ).toEqual(authConfig.gitlab[0].providerName); + } + expect(config.customAuthNames.ldap.length).toEqual( + authConfig.ldap.length, + ); + if (config.customAuthNames.ldap.length === 1) { + expect( + config.customAuthNames.ldap[0].identifier, + ).toEqual(authConfig.ldap[0].identifier); + expect( + config.customAuthNames.ldap[0].providerName, + ).toEqual(authConfig.ldap[0].providerName); + } + expect(config.customAuthNames.saml.length).toEqual( + authConfig.saml.length, + ); + if (config.customAuthNames.saml.length === 1) { + expect( + config.customAuthNames.saml[0].identifier, + ).toEqual(authConfig.saml[0].identifier); + expect( + config.customAuthNames.saml[0].providerName, + ).toEqual(authConfig.saml[0].providerName); + } + expect( + config.customAuthNames.oauth2.length, + ).toEqual(authConfig.oauth2.length); + if (config.customAuthNames.oauth2.length === 1) { + expect( + config.customAuthNames.oauth2[0].identifier, + ).toEqual(authConfig.oauth2[0].identifier); + expect( + config.customAuthNames.oauth2[0].providerName, + ).toEqual(authConfig.oauth2[0].providerName); + } + expect( + config.iframeCommunication.editorOrigin, + ).toEqual(new URL(appConfig.domain)); + expect( + config.iframeCommunication.rendererOrigin, + ).toEqual( + appConfig.rendererOrigin + ? new URL(appConfig.rendererOrigin) + : new URL(appConfig.domain), + ); + expect(config.maxDocumentLength).toEqual( + maxDocumentLength, + ); + expect(config.plantUmlServer).toEqual( + plantUmlServer + ? new URL(plantUmlServer) + : undefined, + ); + expect(config.specialUrls.imprint).toEqual( + imprintLink ? new URL(imprintLink) : undefined, + ); + expect(config.specialUrls.privacy).toEqual( + privacyLink ? new URL(privacyLink) : undefined, + ); + expect(config.specialUrls.termsOfUse).toEqual( + termsOfUseLink + ? new URL(termsOfUseLink) + : undefined, + ); + expect(config.useImageProxy).toEqual(!!imageProxy); + expect(config.version).toEqual( + await getServerVersionFromPackageJson(), + ); + }); + index += 1; + } + } + } + } + } + } + } + } + } + } + } + } +}); diff --git a/src/frontend-config/frontend-config.service.ts b/src/frontend-config/frontend-config.service.ts new file mode 100644 index 000000000..6bb39e4b1 --- /dev/null +++ b/src/frontend-config/frontend-config.service.ts @@ -0,0 +1,160 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { ConsoleLoggerService } from '../logger/console-logger.service'; +import { + AuthProviders, + BannerDto, + BrandingDto, + CustomAuthNamesDto, + FrontendConfigDto, + IframeCommunicationDto, + SpecialUrlsDto, +} from './frontend-config.dto'; +import authConfiguration, { AuthConfig } from '../config/auth.config'; +import customizationConfiguration, { + CustomizationConfig, +} from '../config/customization.config'; +import appConfiguration, { AppConfig } from '../config/app.config'; +import externalServicesConfiguration, { + ExternalServicesConfig, +} from '../config/external-services.config'; +import { getServerVersionFromPackageJson } from '../utils/serverVersion'; +import { promises as fs, Stats } from 'fs'; +import { join } from 'path'; + +@Injectable() +export class FrontendConfigService { + constructor( + private readonly logger: ConsoleLoggerService, + @Inject(appConfiguration.KEY) + private appConfig: AppConfig, + @Inject(authConfiguration.KEY) + private authConfig: AuthConfig, + @Inject(customizationConfiguration.KEY) + private customizationConfig: CustomizationConfig, + @Inject(externalServicesConfiguration.KEY) + private externalServicesConfig: ExternalServicesConfig, + ) { + this.logger.setContext(FrontendConfigService.name); + } + + async getFrontendConfig(): Promise { + return { + // ToDo: use actual value here + allowAnonymous: false, + allowRegister: this.authConfig.email.enableRegister, + authProviders: this.getAuthProviders(), + banner: await FrontendConfigService.getBanner(), + branding: this.getBranding(), + customAuthNames: this.getCustomAuthNames(), + iframeCommunication: this.getIframeCommunication(), + maxDocumentLength: this.appConfig.maxDocumentLength, + plantUmlServer: this.externalServicesConfig.plantUmlServer + ? new URL(this.externalServicesConfig.plantUmlServer) + : undefined, + specialUrls: this.getSpecialUrls(), + useImageProxy: !!this.externalServicesConfig.imageProxy, + version: await getServerVersionFromPackageJson(), + }; + } + + private getAuthProviders(): AuthProviders { + return { + dropbox: !!this.authConfig.dropbox.clientID, + facebook: !!this.authConfig.facebook.clientID, + github: !!this.authConfig.github.clientID, + gitlab: this.authConfig.gitlab.length !== 0, + google: !!this.authConfig.google.clientID, + internal: this.authConfig.email.enableLogin, + ldap: this.authConfig.ldap.length !== 0, + oauth2: this.authConfig.oauth2.length !== 0, + saml: this.authConfig.saml.length !== 0, + twitter: !!this.authConfig.twitter.consumerKey, + }; + } + + private getCustomAuthNames(): CustomAuthNamesDto { + return { + gitlab: this.authConfig.gitlab.map((entry) => { + return { + identifier: entry.identifier, + providerName: entry.providerName, + }; + }), + ldap: this.authConfig.ldap.map((entry) => { + return { + identifier: entry.identifier, + providerName: entry.providerName, + }; + }), + oauth2: this.authConfig.oauth2.map((entry) => { + return { + identifier: entry.identifier, + providerName: entry.providerName, + }; + }), + saml: this.authConfig.saml.map((entry) => { + return { + identifier: entry.identifier, + providerName: entry.providerName, + }; + }), + }; + } + + private getBranding(): BrandingDto { + return { + logo: this.customizationConfig.branding.customLogo + ? new URL(this.customizationConfig.branding.customLogo) + : undefined, + name: this.customizationConfig.branding.customName, + }; + } + + private getSpecialUrls(): SpecialUrlsDto { + return { + imprint: this.customizationConfig.specialUrls.imprint + ? new URL(this.customizationConfig.specialUrls.imprint) + : undefined, + privacy: this.customizationConfig.specialUrls.privacy + ? new URL(this.customizationConfig.specialUrls.privacy) + : undefined, + termsOfUse: this.customizationConfig.specialUrls.termsOfUse + ? new URL(this.customizationConfig.specialUrls.termsOfUse) + : undefined, + }; + } + + private getIframeCommunication(): IframeCommunicationDto { + return { + editorOrigin: new URL(this.appConfig.domain), + rendererOrigin: this.appConfig.rendererOrigin + ? new URL(this.appConfig.rendererOrigin) + : new URL(this.appConfig.domain), + }; + } + + private static async getBanner(): Promise { + const path = join(__dirname, '../../banner.md'); + try { + const bannerContent: string = await fs.readFile(path, { + encoding: 'utf8', + }); + const fileStats: Stats = await fs.stat(path); + return { + text: bannerContent, + updateTime: fileStats.mtime, + }; + } catch (e) { + return { + text: '', + updateTime: new Date(0), + }; + } + } +} From dd7ca87337def0994622fb85b212e354e6ce043a Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 1 Mar 2021 21:16:34 +0100 Subject: [PATCH 09/10] PrivateApi: Add config controller Signed-off-by: Philip Molares --- .../private/config/config.controller.spec.ts | 44 +++++++++++++++++++ src/api/private/config/config.controller.ts | 25 +++++++++++ src/api/private/private-api.module.ts | 13 +++++- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 src/api/private/config/config.controller.spec.ts create mode 100644 src/api/private/config/config.controller.ts diff --git a/src/api/private/config/config.controller.spec.ts b/src/api/private/config/config.controller.spec.ts new file mode 100644 index 000000000..892fbe3c8 --- /dev/null +++ b/src/api/private/config/config.controller.spec.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigController } from './config.controller'; +import { LoggerModule } from '../../../logger/logger.module'; +import { FrontendConfigModule } from '../../../frontend-config/frontend-config.module'; +import { ConfigModule } from '@nestjs/config'; +import appConfigMock from '../../../config/mock/app.config.mock'; +import authConfigMock from '../../../config/mock/auth.config.mock'; +import customizationConfigMock from '../../../config/mock/customization.config.mock'; +import externalConfigMock from '../../../config/mock/external-services.config.mock'; + +describe('ConfigController', () => { + let controller: ConfigController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [ + appConfigMock, + authConfigMock, + customizationConfigMock, + externalConfigMock, + ], + }), + LoggerModule, + FrontendConfigModule, + ], + controllers: [ConfigController], + }).compile(); + + controller = module.get(ConfigController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/api/private/config/config.controller.ts b/src/api/private/config/config.controller.ts new file mode 100644 index 000000000..eeabf6239 --- /dev/null +++ b/src/api/private/config/config.controller.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Controller, Get } from '@nestjs/common'; +import { ConsoleLoggerService } from '../../../logger/console-logger.service'; +import { FrontendConfigService } from '../../../frontend-config/frontend-config.service'; +import { FrontendConfigDto } from '../../../frontend-config/frontend-config.dto'; + +@Controller('config') +export class ConfigController { + constructor( + private readonly logger: ConsoleLoggerService, + private frontendConfigService: FrontendConfigService, + ) { + this.logger.setContext(ConfigController.name); + } + + @Get() + async getFrontendConfig(): Promise { + return await this.frontendConfigService.getFrontendConfig(); + } +} diff --git a/src/api/private/private-api.module.ts b/src/api/private/private-api.module.ts index 3d6d8b4a2..bcc7c2210 100644 --- a/src/api/private/private-api.module.ts +++ b/src/api/private/private-api.module.ts @@ -9,12 +9,21 @@ import { TokensController } from './tokens/tokens.controller'; import { LoggerModule } from '../../logger/logger.module'; import { UsersModule } from '../../users/users.module'; import { AuthModule } from '../../auth/auth.module'; +import { ConfigController } from './config/config.controller'; +import { FrontendConfigModule } from '../../frontend-config/frontend-config.module'; import { HistoryController } from './me/history/history.controller'; import { HistoryModule } from '../../history/history.module'; import { NotesModule } from '../../notes/notes.module'; @Module({ - imports: [LoggerModule, UsersModule, AuthModule, HistoryModule, NotesModule], - controllers: [TokensController, HistoryController], + imports: [ + LoggerModule, + UsersModule, + AuthModule, + FrontendConfigModule, + HistoryModule, + NotesModule, + ], + controllers: [TokensController, ConfigController, HistoryController], }) export class PrivateApiModule {} From bd9235c5c9b438c40415ccb5c34dca669ea0259f Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sat, 20 Mar 2021 13:29:39 +0100 Subject: [PATCH 10/10] DevDocs: Add explanations for new config modules The new config modules `customization` and `external-services` are explained in the dev doc overview of all config modules now. Signed-off-by: Philip Molares --- docs/content/dev/config.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/content/dev/config.md b/docs/content/dev/config.md index c799bc1d2..5452ace90 100644 --- a/docs/content/dev/config.md +++ b/docs/content/dev/config.md @@ -8,7 +8,7 @@ NestJS - the framework we use - is reading the variables from the environment an ## How the config code works -The config of HedgeDoc is split up into **six** different modules: +The config of HedgeDoc is split up into **eight** different modules: `app.config.ts` : General configuration of the app @@ -19,9 +19,15 @@ The config of HedgeDoc is split up into **six** different modules: `csp.config.ts` : Configuration for [Content Security Policy][csp] +`customization.config.ts` +: Config to customize the instance and set instance specific links + `database.config.ts` : Which database should be used +`external-services.config.ts` +: Which external services are activated and where can they be called + `hsts.config.ts` : Configuration for [HTTP Strict-Transport-Security][hsts]