refactor(backend): config validation joi to zod

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2025-03-29 23:11:01 +01:00 committed by Philip Molares
parent 91ebd519a8
commit 748702daf5
37 changed files with 1299 additions and 994 deletions

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -19,7 +19,9 @@ import appConfig from './config/app.config';
import authConfig from './config/auth.config'; import authConfig from './config/auth.config';
import cspConfig from './config/csp.config'; import cspConfig from './config/csp.config';
import customizationConfig from './config/customization.config'; import customizationConfig from './config/customization.config';
import databaseConfig, { DatabaseConfig } from './config/database.config'; import databaseConfig, {
PostgresDatabaseConfig,
} from './config/database.config';
import externalConfig from './config/external-services.config'; import externalConfig from './config/external-services.config';
import mediaConfig from './config/media.config'; import mediaConfig from './config/media.config';
import noteConfig from './config/note.config'; import noteConfig from './config/note.config';
@ -63,7 +65,7 @@ const routes: Routes = [
imports: [ConfigModule, LoggerModule], imports: [ConfigModule, LoggerModule],
inject: [databaseConfig.KEY, TypeormLoggerService], inject: [databaseConfig.KEY, TypeormLoggerService],
useFactory: ( useFactory: (
databaseConfig: DatabaseConfig, databaseConfig: PostgresDatabaseConfig,
logger: TypeormLoggerService, logger: TypeormLoggerService,
) => { ) => {
return { return {
@ -72,7 +74,7 @@ const routes: Routes = [
port: databaseConfig.port, port: databaseConfig.port,
username: databaseConfig.username, username: databaseConfig.username,
password: databaseConfig.password, password: databaseConfig.password,
database: databaseConfig.database, database: databaseConfig.name,
autoLoadEntities: true, autoLoadEntities: true,
logging: true, logging: true,
logger: logger, logger: logger,

View file

@ -16,7 +16,7 @@ import LdapAuth from 'ldapauth-fork';
import authConfiguration, { import authConfiguration, {
AuthConfig, AuthConfig,
LDAPConfig, LdapConfig,
} from '../../config/auth.config'; } from '../../config/auth.config';
import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../logger/console-logger.service';
@ -47,7 +47,7 @@ export class LdapService {
/** /**
* Try to log in the user with the given credentials. * Try to log in the user with the given credentials.
* *
* @param ldapConfig {LDAPConfig} - the ldap config to use * @param ldapConfig {LdapConfig} - the ldap config to use
* @param username {string} - the username to log in with * @param username {string} - the username to log in with
* @param password {string} - the password to log in with * @param password {string} - the password to log in with
* @returns {FullUserInfoWithIdDto} - the user info of the user that logged in * @returns {FullUserInfoWithIdDto} - the user info of the user that logged in
@ -56,7 +56,7 @@ export class LdapService {
* @private * @private
*/ */
getUserInfoFromLdap( getUserInfoFromLdap(
ldapConfig: LDAPConfig, ldapConfig: LdapConfig,
username: string, // This is not of type Username, because LDAP server may use mixed case usernames username: string, // This is not of type Username, because LDAP server may use mixed case usernames
password: string, password: string,
): Promise<FullUserInfoWithIdDto> { ): Promise<FullUserInfoWithIdDto> {
@ -120,12 +120,12 @@ export class LdapService {
/** /**
* Get and return the correct ldap config from the list of available configs. * Get and return the correct ldap config from the list of available configs.
* @param {string} ldapIdentifier the identifier for the ldap config to be used * @param {string} ldapIdentifier the identifier for the ldap config to be used
* @returns {LDAPConfig} - the ldap config with the given identifier * @returns {LdapConfig} - the ldap config with the given identifier
* @throws {NotFoundException} - there is no ldap config with the given identifier * @throws {NotFoundException} - there is no ldap config with the given identifier
* @private * @private
*/ */
getLdapConfig(ldapIdentifier: string): LDAPConfig { getLdapConfig(ldapIdentifier: string): LdapConfig {
const ldapConfig: LDAPConfig | undefined = this.authConfig.ldap.find( const ldapConfig = this.authConfig.ldap.find(
(config) => config.identifier === ldapIdentifier, (config) => config.identifier === ldapIdentifier,
); );
if (!ldapConfig) { if (!ldapConfig) {

View file

@ -94,7 +94,7 @@ export class OidcService {
const redirectUri = `${this.appConfig.baseUrl}/api/private/auth/oidc/${oidcConfig.identifier}/callback`; const redirectUri = `${this.appConfig.baseUrl}/api/private/auth/oidc/${oidcConfig.identifier}/callback`;
const client = new issuer.Client({ const client = new issuer.Client({
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
client_id: oidcConfig.clientID, client_id: oidcConfig.clientId,
client_secret: oidcConfig.clientSecret, client_secret: oidcConfig.clientSecret,
redirect_uris: [redirectUri], redirect_uris: [redirectUri],
response_types: ['code'], response_types: ['code'],
@ -205,7 +205,7 @@ export class OidcService {
); );
const username = OidcService.getResponseFieldValue( const username = OidcService.getResponseFieldValue(
userInfoResponse, userInfoResponse,
oidcConfig.userNameField, oidcConfig.usernameField,
userId, userId,
).toLowerCase() as Lowercase<string>; ).toLowerCase() as Lowercase<string>;
const displayName = OidcService.getResponseFieldValue( const displayName = OidcService.getResponseFieldValue(

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -42,7 +42,7 @@ describe('appConfig', () => {
const config = appConfig(); const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl); expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl); expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
expect(config.port).toEqual(port); expect(config.backendPort).toEqual(port);
expect(config.loglevel).toEqual(loglevel); expect(config.loglevel).toEqual(loglevel);
expect(config.showLogTimestamp).toEqual(showLogTimestamp); expect(config.showLogTimestamp).toEqual(showLogTimestamp);
expect(config.persistInterval).toEqual(100); expect(config.persistInterval).toEqual(100);
@ -67,7 +67,7 @@ describe('appConfig', () => {
const config = appConfig(); const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl); expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(baseUrl); expect(config.rendererBaseUrl).toEqual(baseUrl);
expect(config.port).toEqual(port); expect(config.backendPort).toEqual(port);
expect(config.loglevel).toEqual(loglevel); expect(config.loglevel).toEqual(loglevel);
expect(config.showLogTimestamp).toEqual(showLogTimestamp); expect(config.showLogTimestamp).toEqual(showLogTimestamp);
expect(config.persistInterval).toEqual(100); expect(config.persistInterval).toEqual(100);
@ -92,7 +92,7 @@ describe('appConfig', () => {
const config = appConfig(); const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl); expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl); expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
expect(config.port).toEqual(3000); expect(config.backendPort).toEqual(3000);
expect(config.loglevel).toEqual(loglevel); expect(config.loglevel).toEqual(loglevel);
expect(config.showLogTimestamp).toEqual(showLogTimestamp); expect(config.showLogTimestamp).toEqual(showLogTimestamp);
expect(config.persistInterval).toEqual(100); expect(config.persistInterval).toEqual(100);
@ -117,7 +117,7 @@ describe('appConfig', () => {
const config = appConfig(); const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl); expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl); expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
expect(config.port).toEqual(port); expect(config.backendPort).toEqual(port);
expect(config.loglevel).toEqual(Loglevel.WARN); expect(config.loglevel).toEqual(Loglevel.WARN);
expect(config.showLogTimestamp).toEqual(showLogTimestamp); expect(config.showLogTimestamp).toEqual(showLogTimestamp);
expect(config.persistInterval).toEqual(100); expect(config.persistInterval).toEqual(100);
@ -142,7 +142,7 @@ describe('appConfig', () => {
const config = appConfig(); const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl); expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl); expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
expect(config.port).toEqual(port); expect(config.backendPort).toEqual(port);
expect(config.loglevel).toEqual(Loglevel.TRACE); expect(config.loglevel).toEqual(Loglevel.TRACE);
expect(config.showLogTimestamp).toEqual(showLogTimestamp); expect(config.showLogTimestamp).toEqual(showLogTimestamp);
expect(config.persistInterval).toEqual(10); expect(config.persistInterval).toEqual(10);
@ -168,7 +168,7 @@ describe('appConfig', () => {
const config = appConfig(); const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl); expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl); expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
expect(config.port).toEqual(port); expect(config.backendPort).toEqual(port);
expect(config.loglevel).toEqual(Loglevel.TRACE); expect(config.loglevel).toEqual(Loglevel.TRACE);
expect(config.showLogTimestamp).toEqual(showLogTimestamp); expect(config.showLogTimestamp).toEqual(showLogTimestamp);
expect(config.persistInterval).toEqual(0); expect(config.persistInterval).toEqual(0);
@ -192,7 +192,7 @@ describe('appConfig', () => {
const config = appConfig(); const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl); expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl); expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
expect(config.port).toEqual(port); expect(config.backendPort).toEqual(port);
expect(config.loglevel).toEqual(Loglevel.TRACE); expect(config.loglevel).toEqual(Loglevel.TRACE);
expect(config.showLogTimestamp).toEqual(true); expect(config.showLogTimestamp).toEqual(true);
expect(config.persistInterval).toEqual(0); expect(config.persistInterval).toEqual(0);
@ -232,7 +232,7 @@ describe('appConfig', () => {
}, },
); );
expect(() => appConfig()).toThrow( expect(() => appConfig()).toThrow(
'"HD_BASE_URL" must not contain a subdirectory', 'HD_BASE_URL: baseUrl must not contain a subdirectory',
); );
restore(); restore();
}); });
@ -252,7 +252,7 @@ describe('appConfig', () => {
}, },
); );
expect(() => appConfig()).toThrow( expect(() => appConfig()).toThrow(
'"HD_BACKEND_PORT" must be a positive number', 'HD_BACKEND_PORT: Number must be greater than 0',
); );
restore(); restore();
}); });
@ -272,7 +272,7 @@ describe('appConfig', () => {
}, },
); );
expect(() => appConfig()).toThrow( expect(() => appConfig()).toThrow(
'"HD_BACKEND_PORT" must be less than or equal to 65535', 'HD_BACKEND_PORT: Number must be less than or equal to 65535',
); );
restore(); restore();
}); });
@ -291,7 +291,9 @@ describe('appConfig', () => {
clear: true, clear: true,
}, },
); );
expect(() => appConfig()).toThrow('"HD_BACKEND_PORT" must be an integer'); expect(() => appConfig()).toThrow(
'HD_BACKEND_PORT: Expected integer, received float',
);
restore(); restore();
}); });
@ -309,7 +311,9 @@ describe('appConfig', () => {
clear: true, clear: true,
}, },
); );
expect(() => appConfig()).toThrow('"HD_BACKEND_PORT" must be a number'); expect(() => appConfig()).toThrow(
'HD_BACKEND_PORT: Expected number, received nan',
);
restore(); restore();
}); });

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -9,94 +9,104 @@ import {
WrongProtocolError, WrongProtocolError,
} from '@hedgedoc/commons'; } from '@hedgedoc/commons';
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import * as Joi from 'joi'; import z, { RefinementCtx } from 'zod';
import { CustomHelpers, ErrorReport } from 'joi';
import { Loglevel } from './loglevel.enum'; import { Loglevel } from './loglevel.enum';
import { buildErrorMessage, parseOptionalNumber } from './utils'; import { parseOptionalBoolean, parseOptionalNumber } from './utils';
import {
buildErrorMessage,
extractDescriptionFromZodIssue,
} from './zod-error-message';
export interface AppConfig { function validateUrl(value: string | undefined, ctx: RefinementCtx): void {
baseUrl: string; if (!value) {
rendererBaseUrl: string; return z.NEVER;
port: number; }
loglevel: Loglevel;
showLogTimestamp: boolean;
persistInterval: number;
}
function validateUrl(
value: string,
helpers: CustomHelpers,
): string | ErrorReport {
try { try {
return parseUrl(value).isPresent() ? value : helpers.error('string.uri'); if (!parseUrl(value).isPresent()) {
ctx.addIssue({
code: z.ZodIssueCode.invalid_string,
message: "Can't parse as URL",
fatal: true,
validation: 'url',
});
return z.NEVER;
}
} catch (error) { } catch (error) {
if (error instanceof NoSubdirectoryAllowedError) { if (error instanceof NoSubdirectoryAllowedError) {
return helpers.error('url.noSubDirectoryAllowed'); ctx.addIssue({
code: z.ZodIssueCode.invalid_string,
message: ctx.path[0] + ' must not contain a subdirectory',
fatal: true,
validation: 'url',
});
} else if (error instanceof WrongProtocolError) { } else if (error instanceof WrongProtocolError) {
return helpers.error('url.wrongProtocol'); ctx.addIssue({
code: z.ZodIssueCode.invalid_string,
message: ctx.path[0] + ' protocol must be HTTP or HTTPS',
fatal: true,
validation: 'url',
});
} else { } else {
throw error; throw error;
} }
} }
} }
const schema = Joi.object({ const schema = z
baseUrl: Joi.string().custom(validateUrl).label('HD_BASE_URL'), .object({
rendererBaseUrl: Joi.string() baseUrl: z.string().superRefine(validateUrl).describe('HD_BASE_URL'),
.custom(validateUrl) rendererBaseUrl: z
.default(Joi.ref('baseUrl')) .string()
.optional() .superRefine(validateUrl)
.label('HD_RENDERER_BASE_URL'), .default('')
port: Joi.number() .describe('HD_RENDERER_BASE_URL'),
.positive() backendPort: z
.integer() .number()
.default(3000) .positive()
.max(65535) .int()
.optional() .max(65535)
.label('HD_BACKEND_PORT'), .default(3000)
loglevel: Joi.string() .describe('HD_BACKEND_PORT'),
.valid(...Object.values(Loglevel)) loglevel: z
.default(Loglevel.WARN) .enum(Object.values(Loglevel) as [Loglevel, ...Loglevel[]])
.optional() .default(Loglevel.WARN)
.label('HD_LOGLEVEL'), .describe('HD_LOGLEVEL'),
showLogTimestamp: Joi.boolean() showLogTimestamp: z
.default(true) .boolean()
.optional() .default(true)
.label('HD_SHOW_LOG_TIMESTAMP'), .describe('HD_SHOW_LOG_TIMESTAMP'),
persistInterval: Joi.number() persistInterval: z.coerce
.integer() .number()
.min(0) .int()
.default(10) .min(0)
.optional() .default(10)
.label('HD_PERSIST_INTERVAL'), .describe('HD_PERSIST_INTERVAL'),
}).messages({ })
// eslint-disable-next-line @typescript-eslint/naming-convention .transform((data) => {
'url.noSubDirectoryAllowed': '{{#label}} must not contain a subdirectory', // Handle the default reference for rendererBaseUrl
// eslint-disable-next-line @typescript-eslint/naming-convention if (data.rendererBaseUrl === '') {
'url.wrongProtocol': '{{#label}} protocol must be HTTP or HTTPS', data.rendererBaseUrl = data.baseUrl;
}); }
return data;
});
export type AppConfig = z.infer<typeof schema>;
export default registerAs('appConfig', () => { export default registerAs('appConfig', () => {
const appConfig = schema.validate( const appConfig = schema.safeParse({
{ baseUrl: process.env.HD_BASE_URL,
baseUrl: process.env.HD_BASE_URL, rendererBaseUrl: process.env.HD_RENDERER_BASE_URL,
rendererBaseUrl: process.env.HD_RENDERER_BASE_URL, backendPort: parseOptionalNumber(process.env.HD_BACKEND_PORT),
port: parseOptionalNumber(process.env.HD_BACKEND_PORT), loglevel: process.env.HD_LOGLEVEL,
loglevel: process.env.HD_LOGLEVEL, showLogTimestamp: parseOptionalBoolean(process.env.HD_SHOW_LOG_TIMESTAMP),
showLogTimestamp: process.env.HD_SHOW_LOG_TIMESTAMP, persistInterval: process.env.HD_PERSIST_INTERVAL,
persistInterval: process.env.HD_PERSIST_INTERVAL, });
},
{
abortEarly: false,
presence: 'required',
},
);
if (appConfig.error) { if (appConfig.error) {
const errorMessages = appConfig.error.details.map( const errorMessages = appConfig.error.errors.map((issue) =>
(detail) => detail.message, extractDescriptionFromZodIssue(issue, 'HD'),
); );
throw new Error(buildErrorMessage(errorMessages)); throw new Error(buildErrorMessage(errorMessages));
} }
return appConfig.value as AppConfig; return appConfig.data;
}); });

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -129,7 +129,7 @@ describe('authConfig', () => {
}, },
); );
expect(() => authConfig()).toThrow( expect(() => authConfig()).toThrow(
'"HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH" must be less than or equal to 4', 'HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH: Number must be less than or equal to 4',
); );
restore(); restore();
}); });
@ -147,7 +147,7 @@ describe('authConfig', () => {
}, },
); );
expect(() => authConfig()).toThrow( expect(() => authConfig()).toThrow(
'"HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH" must be greater than or equal to 0', 'HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH: Number must be greater than or equal to 0',
); );
restore(); restore();
}); });
@ -200,6 +200,7 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1); expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0]; const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]); expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -232,6 +233,7 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1); expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0]; const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]); expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -263,6 +265,7 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1); expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0]; const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]); expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -294,6 +297,7 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1); expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0]; const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]); expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -325,6 +329,7 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1); expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0]; const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]); expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -356,6 +361,7 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1); expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0]; const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]); expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -387,6 +393,7 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1); expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0]; const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]); expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -418,6 +425,7 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1); expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0]; const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]); expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -449,6 +457,7 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1); expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0]; const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]); expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -481,7 +490,7 @@ describe('authConfig', () => {
}, },
); );
expect(() => authConfig()).toThrow( expect(() => authConfig()).toThrow(
'"HD_AUTH_LDAP_FUTURAMA_URL" is required', 'HD_AUTH_LDAP_FUTURAMA_URL: Required',
); );
restore(); restore();
}); });
@ -499,7 +508,7 @@ describe('authConfig', () => {
}, },
); );
expect(() => authConfig()).toThrow( expect(() => authConfig()).toThrow(
'"HD_AUTH_LDAP_FUTURAMA_SEARCH_BASE" is required', 'HD_AUTH_LDAP_FUTURAMA_SEARCH_BASE: Required',
); );
restore(); restore();
}); });
@ -517,7 +526,7 @@ describe('authConfig', () => {
}, },
); );
expect(() => authConfig()).toThrow( expect(() => authConfig()).toThrow(
'"HD_AUTH_LDAP_FUTURAMA_TLS_CERT_PATHS[0]" must not be a sparse array item', 'HD_AUTH_LDAP_FUTURAMA_TLS_CA_CERTS[0]: File not found',
); );
restore(); restore();
}); });
@ -582,11 +591,12 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1); expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0]; const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]); expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer); expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId); expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret); expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme); expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl); expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -595,7 +605,7 @@ describe('authConfig', () => {
expect(firstOidc.scope).toEqual(scope); expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl); expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.userIdField).toEqual(userIdField); expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField); expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField); expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField); expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField); expect(firstOidc.emailField).toEqual(emailField);
@ -616,11 +626,12 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1); expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0]; const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]); expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer); expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId); expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret); expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toBeUndefined(); expect(firstOidc.theme).toBeUndefined();
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl); expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -629,7 +640,7 @@ describe('authConfig', () => {
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl); expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.scope).toEqual(scope); expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField); expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField); expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField); expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField); expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField); expect(firstOidc.emailField).toEqual(emailField);
@ -650,11 +661,12 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1); expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0]; const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]); expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer); expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId); expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret); expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme); expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toBeUndefined(); expect(firstOidc.authorizeUrl).toBeUndefined();
@ -663,7 +675,7 @@ describe('authConfig', () => {
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl); expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.scope).toEqual(scope); expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField); expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField); expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField); expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField); expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField); expect(firstOidc.emailField).toEqual(emailField);
@ -684,11 +696,12 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1); expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0]; const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]); expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer); expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId); expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret); expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme); expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl); expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -697,7 +710,7 @@ describe('authConfig', () => {
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl); expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.scope).toEqual(scope); expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField); expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField); expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField); expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField); expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField); expect(firstOidc.emailField).toEqual(emailField);
@ -718,11 +731,12 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1); expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0]; const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]); expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer); expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId); expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret); expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme); expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl); expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -731,7 +745,7 @@ describe('authConfig', () => {
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl); expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.scope).toEqual(scope); expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField); expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField); expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField); expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField); expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField); expect(firstOidc.emailField).toEqual(emailField);
@ -752,11 +766,12 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1); expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0]; const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]); expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer); expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId); expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret); expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme); expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl); expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -765,7 +780,7 @@ describe('authConfig', () => {
expect(firstOidc.endSessionUrl).toBeUndefined(); expect(firstOidc.endSessionUrl).toBeUndefined();
expect(firstOidc.scope).toEqual(scope); expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField); expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField); expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField); expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField); expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField); expect(firstOidc.emailField).toEqual(emailField);
@ -786,11 +801,12 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1); expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0]; const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]); expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer); expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId); expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret); expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme); expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl); expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -799,7 +815,7 @@ describe('authConfig', () => {
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl); expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.scope).toEqual(defaultScope); expect(firstOidc.scope).toEqual(defaultScope);
expect(firstOidc.userIdField).toEqual(userIdField); expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField); expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField); expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField); expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField); expect(firstOidc.emailField).toEqual(emailField);
@ -820,11 +836,12 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1); expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0]; const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]); expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer); expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId); expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret); expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme); expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl); expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -833,7 +850,7 @@ describe('authConfig', () => {
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl); expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl); expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.userIdField).toEqual(defaultUserIdField); expect(firstOidc.userIdField).toEqual(defaultUserIdField);
expect(firstOidc.userNameField).toEqual(userNameField); expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField); expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField); expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField); expect(firstOidc.emailField).toEqual(emailField);
@ -854,11 +871,12 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1); expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0]; const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]); expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer); expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId); expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret); expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme); expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl); expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -867,7 +885,7 @@ describe('authConfig', () => {
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl); expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl); expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.userIdField).toEqual(userIdField); expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField); expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(defaultDisplayNameField); expect(firstOidc.displayNameField).toEqual(defaultDisplayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField); expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField); expect(firstOidc.emailField).toEqual(emailField);
@ -888,11 +906,12 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1); expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0]; const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]); expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer); expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId); expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret); expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme); expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl); expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -901,7 +920,7 @@ describe('authConfig', () => {
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl); expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl); expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.userIdField).toEqual(userIdField); expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField); expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField); expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual( expect(firstOidc.profilePictureField).toEqual(
defaultProfilePictureField, defaultProfilePictureField,
@ -924,11 +943,12 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1); expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0]; const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]); expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer); expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId); expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret); expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme); expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl); expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -937,7 +957,7 @@ describe('authConfig', () => {
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl); expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl); expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.userIdField).toEqual(userIdField); expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField); expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField); expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField); expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(defaultEmailField); expect(firstOidc.emailField).toEqual(defaultEmailField);
@ -958,11 +978,12 @@ describe('authConfig', () => {
}, },
); );
const config = authConfig(); const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1); expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0]; const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]); expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer); expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId); expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret); expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme); expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl); expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -971,7 +992,7 @@ describe('authConfig', () => {
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl); expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl); expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.userIdField).toEqual(userIdField); expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField); expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField); expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField); expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField); expect(firstOidc.emailField).toEqual(emailField);
@ -994,7 +1015,7 @@ describe('authConfig', () => {
}, },
); );
expect(() => authConfig()).toThrow( expect(() => authConfig()).toThrow(
'"HD_AUTH_OIDC_GITLAB_ISSUER" is required', 'HD_AUTH_OIDC_GITLAB_ISSUER: Required',
); );
restore(); restore();
}); });
@ -1012,7 +1033,7 @@ describe('authConfig', () => {
}, },
); );
expect(() => authConfig()).toThrow( expect(() => authConfig()).toThrow(
'"HD_AUTH_OIDC_GITLAB_CLIENT_ID" is required', 'HD_AUTH_OIDC_GITLAB_CLIENT_ID: Required',
); );
restore(); restore();
}); });
@ -1030,7 +1051,7 @@ describe('authConfig', () => {
}, },
); );
expect(() => authConfig()).toThrow( expect(() => authConfig()).toThrow(
'"HD_AUTH_OIDC_GITLAB_CLIENT_SECRET" is required', 'HD_AUTH_OIDC_GITLAB_CLIENT_SECRET: Required',
); );
restore(); restore();
}); });
@ -1047,7 +1068,9 @@ describe('authConfig', () => {
clear: true, clear: true,
}, },
); );
expect(() => authConfig()).toThrow('"HD_AUTH_OIDC_GITLAB_THEME"'); expect(() => authConfig()).toThrow(
"HD_AUTH_OIDC_GITLAB_THEME: Invalid enum value. Expected 'google' | 'github' | 'gitlab' | 'facebook' | 'discord' | 'mastodon' | 'azure', received 'something else'",
);
restore(); restore();
}); });
}); });

