Merge pull request #980 from hedgedoc/privateApi/frontendConfig

This commit is contained in:
David Mehren 2021-03-24 22:14:45 +01:00 committed by GitHub
commit e66c3a9ceb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1274 additions and 120 deletions

View file

@ -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]

View file

@ -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>(ConfigController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View file

@ -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<FrontendConfigDto> {
return await this.frontendConfigService.getFrontendConfig();
}
}

View file

@ -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;

View file

@ -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 {}

View file

@ -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,

View file

@ -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';

View file

@ -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;

View file

@ -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,
}),
@ -70,8 +76,9 @@ const routes: Routes = [
LoggerModule,
MediaModule,
AuthModule,
FrontendConfigModule,
],
controllers: [],
providers: [],
providers: [FrontendConfigService],
})
export class AppModule {}

View file

@ -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,

View file

@ -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:

View file

@ -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;
});

View file

@ -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;
});

View file

@ -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'],
}));

View file

@ -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: [],
}));

View file

@ -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',
},
}));

View file

@ -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',
}));

View file

@ -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;
}

View file

@ -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 {}

View file

@ -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;
}
}
}
}
}
}
}
}
}
}
}
}
});

View file

@ -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<FrontendConfigDto> {
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<BannerDto> {
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),
};
}
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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<ServerVersion> {
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 {

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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<ServerVersion> {
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;
}

View file

@ -14,8 +14,11 @@ 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 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,

View file

@ -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';

View file

@ -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';

View file

@ -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';