View file

@ -1,174 +1,220 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import * as fs from 'fs'; import fs from 'fs';
import * as Joi from 'joi'; import z from 'zod';
import { Theme } from './theme.enum'; import { Theme } from './theme.enum';
import { import {
buildErrorMessage,
ensureNoDuplicatesExist, ensureNoDuplicatesExist,
parseOptionalBoolean, parseOptionalBoolean,
parseOptionalNumber, parseOptionalNumber,
replaceAuthErrorsWithEnvironmentVariables,
toArrayConfig, toArrayConfig,
} from './utils'; } from './utils';
import {
buildErrorMessage,
extractDescriptionFromZodIssue,
} from './zod-error-message';
export interface InternalIdentifier { const ldapSchema = z
identifier: string; .object({
providerName: string; identifier: z.string().describe('HD_AUTH_LDAP_SERVERS'),
} providerName: z
.string()
export interface LDAPConfig extends InternalIdentifier { .default('LDAP')
url: string; .describe('HD_AUTH_LDAP_*_PROVIDER_NAME'),
bindDn?: string; url: z.string().describe('HD_AUTH_LDAP_*_URL'),
bindCredentials?: string; bindDn: z.string().optional().describe('HD_AUTH_LDAP_*_BIND_DN'),
searchBase: string; bindCredentials: z
searchFilter: string; .string()
searchAttributes: string[]; .optional()
userIdField: string; .describe('HD_AUTH_LDAP_*_BIND_CREDENTIALS'),
displayNameField: string; searchBase: z.string().describe('HD_AUTH_LDAP_*_SEARCH_BASE'),
emailField: string; searchFilter: z
profilePictureField: string; .string()
tlsCaCerts?: string[]; .default('(uid={{username}})')
} .describe('HD_AUTH_LDAP_*_SEARCH_FILTER'),
searchAttributes: z
export interface OidcConfig extends InternalIdentifier { .array(z.string())
issuer: string; .optional()
clientID: string; .describe('HD_AUTH_LDAP_*_SEARCH_ATTRIBUTES'),
clientSecret: string; userIdField: z
theme?: string; .string()
authorizeUrl?: string; .default('uid')
tokenUrl?: string; .describe('HD_AUTH_LDAP_*_USER_ID_FIELD'),
userinfoUrl?: string; displayNameField: z
endSessionUrl?: string; .string()
scope: string; .default('displayName')
userNameField: string; .describe('HD_AUTH_LDAP_*_DISPLAY_NAME_FIELD'),
userIdField: string; emailField: z
displayNameField: string; .string()
profilePictureField: string; .default('mail')
emailField: string; .describe('HD_AUTH_LDAP_*_EMAIL_FIELD'),
enableRegistration?: boolean; profilePictureField: z
} .string()
.default('jpegPhoto')
export interface AuthConfig { .describe('HD_AUTH_LDAP_*_PROFILE_PICTURE_FIELD'),
common: { tlsCaCerts: z
allowProfileEdits: boolean; .array(
allowChooseUsername: boolean; z.string({
syncSource?: string; // eslint-disable-next-line @typescript-eslint/naming-convention
}; required_error: 'File not found',
session: { }),
secret: string; )
lifetime: number; .optional()
}; .describe('HD_AUTH_LDAP_*_TLS_CA_CERTS'),
local: { tlsRejectUnauthorized: z
enableLogin: boolean; .boolean()
enableRegister: boolean;
minimalPasswordStrength: number;
};
// ToDo: tlsOptions exist in config.json.example. See https://nodejs.org/api/tls.html#tls_tls_connect_options_callback
ldap: LDAPConfig[];
oidc: OidcConfig[];
}
const authSchema = Joi.object({
common: {
allowProfileEdits: Joi.boolean()
.default(true) .default(true)
.describe('HD_AUTH_LDAP_*_TLS_REJECT_UNAUTHORIZED'),
tlsSniName: z.string().optional().describe('HD_AUTH_LDAP_*_TLS_SNI_NAME'),
tlsAllowPartialTrustChain: z
.boolean()
.optional() .optional()
.label('HD_AUTH_ALLOW_PROFILE_EDITS'), .describe('HD_AUTH_LDAP_*_TLS_ALLOW_PARTIAL_TRUST_CHAIN'),
allowChooseUsername: Joi.boolean() tlsMinVersion: z
.default(true) .enum(['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'])
.optional() .optional()
.label('HD_AUTH_ALLOW_CHOOSE_USERNAME'), .describe('HD_AUTH_LDAP_*_TLS_MIN_VERSION'),
syncSource: Joi.string().optional().label('HD_AUTH_SYNC_SOURCE'), tlsMaxVersion: z
}, .enum(['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'])
session: {
secret: Joi.string().label('HD_SESSION_SECRET'),
lifetime: Joi.number()
.default(1209600) // 14 * 24 * 60 * 60s = 14 days
.optional() .optional()
.label('HD_SESSION_LIFETIME'), .describe('HD_AUTH_LDAP_*_TLS_MAX_VERSION'),
}, })
local: { .superRefine((config, ctx) => {
enableLogin: Joi.boolean() const tlsMin = config.tlsMinVersion?.replace('TLSv', '');
.default(false) const tlsMax = config.tlsMaxVersion?.replace('TLSv', '');
.optional() if (tlsMin && tlsMax && tlsMin > tlsMax) {
.label('HD_AUTH_LOCAL_ENABLE_LOGIN'), ctx.addIssue({
enableRegister: Joi.boolean() code: z.ZodIssueCode.custom,
.default(false) message:
.optional() 'TLS min version must be less than or equal to TLS max version',
.label('HD_AUTH_LOCAL_ENABLE_REGISTER'), fatal: true,
minimalPasswordStrength: Joi.number() });
.default(2) }
.min(0) if ((tlsMin && tlsMin < '1.2') || (tlsMax && tlsMax < '1.2')) {
.max(4) ctx.addIssue({
.optional() code: z.ZodIssueCode.custom,
.label('HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH'), message:
}, 'For security reasons, consider using TLS version 1.2 or higher',
ldap: Joi.array() fatal: false,
.items( });
Joi.object({ }
identifier: Joi.string(), });
providerName: Joi.string().default('LDAP').optional(),
url: Joi.string(), const oidcSchema = z.object({
bindDn: Joi.string().optional(), identifier: z.string().describe('HD_AUTH_OIDC_SERVERS'),
bindCredentials: Joi.string().optional(), providerName: z
searchBase: Joi.string(), .string()
searchFilter: Joi.string().default('(uid={{username}})').optional(), .default('OpenID Connect')
searchAttributes: Joi.array().items(Joi.string()).optional(), .describe('HD_AUTH_OIDC_*_PROVIDER_NAME'),
userIdField: Joi.string().default('uid').optional(), issuer: z.string().url().describe('HD_AUTH_OIDC_*_ISSUER'),
displayNameField: Joi.string().default('displayName').optional(), clientId: z.string().describe('HD_AUTH_OIDC_*_CLIENT_ID'),
emailField: Joi.string().default('mail').optional(), clientSecret: z.string().describe('HD_AUTH_OIDC_*_CLIENT_SECRET'),
profilePictureField: Joi.string().default('jpegPhoto').optional(), theme: z.nativeEnum(Theme).optional().describe('HD_AUTH_OIDC_*_THEME'),
tlsCaCerts: Joi.array().items(Joi.string()).optional(), authorizeUrl: z
}).optional(), .string()
) .url()
.optional(), .optional()
oidc: Joi.array() .describe('HD_AUTH_OIDC_*_AUTHORIZE_URL'),
.items( tokenUrl: z.string().url().optional().describe('HD_AUTH_OIDC_*_TOKEN_URL'),
Joi.object({ userinfoUrl: z
identifier: Joi.string(), .string()
providerName: Joi.string().default('OpenID Connect').optional(), .url()
issuer: Joi.string(), .optional()
clientID: Joi.string(), .describe('HD_AUTH_OIDC_*_USERINFO_URL'),
clientSecret: Joi.string(), endSessionUrl: z
theme: Joi.string() .string()
.valid(...Object.values(Theme)) .url()
.optional(), .optional()
authorizeUrl: Joi.string().optional(), .describe('HD_AUTH_OIDC_*_END_SESSION_URL'),
tokenUrl: Joi.string().optional(), scope: z
userinfoUrl: Joi.string().optional(), .string()
endSessionUrl: Joi.string().optional(), .default('openid profile email')
scope: Joi.string().default('openid profile email').optional(), .describe('HD_AUTH_OIDC_*_SCOPE'),
userIdField: Joi.string().default('sub').optional(), usernameField: z
userNameField: Joi.string().default('preferred_username').optional(), .string()
displayNameField: Joi.string().default('name').optional(), .default('preferred_username')
profilePictureField: Joi.string().default('picture').optional(), .describe('HD_AUTH_OIDC_*_USERNAME_FIELD'),
emailField: Joi.string().default('email').optional(), userIdField: z
enableRegistration: Joi.boolean().default(true).optional(), .string()
}).optional(), .default('sub')
) .describe('HD_AUTH_OIDC_*_USER_ID_FIELD'),
.optional(), displayNameField: z
.string()
.default('name')
.describe('HD_AUTH_OIDC_*_DISPLAY_NAME_FIELD'),
profilePictureField: z
.string()
.default('picture')
.describe('HD_AUTH_OIDC_*_PROFILE_PICTURE_FIELD'),
emailField: z
.string()
.default('email')
.describe('HD_AUTH_OIDC_*_EMAIL_FIELD'),
enableRegistration: z
.boolean()
.default(true)
.describe('HD_AUTH_OIDC_*_ENABLE_REGISTRATION'),
}); });
const schema = z.object({
common: z.object({
allowProfileEdits: z
.boolean()
.default(true)
.describe('HD_AUTH_ALLOW_PROFILE_EDITS'),
allowChooseUsername: z
.boolean()
.default(true)
.describe('HD_AUTH_ALLOW_CHOOSE_USERNAME'),
syncSource: z.string().optional().describe('HD_AUTH_SYNC_SOURCE'),
}),
session: z.object({
secret: z.string().describe('HD_SESSION_SECRET'),
lifetime: z.number().default(1209600).describe('HD_SESSION_LIFETIME'), // 14 * 24 * 60 * 60s = 14 days
}),
local: z.object({
enableLogin: z
.boolean()
.default(false)
.describe('HD_AUTH_LOCAL_ENABLE_LOGIN'),
enableRegister: z
.boolean()
.default(false)
.describe('HD_AUTH_LOCAL_ENABLE_REGISTER'),
minimalPasswordStrength: z.coerce
.number()
.min(0)
.max(4)
.default(2)
.describe('HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH'),
}),
ldap: z.array(ldapSchema).describe('HD_AUTH_LDAP_*'),
oidc: z.array(oidcSchema).describe('HD_AUTH_OIDC_*'),
});
export type AuthConfig = z.infer<typeof schema>;
export type LdapConfig = z.infer<typeof ldapSchema>;
export type OidcConfig = z.infer<typeof oidcSchema>;
export default registerAs('authConfig', () => { export default registerAs('authConfig', () => {
const ldapNames = ( const ldapServers = (process.env.HD_AUTH_LDAP_SERVERS?.split(',') ?? []).map(
toArrayConfig(process.env.HD_AUTH_LDAP_SERVERS, ',') ?? [] (name) => name.toUpperCase(),
).map((name) => name.toUpperCase()); );
ensureNoDuplicatesExist('LDAP', ldapNames); ensureNoDuplicatesExist('LDAP', ldapServers);
const oidcNames = ( const oidcServers = (process.env.HD_AUTH_OIDC_SERVERS?.split(',') ?? []).map(
toArrayConfig(process.env.HD_AUTH_OIDC_SERVERS, ',') ?? [] (name) => name.toUpperCase(),
).map((name) => name.toUpperCase()); );
ensureNoDuplicatesExist('OIDC', oidcNames); ensureNoDuplicatesExist('OIDC', oidcServers);
const ldapInstances = ldapNames.map((ldapName) => { const ldapConfig: Partial<LdapConfig>[] = ldapServers.map((name) => {
const caFiles = toArrayConfig( const caFiles = toArrayConfig(
process.env[`HD_AUTH_LDAP_${ldapName}_TLS_CERT_PATHS`], process.env[`HD_AUTH_LDAP_${name}_TLS_CERT_PATHS`],
',', ',',
); );
let tlsCaCerts = undefined; let tlsCaCerts = undefined;
@ -180,106 +226,97 @@ export default registerAs('authConfig', () => {
}); });
} }
return { return {
identifier: ldapName.toLowerCase(), identifier: name.toLowerCase(),
providerName: process.env[`HD_AUTH_LDAP_${ldapName}_PROVIDER_NAME`], providerName: process.env[`HD_AUTH_LDAP_${name}_PROVIDER_NAME`],
url: process.env[`HD_AUTH_LDAP_${ldapName}_URL`], url: process.env[`HD_AUTH_LDAP_${name}_URL`],
bindDn: process.env[`HD_AUTH_LDAP_${ldapName}_BIND_DN`], bindDn: process.env[`HD_AUTH_LDAP_${name}_BIND_DN`],
bindCredentials: process.env[`HD_AUTH_LDAP_${ldapName}_BIND_CREDENTIALS`], bindCredentials: process.env[`HD_AUTH_LDAP_${name}_BIND_CREDENTIALS`],
searchBase: process.env[`HD_AUTH_LDAP_${ldapName}_SEARCH_BASE`], searchBase: process.env[`HD_AUTH_LDAP_${name}_SEARCH_BASE`],
searchFilter: process.env[`HD_AUTH_LDAP_${ldapName}_SEARCH_FILTER`], searchFilter: process.env[`HD_AUTH_LDAP_${name}_SEARCH_FILTER`],
searchAttributes: toArrayConfig( searchAttributes:
process.env[`HD_AUTH_LDAP_${ldapName}_SEARCH_ATTRIBUTES`], process.env[`HD_AUTH_LDAP_${name}_SEARCH_ATTRIBUTES`]?.split(','),
',', userIdField: process.env[`HD_AUTH_LDAP_${name}_USER_ID_FIELD`],
), displayNameField: process.env[`HD_AUTH_LDAP_${name}_DISPLAY_NAME_FIELD`],
userIdField: process.env[`HD_AUTH_LDAP_${ldapName}_USER_ID_FIELD`], emailField: process.env[`HD_AUTH_LDAP_${name}_EMAIL_FIELD`],
displayNameField:
process.env[`HD_AUTH_LDAP_${ldapName}_DISPLAY_NAME_FIELD`],
emailField: process.env[`HD_AUTH_LDAP_${ldapName}_EMAIL_FIELD`],
profilePictureField: profilePictureField:
process.env[`HD_AUTH_LDAP_${ldapName}_PROFILE_PICTURE_FIELD`], process.env[`HD_AUTH_LDAP_${name}_PROFILE_PICTURE_FIELD`],
tlsCaCerts: tlsCaCerts, // Technically this can be (string | undefined)[] | undefined, but an undefined array element tells us that the file is not there and the user input is invalid
tlsCaCerts: tlsCaCerts as string[] | undefined,
tlsRejectUnauthorized: parseOptionalBoolean(
process.env[`HD_AUTH_LDAP_${name}_TLS_REJECT_UNAUTHORIZED`],
),
tlsSniName: process.env[`HD_AUTH_LDAP_${name}_TLS_SNI_NAME`],
tlsAllowPartialTrustChain: parseOptionalBoolean(
process.env[`HD_AUTH_LDAP_${name}_TLS_ALLOW_PARTIAL_TRUST_CHAIN`],
),
tlsMinVersion: process.env[`HD_AUTH_LDAP_${name}_TLS_MIN_VERSION`] as
| 'TLSv1' // This typecast is required since zod validates the input later but TypeScript already expects valid input
| undefined,
tlsMaxVersion: process.env[`HD_AUTH_LDAP_${name}_TLS_MAX_VERSION`] as
| 'TLSv1'
| undefined,
}; };
}); });
const oidcInstances = oidcNames.map((oidcName) => ({ const oidcConfig: Partial<OidcConfig>[] = oidcServers.map((name) => ({
identifier: oidcName.toLowerCase(), identifier: name.toLowerCase(),
providerName: process.env[`HD_AUTH_OIDC_${oidcName}_PROVIDER_NAME`], providerName: process.env[`HD_AUTH_OIDC_${name}_PROVIDER_NAME`],
issuer: process.env[`HD_AUTH_OIDC_${oidcName}_ISSUER`], issuer: process.env[`HD_AUTH_OIDC_${name}_ISSUER`],
clientID: process.env[`HD_AUTH_OIDC_${oidcName}_CLIENT_ID`], clientId: process.env[`HD_AUTH_OIDC_${name}_CLIENT_ID`],
clientSecret: process.env[`HD_AUTH_OIDC_${oidcName}_CLIENT_SECRET`], clientSecret: process.env[`HD_AUTH_OIDC_${name}_CLIENT_SECRET`],
theme: process.env[`HD_AUTH_OIDC_${oidcName}_THEME`], theme: process.env[`HD_AUTH_OIDC_${name}_THEME`] as Theme | undefined,
authorizeUrl: process.env[`HD_AUTH_OIDC_${oidcName}_AUTHORIZE_URL`], authorizeUrl: process.env[`HD_AUTH_OIDC_${name}_AUTHORIZE_URL`],
tokenUrl: process.env[`HD_AUTH_OIDC_${oidcName}_TOKEN_URL`], tokenUrl: process.env[`HD_AUTH_OIDC_${name}_TOKEN_URL`],
userinfoUrl: process.env[`HD_AUTH_OIDC_${oidcName}_USERINFO_URL`], userinfoUrl: process.env[`HD_AUTH_OIDC_${name}_USERINFO_URL`],
endSessionUrl: process.env[`HD_AUTH_OIDC_${oidcName}_END_SESSION_URL`], endSessionUrl: process.env[`HD_AUTH_OIDC_${name}_END_SESSION_URL`],
scope: process.env[`HD_AUTH_OIDC_${oidcName}_SCOPE`], scope: process.env[`HD_AUTH_OIDC_${name}_SCOPE`],
userIdField: process.env[`HD_AUTH_OIDC_${oidcName}_USER_ID_FIELD`], userIdField: process.env[`HD_AUTH_OIDC_${name}_USER_ID_FIELD`],
userNameField: process.env[`HD_AUTH_OIDC_${oidcName}_USER_NAME_FIELD`], userNameField: process.env[`HD_AUTH_OIDC_${name}_USER_NAME_FIELD`],
displayNameField: displayNameField: process.env[`HD_AUTH_OIDC_${name}_DISPLAY_NAME_FIELD`],
process.env[`HD_AUTH_OIDC_${oidcName}_DISPLAY_NAME_FIELD`],
profilePictureField: profilePictureField:
process.env[`HD_AUTH_OIDC_${oidcName}_PROFILE_PICTURE_FIELD`], process.env[`HD_AUTH_OIDC_${name}_PROFILE_PICTURE_FIELD`],
emailField: process.env[`HD_AUTH_OIDC_${oidcName}_EMAIL_FIELD`], emailField: process.env[`HD_AUTH_OIDC_${name}_EMAIL_FIELD`],
enableRegistration: parseOptionalBoolean( enableRegistration: parseOptionalBoolean(
process.env[`HD_AUTH_OIDC_${oidcName}_ENABLE_REGISTER`], process.env[`HD_AUTH_OIDC_${name}_ENABLE_REGISTER`],
), ),
})); }));
let syncSource = process.env.HD_AUTH_SYNC_SOURCE; const authConfig = schema.safeParse({
if (syncSource !== undefined) { common: {
syncSource = syncSource.toLowerCase(); allowProfileEdits: parseOptionalBoolean(
} process.env.HD_AUTH_ALLOW_PROFILE_EDITS,
),
allowChooseUsername: parseOptionalBoolean(
process.env.HD_AUTH_ALLOW_CHOOSE_USERNAME,
),
syncSource: process.env.HD_AUTH_SYNC_SOURCE?.toLowerCase(),
},
session: {
secret: process.env.HD_SESSION_SECRET,
lifetime: parseOptionalNumber(process.env.HD_SESSION_LIFETIME),
},
local: {
enableLogin: parseOptionalBoolean(process.env.HD_AUTH_LOCAL_ENABLE_LOGIN),
enableRegister: parseOptionalBoolean(
process.env.HD_AUTH_LOCAL_ENABLE_REGISTER,
),
minimalPasswordStrength: parseOptionalNumber(
process.env.HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH,
),
},
ldap: ldapConfig,
oidc: oidcConfig,
});
const authConfig = authSchema.validate(
{
common: {
allowProfileEdits: process.env.HD_AUTH_ALLOW_PROFILE_EDITS,
allowChooseUsername: process.env.HD_AUTH_ALLOW_CHOOSE_USERNAME,
syncSource: syncSource,
},
session: {
secret: process.env.HD_SESSION_SECRET,
lifetime: parseOptionalNumber(process.env.HD_SESSION_LIFETIME),
},
local: {
enableLogin: parseOptionalBoolean(
process.env.HD_AUTH_LOCAL_ENABLE_LOGIN,
),
enableRegister: parseOptionalBoolean(
process.env.HD_AUTH_LOCAL_ENABLE_REGISTER,
),
minimalPasswordStrength: parseOptionalNumber(
process.env.HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH,
),
},
ldap: ldapInstances,
oidc: oidcInstances,
},
{
abortEarly: false,
presence: 'required',
},
);
if (authConfig.error) { if (authConfig.error) {
const errorMessages = authConfig.error.details const errorMessages = authConfig.error.errors.map((issue) =>
.map((detail) => detail.message) extractDescriptionFromZodIssue(issue, 'HD_AUTH', {
.map((error) => ldap: ldapServers,
replaceAuthErrorsWithEnvironmentVariables( oidc: oidcServers,
error, }),
'ldap', );
'HD_AUTH_LDAP_',
ldapNames,
),
)
.map((error) =>
replaceAuthErrorsWithEnvironmentVariables(
error,
'oidc',
'HD_AUTH_OIDC_',
oidcNames,
),
);
throw new Error(buildErrorMessage(errorMessages)); throw new Error(buildErrorMessage(errorMessages));
} }
return authConfig.value as AuthConfig;
return authConfig.data;
}); });

View file

@ -1,23 +1,25 @@
/* /*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import * as Joi from 'joi'; import * as process from 'node:process';
import z from 'zod';
import { buildErrorMessage } from './utils'; import { parseOptionalBoolean } from './utils';
import {
buildErrorMessage,
extractDescriptionFromZodIssue,
} from './zod-error-message';
export interface CspConfig { const cspSchema = z.object({
enable: boolean; enable: z.boolean().default(true).describe('HD_CSP_ENABLED'),
reportURI: string; reportURI: z.string().optional().describe('HD_CSP_REPORT_URI'),
}
const cspSchema = Joi.object({
enable: Joi.boolean().default(true).optional().label('HD_CSP_ENABLE'),
reportURI: Joi.string().optional().label('HD_CSP_REPORT_URI'),
}); });
export type CspConfig = z.infer<typeof cspSchema>;
export default registerAs('cspConfig', () => { export default registerAs('cspConfig', () => {
if ( if (
process.env.HD_CSP_ENABLE !== undefined || process.env.HD_CSP_ENABLE !== undefined ||
@ -28,21 +30,15 @@ export default registerAs('cspConfig', () => {
); );
} }
const cspConfig = cspSchema.validate( const cspConfig = cspSchema.safeParse({
{ enable: parseOptionalBoolean(process.env.HD_CSP_ENABLED),
enable: process.env.HD_CSP_ENABLE || true, reportURI: process.env.HD_CSP_REPORT_URI,
reportURI: process.env.HD_CSP_REPORT_URI, });
},
{
abortEarly: false,
presence: 'required',
},
);
if (cspConfig.error) { if (cspConfig.error) {
const errorMessages = cspConfig.error.details.map( const errorMessages = cspConfig.error.errors.map((issue) =>
(detail) => detail.message, extractDescriptionFromZodIssue(issue, 'HD_CSP'),
); );
throw new Error(buildErrorMessage(errorMessages)); throw new Error(buildErrorMessage(errorMessages));
} }
return cspConfig.value as CspConfig; return cspConfig.data;
}); });

View file

@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import mockedEnv from 'mocked-env';
import customizationConfig from './customization.config';
describe('customizationConfig', () => {
const customName = 'test name';
const customLogo = 'https://example.com/logo.png';
const privacyUrl = 'https://privacy.example.com';
const termsOfUseUrl = 'https://termsOfUse.example.com';
const imprintUrl = 'https://imprint.example.com';
const invalidCustomLogo = 'example.com/logo.png';
const invalidPrivacyUrl = 'privacy.example.com';
const invalidTermsOfUseUrl = 'termsOfUse.example.com';
const invalidImprintUrl = 'imprint.example.com';
it('correctly parses valid config', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_CUSTOM_NAME: customName,
HD_CUSTOM_LOGO: customLogo,
HD_PRIVACY_URL: privacyUrl,
HD_TERMS_OF_USE_URL: termsOfUseUrl,
HD_IMPRINT_URL: imprintUrl,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = customizationConfig();
expect(config.branding.customName).toEqual(customName);
expect(config.branding.customLogo).toEqual(customLogo);
expect(config.specialUrls.privacy).toEqual(privacyUrl);
expect(config.specialUrls.termsOfUse).toEqual(termsOfUseUrl);
expect(config.specialUrls.imprint).toEqual(imprintUrl);
restore();
});
it('throws an error if anything is wrongly configured', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_CUSTOM_NAME: customName,
HD_CUSTOM_LOGO: invalidCustomLogo,
HD_PRIVACY_URL: invalidPrivacyUrl,
HD_TERMS_OF_USE_URL: invalidTermsOfUseUrl,
HD_IMPRINT_URL: invalidImprintUrl,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
expect(() => customizationConfig()).toThrow(
`- HD_BRANDING_CUSTOM_LOGO: Invalid url
- HD_SPECIAL_URLS_PRIVACY: Invalid url
- HD_SPECIAL_URLS_TERMS_OF_USE: Invalid url
- HD_SPECIAL_URLS_IMPRINT: Invalid url`,
);
restore();
});
});

View file

@ -4,77 +4,44 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import * as Joi from 'joi'; import z from 'zod';
import { buildErrorMessage } from './utils'; import {
buildErrorMessage,
extractDescriptionFromZodIssue,
} from './zod-error-message';
export interface CustomizationConfig { const schema = z.object({
branding: { branding: z.object({
customName: string | null; customName: z.string().or(z.null()).describe('HD_CUSTOM_NAME'),
customLogo: string | null; customLogo: z.string().url().or(z.null()).describe('HD_CUSTOM_LOGO'),
};
specialUrls: {
privacy: string | null;
termsOfUse: string | null;
imprint: string | null;
};
}
const schema = Joi.object({
branding: Joi.object({
customName: Joi.string().allow(null).label('HD_CUSTOM_NAME'),
customLogo: Joi.string()
.uri({
scheme: [/https?/],
})
.allow(null)
.label('HD_CUSTOM_LOGO'),
}), }),
specialUrls: Joi.object({ specialUrls: z.object({
privacy: Joi.string() privacy: z.string().url().or(z.null()).describe('HD_PRIVACY_URL'),
.uri({ termsOfUse: z.string().url().or(z.null()).describe('HD_TERMS_OF_USE_URL'),
scheme: /https?/, imprint: z.string().url().or(z.null()).describe('HD_IMPRINT_URL'),
})
.allow(null)
.label('HD_PRIVACY_URL'),
termsOfUse: Joi.string()
.uri({
scheme: /https?/,
})
.allow(null)
.label('HD_TERMS_OF_USE_URL'),
imprint: Joi.string()
.uri({
scheme: /https?/,
})
.allow(null)
.label('HD_IMPRINT_URL'),
}), }),
}); });
export type CustomizationConfig = z.infer<typeof schema>;
export default registerAs('customizationConfig', () => { export default registerAs('customizationConfig', () => {
const customizationConfig = schema.validate( const customizationConfig = schema.safeParse({
{ branding: {
branding: { customName: process.env.HD_CUSTOM_NAME ?? null,
customName: process.env.HD_CUSTOM_NAME ?? null, customLogo: process.env.HD_CUSTOM_LOGO ?? null,
customLogo: process.env.HD_CUSTOM_LOGO ?? null,
},
specialUrls: {
privacy: process.env.HD_PRIVACY_URL ?? null,
termsOfUse: process.env.HD_TERMS_OF_USE_URL ?? null,
imprint: process.env.HD_IMPRINT_URL ?? null,
},
}, },
{ specialUrls: {
abortEarly: false, privacy: process.env.HD_PRIVACY_URL ?? null,
presence: 'required', termsOfUse: process.env.HD_TERMS_OF_USE_URL ?? null,
imprint: process.env.HD_IMPRINT_URL ?? null,
}, },
); });
if (customizationConfig.error) { if (customizationConfig.error) {
const errorMessages = customizationConfig.error.details.map( const errorMessages = customizationConfig.error.errors.map((issue) =>
(detail) => detail.message, extractDescriptionFromZodIssue(issue, 'HD'),
); );
throw new Error(buildErrorMessage(errorMessages)); throw new Error(buildErrorMessage(errorMessages));
} }
return customizationConfig.value as CustomizationConfig; return customizationConfig.data;
}); });

View file

@ -0,0 +1,169 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import mockedEnv from 'mocked-env';
import databaseConfig, {
MariadbDatabaseConfig,
MySQLDatabaseConfig,
PostgresDatabaseConfig,
SqliteDatabaseConfig,
} from './database.config';
describe('databaseConfig', () => {
const databaseTypeSqlite = 'sqlite';
const databaseTypeMysql = 'mysql';
const databaseTypeMariadb = 'mariadb';
const databaseTypePostgres = 'postgres';
const databaseName = 'test-db';
const databaseUser = 'test-user';
const databasePass = 'test-pass';
const databaseHost = 'test-host';
const databasePort = 1234;
const invalidDatabasePort = -1234;
const invalidDatabasePort2 = 65536;
const databaseFileSqlite = 'test.db';
describe('correctly parses valid', () => {
it('SQLite config', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_DATABASE_TYPE: databaseTypeSqlite,
HD_DATABASE_NAME: databaseFileSqlite,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = databaseConfig() as SqliteDatabaseConfig;
expect(config.type).toEqual(databaseTypeSqlite);
expect(config.name).toEqual(databaseFileSqlite);
restore();
});
it('MySQL config', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_DATABASE_TYPE: databaseTypeMysql,
HD_DATABASE_NAME: databaseName,
HD_DATABASE_USERNAME: databaseUser,
HD_DATABASE_PASSWORD: databasePass,
HD_DATABASE_HOST: databaseHost,
HD_DATABASE_PORT: String(databasePort),
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = databaseConfig() as MySQLDatabaseConfig;
expect(config.type).toEqual(databaseTypeMysql);
expect(config.name).toEqual(databaseName);
expect(config.username).toEqual(databaseUser);
expect(config.password).toEqual(databasePass);
expect(config.host).toEqual(databaseHost);
expect(config.port).toEqual(databasePort);
restore();
});
it('MariaDB config', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_DATABASE_TYPE: databaseTypeMariadb,
HD_DATABASE_NAME: databaseName,
HD_DATABASE_USERNAME: databaseUser,
HD_DATABASE_PASSWORD: databasePass,
HD_DATABASE_HOST: databaseHost,
HD_DATABASE_PORT: String(databasePort),
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = databaseConfig() as MariadbDatabaseConfig;
expect(config.type).toEqual(databaseTypeMariadb);
expect(config.name).toEqual(databaseName);
expect(config.username).toEqual(databaseUser);
expect(config.password).toEqual(databasePass);
expect(config.host).toEqual(databaseHost);
expect(config.port).toEqual(databasePort);
restore();
});
it('Postgres config', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_DATABASE_TYPE: databaseTypePostgres,
HD_DATABASE_NAME: databaseName,
HD_DATABASE_USERNAME: databaseUser,
HD_DATABASE_PASSWORD: databasePass,
HD_DATABASE_HOST: databaseHost,
HD_DATABASE_PORT: String(databasePort),
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = databaseConfig() as PostgresDatabaseConfig;
expect(config.type).toEqual(databaseTypePostgres);
expect(config.name).toEqual(databaseName);
expect(config.username).toEqual(databaseUser);
expect(config.password).toEqual(databasePass);
expect(config.host).toEqual(databaseHost);
expect(config.port).toEqual(databasePort);
restore();
});
});
it('throws an error if the port is negative', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_DATABASE_TYPE: databaseTypePostgres,
HD_DATABASE_NAME: databaseName,
HD_DATABASE_USERNAME: databaseUser,
HD_DATABASE_PASSWORD: databasePass,
HD_DATABASE_HOST: databaseHost,
HD_DATABASE_PORT: String(invalidDatabasePort),
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
expect(() => databaseConfig()).toThrow(
'HD_DATABASE_PORT: Number must be greater than 0',
);
restore();
});
it('throws an error if the port is too big', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_DATABASE_TYPE: databaseTypePostgres,
HD_DATABASE_NAME: databaseName,
HD_DATABASE_USERNAME: databaseUser,
HD_DATABASE_PASSWORD: databasePass,
HD_DATABASE_HOST: databaseHost,
HD_DATABASE_PORT: String(invalidDatabasePort2),
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
expect(() => databaseConfig()).toThrow(
'HD_DATABASE_PORT: Number must be less than or equal to 65535',
);
restore();
});
});

View file

@ -1,73 +1,92 @@
/* /*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import * as Joi from 'joi'; import z from 'zod';
import { DatabaseType } from './database-type.enum'; import { DatabaseType } from './database-type.enum';
import { buildErrorMessage, parseOptionalNumber } from './utils'; import { parseOptionalNumber } from './utils';
import {
buildErrorMessage,
extractDescriptionFromZodIssue,
} from './zod-error-message';
export interface DatabaseConfig { const sqliteDbSchema = z.object({
username: string; type: z.literal(DatabaseType.SQLITE).describe('HD_DATABASE_TYPE'),
password: string; name: z.string().describe('HD_DATABASE_NAME'),
database: string;
host: string;
port: number;
type: DatabaseType;
}
const databaseSchema = Joi.object({
type: Joi.string()
.valid(...Object.values(DatabaseType))
.label('HD_DATABASE_TYPE'),
// This is the database name, except for SQLite,
// where it is the path to the database file.
database: Joi.string().label('HD_DATABASE_NAME'),
username: Joi.when('type', {
is: Joi.invalid(DatabaseType.SQLITE),
then: Joi.string(),
otherwise: Joi.optional(),
}).label('HD_DATABASE_USER'),
password: Joi.when('type', {
is: Joi.invalid(DatabaseType.SQLITE),
then: Joi.string(),
otherwise: Joi.optional(),
}).label('HD_DATABASE_PASS'),
host: Joi.when('type', {
is: Joi.invalid(DatabaseType.SQLITE),
then: Joi.string(),
otherwise: Joi.optional(),
}).label('HD_DATABASE_HOST'),
port: Joi.when('type', {
is: Joi.invalid(DatabaseType.SQLITE),
then: Joi.number(),
otherwise: Joi.optional(),
}).label('HD_DATABASE_PORT'),
}); });
const postgresDbSchema = z.object({
type: z.literal(DatabaseType.POSTGRES).describe('HD_DATABASE_TYPE'),
name: z.string().describe('HD_DATABASE_NAME'),
username: z.string().describe('HD_DATABASE_USERNAME'),
password: z.string().describe('HD_DATABASE_PASSWORD'),
host: z.string().describe('HD_DATABASE_HOST'),
port: z
.number()
.positive()
.max(65535)
.default(5432)
.describe('HD_DATABASE_PORT'),
});
const mariaDbSchema = z.object({
type: z.literal(DatabaseType.MARIADB).describe('HD_DATABASE_TYPE'),
name: z.string().describe('HD_DATABASE_NAME'),
username: z.string().describe('HD_DATABASE_USERNAME'),
password: z.string().describe('HD_DATABASE_PASSWORD'),
host: z.string().describe('HD_DATABASE_HOST'),
port: z
.number()
.positive()
.max(65535)
.default(3306)
.describe('HD_DATABASE_PORT'),
});
const mysqlDbSchema = z.object({
type: z.literal(DatabaseType.MYSQL).describe('HD_DATABASE_TYPE'),
name: z.string().describe('HD_DATABASE_NAME'),
username: z.string().describe('HD_DATABASE_USERNAME'),
password: z.string().describe('HD_DATABASE_PASSWORD'),
host: z.string().describe('HD_DATABASE_HOST'),
port: z
.number()
.positive()
.max(65535)
.default(3306)
.describe('HD_DATABASE_PORT'),
});
const dbSchema = z.discriminatedUnion('type', [
sqliteDbSchema,
mariaDbSchema,
mysqlDbSchema,
postgresDbSchema,
]);
export type SqliteDatabaseConfig = z.infer<typeof sqliteDbSchema>;
export type PostgresDatabaseConfig = z.infer<typeof postgresDbSchema>;
export type MariadbDatabaseConfig = z.infer<typeof mariaDbSchema>;
export type MySQLDatabaseConfig = z.infer<typeof mysqlDbSchema>;
export type DatabaseConfig = z.infer<typeof dbSchema>;
export default registerAs('databaseConfig', () => { export default registerAs('databaseConfig', () => {
const databaseConfig = databaseSchema.validate( const databaseConfig = dbSchema.safeParse({
{ type: process.env.HD_DATABASE_TYPE,
type: process.env.HD_DATABASE_TYPE, username: process.env.HD_DATABASE_USERNAME,
username: process.env.HD_DATABASE_USER, password: process.env.HD_DATABASE_PASSWORD,
password: process.env.HD_DATABASE_PASS, name: process.env.HD_DATABASE_NAME,
database: process.env.HD_DATABASE_NAME, host: process.env.HD_DATABASE_HOST,
host: process.env.HD_DATABASE_HOST, port: parseOptionalNumber(process.env.HD_DATABASE_PORT),
port: parseOptionalNumber(process.env.HD_DATABASE_PORT), });
},
{
abortEarly: false,
presence: 'required',
},
);
if (databaseConfig.error) { if (databaseConfig.error) {
const errorMessages = databaseConfig.error.details.map( const errorMessages = databaseConfig.error.errors.map((issue) =>
(detail) => detail.message, extractDescriptionFromZodIssue(issue, 'HD_DATABASE'),
); );
throw new Error(buildErrorMessage(errorMessages)); throw new Error(buildErrorMessage(errorMessages));
} }
return databaseConfig.value as DatabaseConfig; return databaseConfig.data;
}); });

View file

@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import mockedEnv from 'mocked-env';
import externalServicesConfig from './external-services.config';
describe('externalServices', () => {
const plantUmlServer = 'https://plantuml.example.com';
const imageProxy = 'https://proxy.example.com';
it('correctly parses valid config', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_PLANTUML_SERVER: plantUmlServer,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = externalServicesConfig();
expect(config.plantumlServer).toEqual(plantUmlServer);
restore();
});
it('throws an error if PlantUML server is configured with an invalid URL', () => {
const invalid = 'wrong!';
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_PLANTUML_SERVER: invalid,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
expect(() => externalServicesConfig()).toThrow(
'HD_PLANTUML_SERVER: Invalid url',
);
restore();
});
it('throws an error if image proxy is configured', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_IMAGE_PROXY: imageProxy,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
expect(() => externalServicesConfig()).toThrow(
"HD_IMAGE_PROXY is currently not yet supported. Please don't configure it",
);
restore();
});
});

View file

@ -4,51 +4,35 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import * as Joi from 'joi'; import z from 'zod';
import { buildErrorMessage } from './utils'; import {
buildErrorMessage,
extractDescriptionFromZodIssue,
} from './zod-error-message';
export interface ExternalServicesConfig { const schema = z.object({
plantUmlServer: string | null; plantumlServer: z.string().url().or(z.null()).describe('HD_PLANTUML_SERVER'),
imageProxy: string; imageProxy: z.string().url().or(z.null()).describe('HD_IMAGE_PROXY'),
}
const schema = Joi.object({
plantUmlServer: Joi.string()
.uri({
scheme: /https?/,
})
.allow(null)
.label('HD_PLANTUML_SERVER'),
imageProxy: Joi.string()
.uri({
scheme: /https?/,
})
.optional()
.label('HD_IMAGE_PROXY'),
}); });
export type ExternalServicesConfig = z.infer<typeof schema>;
export default registerAs('externalServicesConfig', () => { export default registerAs('externalServicesConfig', () => {
if (process.env.HD_IMAGE_PROXY !== undefined) { if (process.env.HD_IMAGE_PROXY !== undefined) {
throw new Error( throw new Error(
"HD_IMAGE_PROXY is currently not yet supported. Please don't configure it", "HD_IMAGE_PROXY is currently not yet supported. Please don't configure it",
); );
} }
const externalConfig = schema.validate( const externalConfig = schema.safeParse({
{ plantumlServer: process.env.HD_PLANTUML_SERVER ?? null,
plantUmlServer: process.env.HD_PLANTUML_SERVER ?? null, imageProxy: process.env.HD_IMAGE_PROXY ?? null,
imageProxy: process.env.HD_IMAGE_PROXY, });
},
{
abortEarly: false,
presence: 'required',
},
);
if (externalConfig.error) { if (externalConfig.error) {
const errorMessages = externalConfig.error.details.map( const errorMessages = externalConfig.error.errors.map((issue) =>
(detail) => detail.message, extractDescriptionFromZodIssue(issue, 'HD'),
); );
throw new Error(buildErrorMessage(errorMessages)); throw new Error(buildErrorMessage(errorMessages));
} }
return externalConfig.value as ExternalServicesConfig; return externalConfig.data;
}); });

View file

@ -1,12 +1,18 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import mockedEnv from 'mocked-env'; import mockedEnv from 'mocked-env';
import { BackendType } from '../media/backends/backend-type.enum'; import { BackendType } from '../media/backends/backend-type.enum';
import mediaConfig from './media.config'; import mediaConfig, {
AzureMediaConfig,
FilesystemMediaConfig,
ImgurMediaConfig,
S3MediaConfig,
WebdavMediaConfig,
} from './media.config';
describe('mediaConfig', () => { describe('mediaConfig', () => {
// Filesystem // Filesystem
@ -41,7 +47,7 @@ describe('mediaConfig', () => {
clear: true, clear: true,
}, },
); );
const config = mediaConfig(); const config = mediaConfig() as { backend: FilesystemMediaConfig };
expect(config.backend.use).toEqual(BackendType.FILESYSTEM); expect(config.backend.use).toEqual(BackendType.FILESYSTEM);
expect(config.backend.filesystem.uploadPath).toEqual(uploadPath); expect(config.backend.filesystem.uploadPath).toEqual(uploadPath);
restore(); restore();
@ -64,12 +70,12 @@ describe('mediaConfig', () => {
clear: true, clear: true,
}, },
); );
const config = mediaConfig(); const config = mediaConfig() as { backend: S3MediaConfig };
expect(config.backend.use).toEqual(BackendType.S3); expect(config.backend.use).toEqual(BackendType.S3);
expect(config.backend.s3.accessKeyId).toEqual(accessKeyId); expect(config.backend.s3.accessKeyId).toEqual(accessKeyId);
expect(config.backend.s3.secretAccessKey).toEqual(secretAccessKey); expect(config.backend.s3.secretAccessKey).toEqual(secretAccessKey);
expect(config.backend.s3.bucket).toEqual(bucket); expect(config.backend.s3.bucket).toEqual(bucket);
expect(config.backend.s3.endPoint).toEqual(endPoint); expect(config.backend.s3.endpoint).toEqual(endPoint);
expect(config.backend.s3.region).toEqual(region); expect(config.backend.s3.region).toEqual(region);
expect(config.backend.s3.pathStyle).toEqual(pathStyle); expect(config.backend.s3.pathStyle).toEqual(pathStyle);
restore(); restore();
@ -88,7 +94,7 @@ describe('mediaConfig', () => {
clear: true, clear: true,
}, },
); );
const config = mediaConfig(); const config = mediaConfig() as { backend: AzureMediaConfig };
expect(config.backend.use).toEqual(BackendType.AZURE); expect(config.backend.use).toEqual(BackendType.AZURE);
expect(config.backend.azure.connectionString).toEqual( expect(config.backend.azure.connectionString).toEqual(
azureConnectionString, azureConnectionString,
@ -109,9 +115,9 @@ describe('mediaConfig', () => {
clear: true, clear: true,
}, },
); );
const config = mediaConfig(); const config = mediaConfig() as { backend: ImgurMediaConfig };
expect(config.backend.use).toEqual(BackendType.IMGUR); expect(config.backend.use).toEqual(BackendType.IMGUR);
expect(config.backend.imgur.clientID).toEqual(clientID); expect(config.backend.imgur.clientId).toEqual(clientID);
restore(); restore();
}); });
@ -129,7 +135,7 @@ describe('mediaConfig', () => {
clear: true, clear: true,
}, },
); );
const config = mediaConfig(); const config = mediaConfig() as { backend: WebdavMediaConfig };
expect(config.backend.use).toEqual(BackendType.WEBDAV); expect(config.backend.use).toEqual(BackendType.WEBDAV);
expect(config.backend.webdav.connectionString).toEqual( expect(config.backend.webdav.connectionString).toEqual(
webdavConnectionString, webdavConnectionString,
@ -154,7 +160,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH" is required', 'HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH: Required',
); );
restore(); restore();
}); });
@ -176,7 +182,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_S3_ACCESS_KEY" is required', 'HD_MEDIA_BACKEND_S3_ACCESS_KEY_ID: Required',
); );
restore(); restore();
}); });
@ -195,7 +201,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_S3_SECRET_KEY" is required', 'HD_MEDIA_BACKEND_S3_SECRET_ACCESS_KEY: Required',
); );
restore(); restore();
}); });
@ -214,7 +220,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_S3_BUCKET" is required', 'HD_MEDIA_BACKEND_S3_BUCKET: Required',
); );
restore(); restore();
}); });
@ -233,7 +239,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_S3_ENDPOINT" is required', 'HD_MEDIA_BACKEND_S3_ENDPOINT: Required',
); );
restore(); restore();
}); });
@ -253,27 +259,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_S3_ENDPOINT" must be a valid uri with a scheme matching the ^https? pattern', 'HD_MEDIA_BACKEND_S3_ENDPOINT: Invalid url',
);
restore();
});
it('when HD_MEDIA_BACKEND_S3_ENDPOINT is an URI with a non-http(s) protocol', async () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_MEDIA_BACKEND: BackendType.S3,
HD_MEDIA_BACKEND_S3_ACCESS_KEY: accessKeyId,
HD_MEDIA_BACKEND_S3_SECRET_KEY: secretAccessKey,
HD_MEDIA_BACKEND_S3_BUCKET: bucket,
HD_MEDIA_BACKEND_S3_ENDPOINT: 'ftps://example.org',
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_S3_ENDPOINT" must be a valid uri with a scheme matching the ^https? pattern',
); );
restore(); restore();
}); });
@ -293,7 +279,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING" is required', 'HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING: Required',
); );
restore(); restore();
}); });
@ -310,7 +296,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_AZURE_CONTAINER" is required', 'HD_MEDIA_BACKEND_AZURE_CONTAINER: Required',
); );
restore(); restore();
}); });
@ -329,7 +315,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_IMGUR_CLIENT_ID" is required', 'HD_MEDIA_BACKEND_IMGUR_CLIENT_ID: Required',
); );
restore(); restore();
}); });
@ -350,7 +336,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING" is required', 'HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING: Required',
); );
restore(); restore();
}); });
@ -369,7 +355,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING" must be a valid uri', 'HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING: Invalid url',
); );
restore(); restore();
}); });
@ -387,7 +373,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL" is required', 'HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL: Required',
); );
restore(); restore();
}); });
@ -406,7 +392,7 @@ describe('mediaConfig', () => {
}, },
); );
expect(() => mediaConfig()).toThrow( expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL" must be a valid uri', 'HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL: Invalid url',
); );
restore(); restore();
}); });

View file

@ -1,152 +1,125 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import * as Joi from 'joi'; import z from 'zod';
import { BackendType } from '../media/backends/backend-type.enum'; import { BackendType } from '../media/backends/backend-type.enum';
import { buildErrorMessage, parseOptionalBoolean } from './utils'; import { parseOptionalBoolean } from './utils';
import {
buildErrorMessage,
extractDescriptionFromZodIssue,
} from './zod-error-message';
export interface MediaConfig { const azureSchema = z.object({
backend: MediaBackendConfig; use: z.literal(BackendType.AZURE),
} azure: z.object({
connectionString: z
export interface MediaBackendConfig { .string()
use: BackendType; .describe('HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING'),
filesystem: { container: z.string().describe('HD_MEDIA_BACKEND_AZURE_CONTAINER'),
uploadPath: string; }),
};
s3: {
accessKeyId: string;
secretAccessKey: string;
bucket: string;
endPoint: string;
region: string;
pathStyle: boolean;
};
azure: {
connectionString: string;
container: string;
};
imgur: {
clientID: string;
};
webdav: {
connectionString: string;
uploadDir: string;
publicUrl: string;
};
}
const mediaSchema = Joi.object({
backend: {
use: Joi.string()
.valid(...Object.values(BackendType))
.label('HD_MEDIA_BACKEND'),
filesystem: {
uploadPath: Joi.when('...use', {
is: Joi.valid(BackendType.FILESYSTEM),
then: Joi.string(),
otherwise: Joi.optional(),
}).label('HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH'),
},
s3: Joi.when('use', {
is: Joi.valid(BackendType.S3),
then: Joi.object({
accessKeyId: Joi.string().label('HD_MEDIA_BACKEND_S3_ACCESS_KEY'),
secretAccessKey: Joi.string().label('HD_MEDIA_BACKEND_S3_SECRET_KEY'),
bucket: Joi.string().label('HD_MEDIA_BACKEND_S3_BUCKET'),
endPoint: Joi.string()
.uri({ scheme: /^https?/ })
.label('HD_MEDIA_BACKEND_S3_ENDPOINT'),
region: Joi.string().optional().label('HD_MEDIA_BACKEND_S3_REGION'),
pathStyle: Joi.boolean()
.default(false)
.label('HD_MEDIA_BACKEND_S3_PATH_STYLE'),
}),
otherwise: Joi.optional(),
}),
azure: Joi.when('use', {
is: Joi.valid(BackendType.AZURE),
then: Joi.object({
connectionString: Joi.string().label(
'HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING',
),
container: Joi.string().label('HD_MEDIA_BACKEND_AZURE_CONTAINER'),
}),
otherwise: Joi.optional(),
}),
imgur: Joi.when('use', {
is: Joi.valid(BackendType.IMGUR),
then: Joi.object({
clientID: Joi.string().label('HD_MEDIA_BACKEND_IMGUR_CLIENT_ID'),
}),
otherwise: Joi.optional(),
}),
webdav: Joi.when('use', {
is: Joi.valid(BackendType.WEBDAV),
then: Joi.object({
connectionString: Joi.string()
.uri()
.label('HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING'),
uploadDir: Joi.string()
.optional()
.label('HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR'),
publicUrl: Joi.string()
.uri()
.label('HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL'),
}),
otherwise: Joi.optional(),
}),
},
}); });
const filesystemSchema = z.object({
use: z.literal(BackendType.FILESYSTEM),
filesystem: z.object({
uploadPath: z.string().describe('HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH'),
}),
});
const imgurSchema = z.object({
use: z.literal(BackendType.IMGUR),
imgur: z.object({
clientId: z.string().describe('HD_MEDIA_BACKEND_IMGUR_CLIENT_ID'),
}),
});
const s3Schema = z.object({
use: z.literal(BackendType.S3),
s3: z.object({
accessKeyId: z.string().describe('HD_MEDIA_BACKEND_S3_ACCESS_KEY'),
secretAccessKey: z.string().describe('HD_MEDIA_BACKEND_S3_SECRET_KEY'),
bucket: z.string().describe('HD_MEDIA_BACKEND_S3_BUCKET'),
endpoint: z.string().url().describe('HD_MEDIA_BACKEND_S3_ENDPOINT'),
region: z.string().optional().describe('HD_MEDIA_BACKEND_S3_REGION'),
pathStyle: z
.boolean()
.default(false)
.describe('HD_MEDIA_BACKEND_S3_PATH_STYLE'),
}),
});
const webdavSchema = z.object({
use: z.literal(BackendType.WEBDAV),
webdav: z.object({
connectionString: z
.string()
.url()
.describe('HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING'),
uploadDir: z
.string()
.optional()
.describe('HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR'),
publicUrl: z.string().url().describe('HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL'),
}),
});
const schema = z.object({
backend: z.discriminatedUnion('use', [
azureSchema,
filesystemSchema,
imgurSchema,
s3Schema,
webdavSchema,
]),
});
export type MediaConfig = z.infer<typeof schema>;
export type AzureMediaConfig = z.infer<typeof azureSchema>;
export type FilesystemMediaConfig = z.infer<typeof filesystemSchema>;
export type ImgurMediaConfig = z.infer<typeof imgurSchema>;
export type S3MediaConfig = z.infer<typeof s3Schema>;
export type WebdavMediaConfig = z.infer<typeof webdavSchema>;
export default registerAs('mediaConfig', () => { export default registerAs('mediaConfig', () => {
const mediaConfig = mediaSchema.validate( const mediaConfig = schema.safeParse({
{ backend: {
backend: { use: process.env.HD_MEDIA_BACKEND,
use: process.env.HD_MEDIA_BACKEND, filesystem: {
filesystem: { uploadPath: process.env.HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH,
uploadPath: process.env.HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH, },
}, s3: {
s3: { accessKeyId: process.env.HD_MEDIA_BACKEND_S3_ACCESS_KEY,
accessKeyId: process.env.HD_MEDIA_BACKEND_S3_ACCESS_KEY, secretAccessKey: process.env.HD_MEDIA_BACKEND_S3_SECRET_KEY,
secretAccessKey: process.env.HD_MEDIA_BACKEND_S3_SECRET_KEY, bucket: process.env.HD_MEDIA_BACKEND_S3_BUCKET,
bucket: process.env.HD_MEDIA_BACKEND_S3_BUCKET, endpoint: process.env.HD_MEDIA_BACKEND_S3_ENDPOINT,
endPoint: process.env.HD_MEDIA_BACKEND_S3_ENDPOINT, region: process.env.HD_MEDIA_BACKEND_S3_REGION,
region: process.env.HD_MEDIA_BACKEND_S3_REGION, pathStyle: parseOptionalBoolean(
pathStyle: parseOptionalBoolean( process.env.HD_MEDIA_BACKEND_S3_PATH_STYLE,
process.env.HD_MEDIA_BACKEND_S3_PATH_STYLE, ),
), },
}, azure: {
azure: { connectionString: process.env.HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING,
connectionString: container: process.env.HD_MEDIA_BACKEND_AZURE_CONTAINER,
process.env.HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING, },
container: process.env.HD_MEDIA_BACKEND_AZURE_CONTAINER, imgur: {
}, clientId: process.env.HD_MEDIA_BACKEND_IMGUR_CLIENT_ID,
imgur: { },
clientID: process.env.HD_MEDIA_BACKEND_IMGUR_CLIENT_ID, webdav: {
}, connectionString: process.env.HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING,
webdav: { uploadDir: process.env.HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR,
connectionString: publicUrl: process.env.HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL,
process.env.HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING,
uploadDir: process.env.HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR,
publicUrl: process.env.HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL,
},
}, },
}, },
{ });
abortEarly: false,
presence: 'required',
},
);
if (mediaConfig.error) { if (mediaConfig.error) {
const errorMessages = mediaConfig.error.details.map( const errorMessages = mediaConfig.error.errors.map((issue) =>
(detail) => detail.message, extractDescriptionFromZodIssue(issue, 'HD_MEDIA'),
); );
throw new Error(buildErrorMessage(errorMessages)); throw new Error(buildErrorMessage(errorMessages));
} }
return mediaConfig.value as MediaConfig; return mediaConfig.data;
}); });

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -13,7 +13,7 @@ export function createDefaultMockAppConfig(): AppConfig {
return { return {
baseUrl: 'md.example.com', baseUrl: 'md.example.com',
rendererBaseUrl: 'md-renderer.example.com', rendererBaseUrl: 'md-renderer.example.com',
port: 3000, backendPort: 3000,
loglevel: Loglevel.ERROR, loglevel: Loglevel.ERROR,
showLogTimestamp: true, showLogTimestamp: true,
persistInterval: 10, persistInterval: 10,

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -13,7 +13,7 @@ export function createDefaultMockDatabaseConfig(): DatabaseConfig {
return { return {
type: (process.env.HEDGEDOC_TEST_DB_TYPE || type: (process.env.HEDGEDOC_TEST_DB_TYPE ||
DatabaseType.SQLITE) as DatabaseType, DatabaseType.SQLITE) as DatabaseType,
database: 'hedgedoc', name: 'hedgedoc',
password: 'hedgedoc', password: 'hedgedoc',
host: 'localhost', host: 'localhost',
port: 0, port: 0,

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -10,7 +10,7 @@ import { ExternalServicesConfig } from '../external-services.config';
export function createDefaultMockExternalServicesConfig(): ExternalServicesConfig { export function createDefaultMockExternalServicesConfig(): ExternalServicesConfig {
return { return {
plantUmlServer: 'https://plantuml.example.com', plantumlServer: 'https://plantuml.example.com',
imageProxy: 'https://imageProxy.example.com', imageProxy: 'https://imageProxy.example.com',
}; };
} }

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -17,26 +17,6 @@ export function createDefaultMockMediaConfig(): MediaConfig {
uploadPath: uploadPath:
'test_uploads' + Math.floor(Math.random() * 100000).toString(), 'test_uploads' + Math.floor(Math.random() * 100000).toString(),
}, },
s3: {
accessKeyId: '',
secretAccessKey: '',
bucket: '',
endPoint: '',
pathStyle: false,
region: '',
},
azure: {
connectionString: '',
container: '',
},
imgur: {
clientID: '',
},
webdav: {
connectionString: '',
uploadDir: '',
publicUrl: '',
},
}, },
}; };
} }

View file

@ -28,8 +28,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
HD_REVISION_RETENTION_DAYS: retentionDays.toString(), HD_REVISION_RETENTION_DAYS: retentionDays.toString(),
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
@ -58,8 +58,8 @@ describe('noteConfig', () => {
{ {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -86,8 +86,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteId, HD_FORBIDDEN_NOTE_IDS: forbiddenNoteId,
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -115,8 +115,8 @@ describe('noteConfig', () => {
{ {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -145,7 +145,7 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -174,7 +174,7 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -203,7 +203,7 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
{ {
@ -231,8 +231,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -263,8 +263,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: invalidforbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: invalidforbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -273,7 +273,7 @@ describe('noteConfig', () => {
}, },
); );
expect(() => noteConfig()).toThrow( expect(() => noteConfig()).toThrow(
'"forbiddenNoteIds[0]" is not allowed to be empty', 'HD_FORBIDDEN_NOTE_IDS[0]: String must contain at least 1 character(s)\n - HD_FORBIDDEN_NOTE_IDS[1]: String must contain at least 1 character(s)',
); );
restore(); restore();
}); });
@ -284,8 +284,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: negativeMaxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: negativeMaxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -294,7 +294,7 @@ describe('noteConfig', () => {
}, },
); );
expect(() => noteConfig()).toThrow( expect(() => noteConfig()).toThrow(
'"HD_MAX_DOCUMENT_LENGTH" must be a positive number', 'HD_MAX_DOCUMENT_LENGTH: Number must be greater than 0',
); );
restore(); restore();
}); });
@ -305,8 +305,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: floatMaxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: floatMaxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -315,7 +315,7 @@ describe('noteConfig', () => {
}, },
); );
expect(() => noteConfig()).toThrow( expect(() => noteConfig()).toThrow(
'"HD_MAX_DOCUMENT_LENGTH" must be an integer', 'HD_MAX_DOCUMENT_LENGTH: Expected integer, received float',
); );
restore(); restore();
}); });
@ -326,8 +326,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: invalidMaxDocumentLength, HD_MAX_DOCUMENT_LENGTH: invalidMaxDocumentLength,
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -336,19 +336,19 @@ describe('noteConfig', () => {
}, },
); );
expect(() => noteConfig()).toThrow( expect(() => noteConfig()).toThrow(
'"HD_MAX_DOCUMENT_LENGTH" must be a number', 'HD_MAX_DOCUMENT_LENGTH: Expected number, received nan',
); );
restore(); restore();
}); });
it('when given a non-valid HD_PERMISSION_DEFAULT_EVERYONE', async () => { it('when given a non-valid HD_PERMISSIONS_DEFAULT_EVERYONE', async () => {
const restore = mockedEnv( const restore = mockedEnv(
{ {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: wrongDefaultPermission, HD_PERMISSIONS_DEFAULT_EVERYONE: wrongDefaultPermission,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -357,19 +357,19 @@ describe('noteConfig', () => {
}, },
); );
expect(() => noteConfig()).toThrow( expect(() => noteConfig()).toThrow(
'"HD_PERMISSION_DEFAULT_EVERYONE" must be one of [none, read, write]', "HD_PERMISSIONS_DEFAULT_EVERYONE: Invalid enum value. Expected 'none' | 'read' | 'write', received 'wrong'",
); );
restore(); restore();
}); });
it('when given a non-valid HD_PERMISSION_DEFAULT_LOGGED_IN', async () => { it('when given a non-valid HD_PERMISSIONS_DEFAULT_LOGGED_IN', async () => {
const restore = mockedEnv( const restore = mockedEnv(
{ {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: wrongDefaultPermission, HD_PERMISSIONS_DEFAULT_LOGGED_IN: wrongDefaultPermission,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -378,7 +378,7 @@ describe('noteConfig', () => {
}, },
); );
expect(() => noteConfig()).toThrow( expect(() => noteConfig()).toThrow(
'"HD_PERMISSION_DEFAULT_LOGGED_IN" must be one of [none, read, write]', "HD_PERMISSIONS_DEFAULT_LOGGED_IN: Invalid enum value. Expected 'none' | 'read' | 'write', received 'wrong'",
); );
restore(); restore();
}); });
@ -399,7 +399,7 @@ describe('noteConfig', () => {
}, },
); );
expect(() => noteConfig()).toThrow( expect(() => noteConfig()).toThrow(
'"HD_GUEST_ACCESS" must be one of [deny, read, write, create]', "HD_GUEST_ACCESS: Invalid enum value. Expected 'deny' | 'read' | 'write' | 'create', received 'wrong'",
); );
restore(); restore();
}); });
@ -410,8 +410,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: 'deny', HD_GUEST_ACCESS: 'deny',
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -420,19 +420,19 @@ describe('noteConfig', () => {
}, },
); );
expect(() => noteConfig()).toThrow( expect(() => noteConfig()).toThrow(
`'HD_GUEST_ACCESS' is set to 'deny', but 'HD_PERMISSION_DEFAULT_EVERYONE' is also configured. Please remove 'HD_PERMISSION_DEFAULT_EVERYONE'.`, `'HD_GUEST_ACCESS' is set to 'deny', but 'HD_PERMISSIONS_DEFAULT_EVERYONE' is also configured. Please remove 'HD_PERMISSIONS_DEFAULT_EVERYONE'.`,
); );
restore(); restore();
}); });
it('when HD_PERMISSION_DEFAULT_EVERYONE is set to write, but HD_PERMISSION_DEFAULT_LOGGED_IN is set to read', async () => { it('when HD_PERMISSIONS_DEFAULT_EVERYONE is set to write, but HD_PERMISSION_DEFAULT_LOGGED_IN is set to read', async () => {
const restore = mockedEnv( const restore = mockedEnv(
{ {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.WRITE, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.WRITE,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -441,19 +441,19 @@ describe('noteConfig', () => {
}, },
); );
expect(() => noteConfig()).toThrow( expect(() => noteConfig()).toThrow(
`'HD_PERMISSION_DEFAULT_EVERYONE' is set to '${DefaultAccessLevel.WRITE}', but 'HD_PERMISSION_DEFAULT_LOGGED_IN' is set to '${DefaultAccessLevel.READ}'. This gives everyone greater permissions than logged-in users which is not allowed.`, `'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${DefaultAccessLevel.WRITE}', but 'HD_PERMISSIONS_DEFAULT_LOGGED_IN' is set to '${DefaultAccessLevel.READ}'. This gives everyone greater permissions than logged-in users which is not allowed.`,
); );
restore(); restore();
}); });
it('when HD_PERMISSION_DEFAULT_EVERYONE is set to write, but HD_PERMISSION_DEFAULT_LOGGED_IN is set to none', async () => { it('when HD_PERMISSIONS_DEFAULT_EVERYONE is set to write, but HD_PERMISSION_DEFAULT_LOGGED_IN is set to none', async () => {
const restore = mockedEnv( const restore = mockedEnv(
{ {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.WRITE, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.WRITE,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.NONE, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.NONE,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -462,19 +462,19 @@ describe('noteConfig', () => {
}, },
); );
expect(() => noteConfig()).toThrow( expect(() => noteConfig()).toThrow(
`'HD_PERMISSION_DEFAULT_EVERYONE' is set to '${DefaultAccessLevel.WRITE}', but 'HD_PERMISSION_DEFAULT_LOGGED_IN' is set to '${DefaultAccessLevel.NONE}'. This gives everyone greater permissions than logged-in users which is not allowed.`, `'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${DefaultAccessLevel.WRITE}', but 'HD_PERMISSIONS_DEFAULT_LOGGED_IN' is set to '${DefaultAccessLevel.NONE}'. This gives everyone greater permissions than logged-in users which is not allowed.`,
); );
restore(); restore();
}); });
it('when HD_PERMISSION_DEFAULT_EVERYONE is set to read, but HD_PERMISSION_DEFAULT_LOGGED_IN is set to none', async () => { it('when HD_PERMISSIONS_DEFAULT_EVERYONE is set to read, but HD_PERMISSIONS_DEFAULT_LOGGED_IN is set to none', async () => {
const restore = mockedEnv( const restore = mockedEnv(
{ {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.NONE, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.NONE,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}, },
@ -483,7 +483,7 @@ describe('noteConfig', () => {
}, },
); );
expect(() => noteConfig()).toThrow( expect(() => noteConfig()).toThrow(
`'HD_PERMISSION_DEFAULT_EVERYONE' is set to '${DefaultAccessLevel.READ}', but 'HD_PERMISSION_DEFAULT_LOGGED_IN' is set to '${DefaultAccessLevel.NONE}'. This gives everyone greater permissions than logged-in users which is not allowed.`, `'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${DefaultAccessLevel.READ}', but 'HD_PERMISSIONS_DEFAULT_LOGGED_IN' is set to '${DefaultAccessLevel.NONE}'. This gives everyone greater permissions than logged-in users which is not allowed.`,
); );
restore(); restore();
}); });
@ -494,8 +494,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '), HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(), HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ, HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess, HD_GUEST_ACCESS: guestAccess,
HD_REVISION_RETENTION_DAYS: (-1).toString(), HD_REVISION_RETENTION_DAYS: (-1).toString(),
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
@ -505,7 +505,7 @@ describe('noteConfig', () => {
}, },
); );
expect(() => noteConfig()).toThrow( expect(() => noteConfig()).toThrow(
'"HD_REVISION_RETENTION_DAYS" must be greater than or equal to 0', 'HD_REVISION_RETENTION_DAYS: Number must be greater than or equal to 0',
); );
restore(); restore();
}); });

View file

@ -5,72 +5,67 @@
*/ */
import { GuestAccess } from '@hedgedoc/commons'; import { GuestAccess } from '@hedgedoc/commons';
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import * as Joi from 'joi'; import z from 'zod';
import { import {
DefaultAccessLevel, DefaultAccessLevel,
getDefaultAccessLevelOrdinal, getDefaultAccessLevelOrdinal,
} from './default-access-level.enum'; } from './default-access-level.enum';
import { buildErrorMessage, parseOptionalNumber, toArrayConfig } from './utils'; import { parseOptionalNumber, toArrayConfig } from './utils';
import {
buildErrorMessage,
extractDescriptionFromZodIssue,
} from './zod-error-message';
export interface NoteConfig { const schema = z.object({
forbiddenNoteIds: string[]; forbiddenNoteIds: z
maxDocumentLength: number; .array(z.string().min(1))
guestAccess: GuestAccess;
permissions: {
default: {
everyone: DefaultAccessLevel;
loggedIn: DefaultAccessLevel;
};
};
revisionRetentionDays: number;
}
const schema = Joi.object<NoteConfig>({
forbiddenNoteIds: Joi.array()
.items(Joi.string())
.optional() .optional()
.default([]) .default([])
.label('HD_FORBIDDEN_NOTE_IDS'), .describe('HD_FORBIDDEN_NOTE_IDS'),
maxDocumentLength: Joi.number() maxDocumentLength: z
.default(100000) .number()
.int()
.positive() .positive()
.integer()
.optional() .optional()
.label('HD_MAX_DOCUMENT_LENGTH'), .default(100000)
guestAccess: Joi.string() .describe('HD_MAX_DOCUMENT_LENGTH'),
.valid(...Object.values(GuestAccess)) guestAccess: z
.nativeEnum(GuestAccess)
.optional() .optional()
.default(GuestAccess.WRITE) .default(GuestAccess.WRITE)
.label('HD_GUEST_ACCESS'), .describe('HD_GUEST_ACCESS'),
permissions: { permissions: z.object({
default: { default: z.object({
everyone: Joi.string() everyone: z
.valid(...Object.values(DefaultAccessLevel)) .nativeEnum(DefaultAccessLevel)
.optional() .optional()
.default(DefaultAccessLevel.READ) .default(DefaultAccessLevel.READ)
.label('HD_PERMISSION_DEFAULT_EVERYONE'), .describe('HD_PERMISSIONS_DEFAULT_EVERYONE'),
loggedIn: Joi.string() loggedIn: z
.valid(...Object.values(DefaultAccessLevel)) .nativeEnum(DefaultAccessLevel)
.optional() .optional()
.default(DefaultAccessLevel.WRITE) .default(DefaultAccessLevel.WRITE)
.label('HD_PERMISSION_DEFAULT_LOGGED_IN'), .describe('HD_PERMISSIONS_DEFAULT_LOGGED_IN'),
}, }),
}, }),
revisionRetentionDays: Joi.number() revisionRetentionDays: z
.integer() .number()
.default(0) .int()
.min(0) .nonnegative()
.optional() .optional()
.label('HD_REVISION_RETENTION_DAYS'), .default(0)
.describe('HD_REVISION_RETENTION_DAYS'),
}); });
export type NoteConfig = z.infer<typeof schema>;
function checkEveryoneConfigIsConsistent(config: NoteConfig): void { function checkEveryoneConfigIsConsistent(config: NoteConfig): void {
const everyoneDefaultSet = const everyoneDefaultSet =
process.env.HD_PERMISSION_DEFAULT_EVERYONE !== undefined; process.env.HD_PERMISSIONS_DEFAULT_EVERYONE !== undefined;
if (config.guestAccess === GuestAccess.DENY && everyoneDefaultSet) { if (config.guestAccess === GuestAccess.DENY && everyoneDefaultSet) {
throw new Error( throw new Error(
`'HD_GUEST_ACCESS' is set to '${config.guestAccess}', but 'HD_PERMISSION_DEFAULT_EVERYONE' is also configured. Please remove 'HD_PERMISSION_DEFAULT_EVERYONE'.`, `'HD_GUEST_ACCESS' is set to '${config.guestAccess}', but 'HD_PERMISSIONS_DEFAULT_EVERYONE' is also configured. Please remove 'HD_PERMISSIONS_DEFAULT_EVERYONE'.`,
); );
} }
} }
@ -85,41 +80,33 @@ function checkLoggedInUsersHaveHigherDefaultPermissionsThanGuests(
getDefaultAccessLevelOrdinal(loggedIn) getDefaultAccessLevelOrdinal(loggedIn)
) { ) {
throw new Error( throw new Error(
`'HD_PERMISSION_DEFAULT_EVERYONE' is set to '${everyone}', but 'HD_PERMISSION_DEFAULT_LOGGED_IN' is set to '${loggedIn}'. This gives everyone greater permissions than logged-in users which is not allowed.`, `'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${everyone}', but 'HD_PERMISSIONS_DEFAULT_LOGGED_IN' is set to '${loggedIn}'. This gives everyone greater permissions than logged-in users which is not allowed.`,
); );
} }
} }
export default registerAs('noteConfig', () => { export default registerAs('noteConfig', () => {
const noteConfig = schema.validate( const noteConfig = schema.safeParse({
{ forbiddenNoteIds: toArrayConfig(process.env.HD_FORBIDDEN_NOTE_IDS, ','),
forbiddenNoteIds: toArrayConfig(process.env.HD_FORBIDDEN_NOTE_IDS, ','), maxDocumentLength: parseOptionalNumber(process.env.HD_MAX_DOCUMENT_LENGTH),
maxDocumentLength: parseOptionalNumber( guestAccess: process.env.HD_GUEST_ACCESS,
process.env.HD_MAX_DOCUMENT_LENGTH, permissions: {
), default: {
guestAccess: process.env.HD_GUEST_ACCESS, everyone: process.env.HD_PERMISSIONS_DEFAULT_EVERYONE,
permissions: { loggedIn: process.env.HD_PERMISSIONS_DEFAULT_LOGGED_IN,
default: {
everyone: process.env.HD_PERMISSION_DEFAULT_EVERYONE,
loggedIn: process.env.HD_PERMISSION_DEFAULT_LOGGED_IN,
},
}, },
revisionRetentionDays: parseOptionalNumber(
process.env.HD_REVISION_RETENTION_DAYS,
),
} as NoteConfig,
{
abortEarly: false,
presence: 'required',
}, },
); revisionRetentionDays: parseOptionalNumber(
process.env.HD_REVISION_RETENTION_DAYS,
),
});
if (noteConfig.error) { if (noteConfig.error) {
const errorMessages = noteConfig.error.details.map( const errorMessages = noteConfig.error.errors.map((issue) =>
(detail) => detail.message, extractDescriptionFromZodIssue(issue, 'HD'),
); );
throw new Error(buildErrorMessage(errorMessages)); throw new Error(buildErrorMessage(errorMessages));
} }
const config = noteConfig.value; const config = noteConfig.data;
checkEveryoneConfigIsConsistent(config); checkEveryoneConfigIsConsistent(config);
checkLoggedInUsersHaveHigherDefaultPermissionsThanGuests(config); checkLoggedInUsersHaveHigherDefaultPermissionsThanGuests(config);
return config; return config;

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -10,7 +10,6 @@ import {
needToLog, needToLog,
parseOptionalBoolean, parseOptionalBoolean,
parseOptionalNumber, parseOptionalNumber,
replaceAuthErrorsWithEnvironmentVariables,
toArrayConfig, toArrayConfig,
} from './utils'; } from './utils';
@ -39,13 +38,13 @@ describe('config utils', () => {
}); });
it('throws error if there is a duplicate', () => { it('throws error if there is a duplicate', () => {
expect(() => ensureNoDuplicatesExist('Test', ['A', 'A'])).toThrow( expect(() => ensureNoDuplicatesExist('Test', ['A', 'A'])).toThrow(
"Your Test names 'A,A' contain duplicates 'A'", "Your Test names 'A,A' contain duplicates: 'A'",
); );
}); });
it('throws error if there are multiple duplicates', () => { it('throws error if there are multiple duplicates', () => {
expect(() => expect(() =>
ensureNoDuplicatesExist('Test', ['A', 'A', 'B', 'B']), ensureNoDuplicatesExist('Test', ['A', 'A', 'B', 'B']),
).toThrow("Your Test names 'A,A,B,B' contain duplicates 'A,B'"); ).toThrow("Your Test names 'A,A,B,B' contain duplicates: 'A,B'");
}); });
}); });
describe('toArrayConfig', () => { describe('toArrayConfig', () => {
@ -67,28 +66,6 @@ describe('config utils', () => {
]); ]);
}); });
}); });
describe('replaceAuthErrorsWithEnvironmentVariables', () => {
it('"ldap[0].url', () => {
expect(
replaceAuthErrorsWithEnvironmentVariables(
'"ldap[0].url',
'ldap',
'HD_AUTH_LDAP_',
['test'],
),
).toEqual('"HD_AUTH_LDAP_test_URL');
});
it('"ldap[0].url is not changed by gitlab call', () => {
expect(
replaceAuthErrorsWithEnvironmentVariables(
'"ldap[0].url',
'gitlab',
'HD_AUTH_GITLAB_',
['test'],
),
).toEqual('"ldap[0].url');
});
});
describe('needToLog', () => { describe('needToLog', () => {
it('currentLevel ERROR', () => { it('currentLevel ERROR', () => {
const currentLevel = Loglevel.ERROR; const currentLevel = Loglevel.ERROR;

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -22,7 +22,7 @@ export function ensureNoDuplicatesExist(
throw new Error( throw new Error(
`Your ${authName} names '${names.join( `Your ${authName} names '${names.join(
',', ',',
)}' contain duplicates '${duplicates.join(',')}'`, )}' contain duplicates: '${duplicates.join(',')}'`,
); );
} }
} }
@ -39,60 +39,6 @@ export function toArrayConfig(
return configValue.split(separator).map((arrayItem) => arrayItem.trim()); return configValue.split(separator).map((arrayItem) => arrayItem.trim());
} }
export function buildErrorMessage(errorMessages: string[]): string {
let totalErrorMessage = 'There were some errors with your configuration:';
for (const message of errorMessages) {
totalErrorMessage += '\n - ';
totalErrorMessage += message;
}
totalErrorMessage +=
'\nFor further information, have a look at our configuration docs at https://docs.hedgedoc.org/configuration';
return totalErrorMessage;
}
export function replaceAuthErrorsWithEnvironmentVariables(
message: string,
name: string,
replacement: string,
arrayOfNames: string[],
): string {
// this builds a regex like /"gitlab\[(\d+)]\./ to extract the position in the arrayOfNames
const regex = new RegExp('"' + name + '\\[(\\d+)]\\.', 'g');
let newMessage = message.replace(
regex,
(_, index: number) => `"${replacement}${arrayOfNames[index]}.`,
);
if (newMessage != message) {
newMessage = newMessage.replace('.providerName', '_PROVIDER_NAME');
newMessage = newMessage.replace('.baseURL', '_BASE_URL');
newMessage = newMessage.replace('.clientID', '_CLIENT_ID');
newMessage = newMessage.replace('.url', '_URL');
newMessage = newMessage.replace('.clientSecret', '_CLIENT_SECRET');
newMessage = newMessage.replace('.bindDn', '_BIND_DN');
newMessage = newMessage.replace('.bindCredentials', '_BIND_CREDENTIALS');
newMessage = newMessage.replace('.searchBase', '_SEARCH_BASE');
newMessage = newMessage.replace('.searchFilter', '_SEARCH_FILTER');
newMessage = newMessage.replace('.searchAttributes', '_SEARCH_ATTRIBUTES');
newMessage = newMessage.replace('.userIdField', '_USER_ID_FIELD');
newMessage = newMessage.replace('.userNameField', '_USER_NAME_FIELD');
newMessage = newMessage.replace('.displayNameField', '_DISPLAY_NAME_FIELD');
newMessage = newMessage.replace('.emailField', '_EMAIL_FIELD');
newMessage = newMessage.replace(
'.profilePictureField',
'_PROFILE_PICTURE_FIELD',
);
newMessage = newMessage.replace('.authorizeUrl', '_AUTHORIZE_URL');
newMessage = newMessage.replace('.tokenUrl', '_TOKEN_URL');
newMessage = newMessage.replace('.userinfoUrl', '_USERINFO_URL');
newMessage = newMessage.replace('.endSessionUrl', '_END_SESSION_URL');
newMessage = newMessage.replace('.scope', '_SCOPE');
newMessage = newMessage.replace('.tlsCaCerts', '_TLS_CERT_PATHS');
newMessage = newMessage.replace('.issuer', '_ISSUER');
newMessage = newMessage.replace('.theme', '_THEME');
}
return newMessage;
}
export function needToLog( export function needToLog(
currentLoglevel: Loglevel, currentLoglevel: Loglevel,
requestedLoglevel: Loglevel, requestedLoglevel: Loglevel,

View file

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import z from 'zod';
import { extractDescriptionFromZodIssue } from './zod-error-message';
const PREFIX = 'HD_TEST';
describe('zod error message', () => {
describe('extractDescriptionFromZodSchema', () => {
it('correctly builds an error message on a simple object', () => {
const schema = z.object({
port: z.number().describe('port').positive(),
});
const results = schema.safeParse({
port: -1,
});
expect(results.error).toBeDefined();
const errorMessages = results.error!.errors.map((issue) =>
extractDescriptionFromZodIssue(issue, PREFIX),
);
expect(errorMessages).toHaveLength(1);
expect(errorMessages[0]).toEqual(
`${PREFIX}_PORT: Number must be greater than 0`,
);
});
it('correctly builds an error message on an array object', () => {
const schema = z.object({
array: z.array(z.number().positive()).describe('array'),
});
const results = schema.safeParse({
array: [1, -1],
});
expect(results.error).toBeDefined();
const errorMessages = results.error!.errors.map((issue) =>
extractDescriptionFromZodIssue(issue, PREFIX),
);
expect(errorMessages).toHaveLength(1);
expect(errorMessages[0]).toEqual(
`${PREFIX}_ARRAY[1]: Number must be greater than 0`,
);
});
});
});

View file

@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ZodIssue } from 'zod';
function camelToSnakeCase(str: string): string {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
export function extractDescriptionFromZodIssue(
issue: ZodIssue,
prefix: string,
allArrays?: Record<string, string[]>,
): string {
let identifier: string = prefix;
for (let index = 0; index < issue.path.length; index++) {
const pathSegment = issue.path[index];
if (typeof pathSegment === 'string') {
identifier += '_' + camelToSnakeCase(pathSegment).toUpperCase();
} else if (index >= 1) {
const previousPathSegment = issue.path[index - 1] as string;
if (allArrays && allArrays[previousPathSegment]) {
identifier += '_' + allArrays[previousPathSegment][pathSegment];
} else {
identifier += `[${pathSegment}]`;
}
}
}
return `${identifier}: ${issue.message}`;
}
export function buildErrorMessage(errorMessages: string[]): string {
let totalErrorMessage = 'There were some errors with your configuration:';
for (const message of errorMessages) {
totalErrorMessage += '\n - ';
totalErrorMessage += message;
}
totalErrorMessage +=
'\nFor further information, have a look at our configuration docs at https://docs.hedgedoc.org/configuration\n';
return totalErrorMessage;
}

View file

@ -67,11 +67,11 @@ describe('FrontendConfigService', () => {
identifier: 'oidcTestIdentifier', identifier: 'oidcTestIdentifier',
providerName: 'oidcTestProviderName', providerName: 'oidcTestProviderName',
issuer: 'oidcTestIssuer', issuer: 'oidcTestIssuer',
clientID: 'oidcTestId', clientId: 'oidcTestId',
clientSecret: 'oidcTestSecret', clientSecret: 'oidcTestSecret',
scope: 'openid profile email', scope: 'openid profile email',
userIdField: '', userIdField: '',
userNameField: '', usernameField: '',
displayNameField: '', displayNameField: '',
profilePictureField: '', profilePictureField: '',
emailField: '', emailField: '',
@ -82,7 +82,7 @@ describe('FrontendConfigService', () => {
const appConfig: AppConfig = { const appConfig: AppConfig = {
baseUrl: domain, baseUrl: domain,
rendererBaseUrl: 'https://renderer.example.org', rendererBaseUrl: 'https://renderer.example.org',
port: 3000, backendPort: 3000,
loglevel: Loglevel.ERROR, loglevel: Loglevel.ERROR,
showLogTimestamp: false, showLogTimestamp: false,
persistInterval: 10, persistInterval: 10,
@ -182,7 +182,7 @@ describe('FrontendConfigService', () => {
const appConfig: AppConfig = { const appConfig: AppConfig = {
baseUrl: domain, baseUrl: domain,
rendererBaseUrl: 'https://renderer.example.org', rendererBaseUrl: 'https://renderer.example.org',
port: 3000, backendPort: 3000,
loglevel: Loglevel.ERROR, loglevel: Loglevel.ERROR,
showLogTimestamp: false, showLogTimestamp: false,
persistInterval: 10, persistInterval: 10,
@ -207,7 +207,7 @@ describe('FrontendConfigService', () => {
}, },
}; };
const externalServicesConfig: ExternalServicesConfig = { const externalServicesConfig: ExternalServicesConfig = {
plantUmlServer: plantUmlServer, plantumlServer: plantUmlServer,
imageProxy: imageProxy, imageProxy: imageProxy,
}; };
const noteConfig: NoteConfig = { const noteConfig: NoteConfig = {

View file

@ -52,8 +52,8 @@ export class FrontendConfigService {
authProviders: this.getAuthProviders(), authProviders: this.getAuthProviders(),
branding: this.getBranding(), branding: this.getBranding(),
maxDocumentLength: this.noteConfig.maxDocumentLength, maxDocumentLength: this.noteConfig.maxDocumentLength,
plantUmlServer: this.externalServicesConfig.plantUmlServer plantUmlServer: this.externalServicesConfig.plantumlServer
? new URL(this.externalServicesConfig.plantUmlServer).toString() ? new URL(this.externalServicesConfig.plantumlServer).toString()
: null, : null,
specialUrls: this.getSpecialUrls(), specialUrls: this.getSpecialUrls(),
useImageProxy: !!this.externalServicesConfig.imageProxy, useImageProxy: !!this.externalServicesConfig.imageProxy,

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -44,8 +44,8 @@ async function bootstrap(): Promise<void> {
await setupApp(app, appConfig, authConfig, mediaConfig, logger); await setupApp(app, appConfig, authConfig, mediaConfig, logger);
// Start the server // Start the server
await app.listen(appConfig.port); await app.listen(appConfig.backendPort);
logger.warn(`Listening on port ${appConfig.port}`, 'AppBootstrap'); logger.warn(`Listening on port ${appConfig.backendPort}`, 'AppBootstrap');
} }
void bootstrap(); void bootstrap();

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -14,7 +14,10 @@ import {
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { FileTypeResult } from 'file-type'; import { FileTypeResult } from 'file-type';
import mediaConfiguration, { MediaConfig } from '../../config/media.config'; import mediaConfiguration, {
AzureMediaConfig,
MediaConfig,
} from '../../config/media.config';
import { MediaBackendError } from '../../errors/errors'; import { MediaBackendError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface'; import { MediaBackend } from '../media-backend.interface';
@ -22,7 +25,7 @@ import { BackendType } from './backend-type.enum';
@Injectable() @Injectable()
export class AzureBackend implements MediaBackend { export class AzureBackend implements MediaBackend {
private config: MediaConfig['backend']['azure']; private config: AzureMediaConfig['azure'];
private client: ContainerClient; private client: ContainerClient;
private readonly credential: StorageSharedKeyCredential; private readonly credential: StorageSharedKeyCredential;
@ -32,7 +35,7 @@ export class AzureBackend implements MediaBackend {
private mediaConfig: MediaConfig, private mediaConfig: MediaConfig,
) { ) {
this.logger.setContext(AzureBackend.name); this.logger.setContext(AzureBackend.name);
this.config = this.mediaConfig.backend.azure; this.config = (this.mediaConfig.backend as AzureMediaConfig).azure;
if (this.mediaConfig.backend.use === BackendType.AZURE) { if (this.mediaConfig.backend.use === BackendType.AZURE) {
// only create the client if the backend is configured to azure // only create the client if the backend is configured to azure
const blobServiceClient = BlobServiceClient.fromConnectionString( const blobServiceClient = BlobServiceClient.fromConnectionString(

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -8,7 +8,10 @@ import { FileTypeResult } from 'file-type';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { join } from 'path'; import { join } from 'path';
import mediaConfiguration, { MediaConfig } from '../../config/media.config'; import mediaConfiguration, {
FilesystemMediaConfig,
MediaConfig,
} from '../../config/media.config';
import { MediaBackendError } from '../../errors/errors'; import { MediaBackendError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface'; import { MediaBackend } from '../media-backend.interface';
@ -23,7 +26,9 @@ export class FilesystemBackend implements MediaBackend {
private mediaConfig: MediaConfig, private mediaConfig: MediaConfig,
) { ) {
this.logger.setContext(FilesystemBackend.name); this.logger.setContext(FilesystemBackend.name);
this.uploadDirectory = this.mediaConfig.backend.filesystem.uploadPath; this.uploadDirectory = (
this.mediaConfig.backend as FilesystemMediaConfig
).filesystem.uploadPath;
} }
async saveFile( async saveFile(

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -7,7 +7,10 @@ import { Inject, Injectable } from '@nestjs/common';
import fetch, { Response } from 'node-fetch'; import fetch, { Response } from 'node-fetch';
import { URLSearchParams } from 'url'; import { URLSearchParams } from 'url';
import mediaConfiguration, { MediaConfig } from '../../config/media.config'; import mediaConfiguration, {
ImgurMediaConfig,
MediaConfig,
} from '../../config/media.config';
import { MediaBackendError } from '../../errors/errors'; import { MediaBackendError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface'; import { MediaBackend } from '../media-backend.interface';
@ -26,7 +29,7 @@ interface ImgurBackendData {
@Injectable() @Injectable()
export class ImgurBackend implements MediaBackend { export class ImgurBackend implements MediaBackend {
private config: MediaConfig['backend']['imgur']; private config: ImgurMediaConfig['imgur'];
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
@ -34,7 +37,7 @@ export class ImgurBackend implements MediaBackend {
private mediaConfig: MediaConfig, private mediaConfig: MediaConfig,
) { ) {
this.logger.setContext(ImgurBackend.name); this.logger.setContext(ImgurBackend.name);
this.config = this.mediaConfig.backend.imgur; this.config = (this.mediaConfig.backend as ImgurMediaConfig).imgur;
} }
async saveFile(uuid: string, buffer: Buffer): Promise<string> { async saveFile(uuid: string, buffer: Buffer): Promise<string> {
@ -46,7 +49,7 @@ export class ImgurBackend implements MediaBackend {
method: 'POST', method: 'POST',
body: params, body: params,
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
headers: { Authorization: `Client-ID ${this.config.clientID}` }, headers: { Authorization: `Client-ID ${this.config.clientId}` },
}) })
.then((res) => ImgurBackend.checkStatus(res)) .then((res) => ImgurBackend.checkStatus(res))
.then((res) => res.json())) as UploadResult; .then((res) => res.json())) as UploadResult;
@ -80,7 +83,7 @@ export class ImgurBackend implements MediaBackend {
{ {
method: 'DELETE', method: 'DELETE',
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
headers: { Authorization: `Client-ID ${this.config.clientID}` }, headers: { Authorization: `Client-ID ${this.config.clientId}` },
}, },
); );
ImgurBackend.checkStatus(result); ImgurBackend.checkStatus(result);

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -48,7 +48,7 @@ describe('s3 backend', () => {
accessKeyId: mockedS3AccessKeyId, accessKeyId: mockedS3AccessKeyId,
secretAccessKey: mockedS3SecretAccessKey, secretAccessKey: mockedS3SecretAccessKey,
bucket: mockedS3Bucket, bucket: mockedS3Bucket,
endPoint: endPoint, endpoint: endPoint,
}, },
}, },
}); });

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -8,7 +8,10 @@ import { FileTypeResult } from 'file-type';
import { Client } from 'minio'; import { Client } from 'minio';
import { URL } from 'url'; import { URL } from 'url';
import mediaConfiguration, { MediaConfig } from '../../config/media.config'; import mediaConfiguration, {
MediaConfig,
S3MediaConfig,
} from '../../config/media.config';
import { MediaBackendError } from '../../errors/errors'; import { MediaBackendError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface'; import { MediaBackend } from '../media-backend.interface';
@ -16,7 +19,7 @@ import { BackendType } from './backend-type.enum';
@Injectable() @Injectable()
export class S3Backend implements MediaBackend { export class S3Backend implements MediaBackend {
private config: MediaConfig['backend']['s3']; private config: S3MediaConfig['s3'];
private client: Client; private client: Client;
private static determinePort(url: URL): number | undefined { private static determinePort(url: URL): number | undefined {
@ -34,7 +37,7 @@ export class S3Backend implements MediaBackend {
return; return;
} }
this.config = this.mediaConfig.backend.s3; this.config = this.mediaConfig.backend.s3;
const url = new URL(this.config.endPoint); const url = new URL(this.config.endpoint);
const isSecure = url.protocol === 'https:'; const isSecure = url.protocol === 'https:';
this.client = new Client({ this.client = new Client({
endPoint: url.hostname, endPoint: url.hostname,

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -8,7 +8,10 @@ import { FileTypeResult } from 'file-type';
import fetch, { Response } from 'node-fetch'; import fetch, { Response } from 'node-fetch';
import { URL } from 'url'; import { URL } from 'url';
import mediaConfiguration, { MediaConfig } from '../../config/media.config'; import mediaConfiguration, {
MediaConfig,
WebdavMediaConfig,
} from '../../config/media.config';
import { MediaBackendError } from '../../errors/errors'; import { MediaBackendError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface'; import { MediaBackend } from '../media-backend.interface';
@ -16,7 +19,7 @@ import { BackendType } from './backend-type.enum';
@Injectable() @Injectable()
export class WebdavBackend implements MediaBackend { export class WebdavBackend implements MediaBackend {
private config: MediaConfig['backend']['webdav']; private config: WebdavMediaConfig['webdav'];
private authHeader: string; private authHeader: string;
private readonly baseUrl: string; private readonly baseUrl: string;

View file

@ -10,12 +10,12 @@ We officially support and test these databases:
We don't necessarily support MySQL. We don't necessarily support MySQL.
<!-- markdownlint-disable proper-names --> <!-- markdownlint-disable proper-names -->
| environment variable | default | example | description | | environment variable | default | example | description |
|-----------------------|---------|---------------------|----------------------------------------------------------------------------------------| |------------------------|---------|---------------------|----------------------------------------------------------------------------------------|
| `HD_DATABASE_TYPE` | - | `postgres` | The database type you want to use. This can be `postgres`, `mariadb` or `sqlite`. | | `HD_DATABASE_TYPE` | - | `postgres` | The database type you want to use. This can be `postgres`, `mariadb` or `sqlite`. |
| `HD_DATABASE_NAME` | - | `hedgedoc` | The name of the database to use. When using SQLite, this is the path to the database file. | | `HD_DATABASE_NAME` | - | `hedgedoc` | The name of the database to use. When using SQLite, this is the path to the database file. |
| `HD_DATABASE_HOST` | - | `db.example.com` | The host, where the database runs. *Only if you're **not** using `sqlite`.* | | `HD_DATABASE_HOST` | - | `db.example.com` | The host, where the database runs. *Only if you're **not** using `sqlite`.* |
| `HD_DATABASE_PORT` | - | `5432` | The port, where the database runs. *Only if you're **not** using `sqlite`.* | | `HD_DATABASE_PORT` | - | `5432` | The port, where the database runs. *Only if you're **not** using `sqlite`.* |
| `HD_DATABASE_USER` | - | `hedgedoc` | The user that logs in the database. *Only if you're **not** using `sqlite`.* | | `HD_DATABASE_USERNAME` | - | `hedgedoc` | The user that logs in the database. *Only if you're **not** using `sqlite`.* |
| `HD_DATABASE_PASS` | - | `password` | The password to log into the database. *Only if you're **not** using `sqlite`.* | | `HD_DATABASE_PASSWORD` | - | `password` | The password to log into the database. *Only if you're **not** using `sqlite`.* |
<!-- markdownlint-enable proper-names --> <!-- markdownlint-enable proper-names -->

View file

@ -1,11 +1,11 @@
# Notes # Notes
| environment variable | default | example | description | | environment variable | default | example | description |
|-----------------------------------|---------|-----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |------------------------------------|---------|-----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `HD_FORBIDDEN_NOTE_IDS` | - | `notAllowed, alsoNotAllowed` | A list of note ids (separated by `,`), that are not allowed to be created or requested by anyone. | | `HD_FORBIDDEN_NOTE_IDS` | - | `notAllowed, alsoNotAllowed` | A list of note ids (separated by `,`), that are not allowed to be created or requested by anyone. |
| `HD_MAX_DOCUMENT_LENGTH` | 100000 | | The maximum length of any one document. Changes to this will impact performance for your users. | | `HD_MAX_DOCUMENT_LENGTH` | 100000 | | The maximum length of any one document. Changes to this will impact performance for your users. |
| `HD_GUEST_ACCESS` | `write` | `deny`, `read`, `write`, `create` | Defines the maximum access level for guest users to the instance. If guest access is set lower than the "everyone" permission of a note then the note permission will be overridden. | | `HD_GUEST_ACCESS` | `write` | `deny`, `read`, `write`, `create` | Defines the maximum access level for guest users to the instance. If guest access is set lower than the "everyone" permission of a note then the note permission will be overridden. |
| `HD_PERMISSION_DEFAULT_LOGGED_IN` | `write` | `none`, `read`, `write` | The default permission for the "logged-in" group that is set on new notes. | | `HD_PERMISSIONS_DEFAULT_LOGGED_IN` | `write` | `none`, `read`, `write` | The default permission for the "logged-in" group that is set on new notes. |
| `HD_PERMISSION_DEFAULT_EVERYONE` | `read` | `none`, `read`, `write` | The default permission for the "everyone" group (logged-in & guest users), that is set on new notes created by logged-in users. Notes created by guests always set this to "write". | | `HD_PERMISSIONS_DEFAULT_EVERYONE` | `read` | `none`, `read`, `write` | The default permission for the "everyone" group (logged-in & guest users), that is set on new notes created by logged-in users. Notes created by guests always set this to "write". |
| `HD_PERSIST_INTERVAL` | 10 | `0`, `5`, `10`, `20` | The time interval in **minutes** for the periodic note revision creation during realtime editing. `0` deactivates the periodic note revision creation. | | `HD_PERSIST_INTERVAL` | 10 | `0`, `5`, `10`, `20` | The time interval in **minutes** for the periodic note revision creation during realtime editing. `0` deactivates the periodic note revision creation. |
| `HD_REVISION_RETENTION_DAYS` | 0 | | The number of days a revision should be kept. If the config option is not set or set to 0, all revisions will be kept forever. | | `HD_REVISION_RETENTION_DAYS` | 0 | | The number of days a revision should be kept. If the config option is not set or set to 0, all revisions will be kept forever. |