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
parent 91ebd519a8
commit 926f7a5e49
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
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
*/
@ -19,7 +19,9 @@ import appConfig from './config/app.config';
import authConfig from './config/auth.config';
import cspConfig from './config/csp.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 mediaConfig from './config/media.config';
import noteConfig from './config/note.config';
@ -63,7 +65,7 @@ const routes: Routes = [
imports: [ConfigModule, LoggerModule],
inject: [databaseConfig.KEY, TypeormLoggerService],
useFactory: (
databaseConfig: DatabaseConfig,
databaseConfig: PostgresDatabaseConfig,
logger: TypeormLoggerService,
) => {
return {
@ -72,7 +74,7 @@ const routes: Routes = [
port: databaseConfig.port,
username: databaseConfig.username,
password: databaseConfig.password,
database: databaseConfig.database,
database: databaseConfig.name,
autoLoadEntities: true,
logging: true,
logger: logger,

View file

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

View file

@ -94,7 +94,7 @@ export class OidcService {
const redirectUri = `${this.appConfig.baseUrl}/api/private/auth/oidc/${oidcConfig.identifier}/callback`;
const client = new issuer.Client({
/* eslint-disable @typescript-eslint/naming-convention */
client_id: oidcConfig.clientID,
client_id: oidcConfig.clientId,
client_secret: oidcConfig.clientSecret,
redirect_uris: [redirectUri],
response_types: ['code'],
@ -205,7 +205,7 @@ export class OidcService {
);
const username = OidcService.getResponseFieldValue(
userInfoResponse,
oidcConfig.userNameField,
oidcConfig.usernameField,
userId,
).toLowerCase() as Lowercase<string>;
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
*/
@ -42,7 +42,7 @@ describe('appConfig', () => {
const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
expect(config.port).toEqual(port);
expect(config.backendPort).toEqual(port);
expect(config.loglevel).toEqual(loglevel);
expect(config.showLogTimestamp).toEqual(showLogTimestamp);
expect(config.persistInterval).toEqual(100);
@ -67,7 +67,7 @@ describe('appConfig', () => {
const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(baseUrl);
expect(config.port).toEqual(port);
expect(config.backendPort).toEqual(port);
expect(config.loglevel).toEqual(loglevel);
expect(config.showLogTimestamp).toEqual(showLogTimestamp);
expect(config.persistInterval).toEqual(100);
@ -92,7 +92,7 @@ describe('appConfig', () => {
const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
expect(config.port).toEqual(3000);
expect(config.backendPort).toEqual(3000);
expect(config.loglevel).toEqual(loglevel);
expect(config.showLogTimestamp).toEqual(showLogTimestamp);
expect(config.persistInterval).toEqual(100);
@ -117,7 +117,7 @@ describe('appConfig', () => {
const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
expect(config.port).toEqual(port);
expect(config.backendPort).toEqual(port);
expect(config.loglevel).toEqual(Loglevel.WARN);
expect(config.showLogTimestamp).toEqual(showLogTimestamp);
expect(config.persistInterval).toEqual(100);
@ -142,7 +142,7 @@ describe('appConfig', () => {
const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
expect(config.port).toEqual(port);
expect(config.backendPort).toEqual(port);
expect(config.loglevel).toEqual(Loglevel.TRACE);
expect(config.showLogTimestamp).toEqual(showLogTimestamp);
expect(config.persistInterval).toEqual(10);
@ -168,7 +168,7 @@ describe('appConfig', () => {
const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
expect(config.port).toEqual(port);
expect(config.backendPort).toEqual(port);
expect(config.loglevel).toEqual(Loglevel.TRACE);
expect(config.showLogTimestamp).toEqual(showLogTimestamp);
expect(config.persistInterval).toEqual(0);
@ -192,7 +192,7 @@ describe('appConfig', () => {
const config = appConfig();
expect(config.baseUrl).toEqual(baseUrl);
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
expect(config.port).toEqual(port);
expect(config.backendPort).toEqual(port);
expect(config.loglevel).toEqual(Loglevel.TRACE);
expect(config.showLogTimestamp).toEqual(true);
expect(config.persistInterval).toEqual(0);
@ -232,7 +232,7 @@ describe('appConfig', () => {
},
);
expect(() => appConfig()).toThrow(
'"HD_BASE_URL" must not contain a subdirectory',
'HD_BASE_URL: baseUrl must not contain a subdirectory',
);
restore();
});
@ -252,7 +252,7 @@ describe('appConfig', () => {
},
);
expect(() => appConfig()).toThrow(
'"HD_BACKEND_PORT" must be a positive number',
'HD_BACKEND_PORT: Number must be greater than 0',
);
restore();
});
@ -272,7 +272,7 @@ describe('appConfig', () => {
},
);
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();
});
@ -291,7 +291,9 @@ describe('appConfig', () => {
clear: true,
},
);
expect(() => appConfig()).toThrow('"HD_BACKEND_PORT" must be an integer');
expect(() => appConfig()).toThrow(
'HD_BACKEND_PORT: Expected integer, received float',
);
restore();
});
@ -309,7 +311,9 @@ describe('appConfig', () => {
clear: true,
},
);
expect(() => appConfig()).toThrow('"HD_BACKEND_PORT" must be a number');
expect(() => appConfig()).toThrow(
'HD_BACKEND_PORT: Expected number, received nan',
);
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
*/
@ -9,94 +9,104 @@ import {
WrongProtocolError,
} from '@hedgedoc/commons';
import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';
import { CustomHelpers, ErrorReport } from 'joi';
import z, { RefinementCtx } from 'zod';
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 {
baseUrl: string;
rendererBaseUrl: string;
port: number;
loglevel: Loglevel;
showLogTimestamp: boolean;
persistInterval: number;
}
function validateUrl(
value: string,
helpers: CustomHelpers,
): string | ErrorReport {
function validateUrl(value: string | undefined, ctx: RefinementCtx): void {
if (!value) {
return z.NEVER;
}
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) {
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) {
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 {
throw error;
}
}
}
const schema = Joi.object({
baseUrl: Joi.string().custom(validateUrl).label('HD_BASE_URL'),
rendererBaseUrl: Joi.string()
.custom(validateUrl)
.default(Joi.ref('baseUrl'))
.optional()
.label('HD_RENDERER_BASE_URL'),
port: Joi.number()
.positive()
.integer()
.default(3000)
.max(65535)
.optional()
.label('HD_BACKEND_PORT'),
loglevel: Joi.string()
.valid(...Object.values(Loglevel))
.default(Loglevel.WARN)
.optional()
.label('HD_LOGLEVEL'),
showLogTimestamp: Joi.boolean()
.default(true)
.optional()
.label('HD_SHOW_LOG_TIMESTAMP'),
persistInterval: Joi.number()
.integer()
.min(0)
.default(10)
.optional()
.label('HD_PERSIST_INTERVAL'),
}).messages({
// eslint-disable-next-line @typescript-eslint/naming-convention
'url.noSubDirectoryAllowed': '{{#label}} must not contain a subdirectory',
// eslint-disable-next-line @typescript-eslint/naming-convention
'url.wrongProtocol': '{{#label}} protocol must be HTTP or HTTPS',
});
const schema = z
.object({
baseUrl: z.string().superRefine(validateUrl).describe('HD_BASE_URL'),
rendererBaseUrl: z
.string()
.superRefine(validateUrl)
.default('')
.describe('HD_RENDERER_BASE_URL'),
backendPort: z
.number()
.positive()
.int()
.max(65535)
.default(3000)
.describe('HD_BACKEND_PORT'),
loglevel: z
.enum(Object.values(Loglevel) as [Loglevel, ...Loglevel[]])
.default(Loglevel.WARN)
.describe('HD_LOGLEVEL'),
showLogTimestamp: z
.boolean()
.default(true)
.describe('HD_SHOW_LOG_TIMESTAMP'),
persistInterval: z.coerce
.number()
.int()
.min(0)
.default(10)
.describe('HD_PERSIST_INTERVAL'),
})
.transform((data) => {
// Handle the default reference for rendererBaseUrl
if (data.rendererBaseUrl === '') {
data.rendererBaseUrl = data.baseUrl;
}
return data;
});
export type AppConfig = z.infer<typeof schema>;
export default registerAs('appConfig', () => {
const appConfig = schema.validate(
{
baseUrl: process.env.HD_BASE_URL,
rendererBaseUrl: process.env.HD_RENDERER_BASE_URL,
port: parseOptionalNumber(process.env.HD_BACKEND_PORT),
loglevel: process.env.HD_LOGLEVEL,
showLogTimestamp: process.env.HD_SHOW_LOG_TIMESTAMP,
persistInterval: process.env.HD_PERSIST_INTERVAL,
},
{
abortEarly: false,
presence: 'required',
},
);
const appConfig = schema.safeParse({
baseUrl: process.env.HD_BASE_URL,
rendererBaseUrl: process.env.HD_RENDERER_BASE_URL,
backendPort: parseOptionalNumber(process.env.HD_BACKEND_PORT),
loglevel: process.env.HD_LOGLEVEL,
showLogTimestamp: parseOptionalBoolean(process.env.HD_SHOW_LOG_TIMESTAMP),
persistInterval: process.env.HD_PERSIST_INTERVAL,
});
if (appConfig.error) {
const errorMessages = appConfig.error.details.map(
(detail) => detail.message,
const errorMessages = appConfig.error.errors.map((issue) =>
extractDescriptionFromZodIssue(issue, 'HD'),
);
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
*/
@ -129,7 +129,7 @@ describe('authConfig', () => {
},
);
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();
});
@ -147,7 +147,7 @@ describe('authConfig', () => {
},
);
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();
});
@ -200,6 +200,7 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -232,6 +233,7 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -263,6 +265,7 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -294,6 +297,7 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -325,6 +329,7 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -356,6 +361,7 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -387,6 +393,7 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -418,6 +425,7 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -449,6 +457,7 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.ldap).toBeDefined();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0]);
@ -481,7 +490,7 @@ describe('authConfig', () => {
},
);
expect(() => authConfig()).toThrow(
'"HD_AUTH_LDAP_FUTURAMA_URL" is required',
'HD_AUTH_LDAP_FUTURAMA_URL: Required',
);
restore();
});
@ -499,7 +508,7 @@ describe('authConfig', () => {
},
);
expect(() => authConfig()).toThrow(
'"HD_AUTH_LDAP_FUTURAMA_SEARCH_BASE" is required',
'HD_AUTH_LDAP_FUTURAMA_SEARCH_BASE: Required',
);
restore();
});
@ -517,7 +526,7 @@ describe('authConfig', () => {
},
);
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();
});
@ -582,11 +591,12 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -595,7 +605,7 @@ describe('authConfig', () => {
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
@ -616,11 +626,12 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toBeUndefined();
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -629,7 +640,7 @@ describe('authConfig', () => {
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
@ -650,11 +661,12 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toBeUndefined();
@ -663,7 +675,7 @@ describe('authConfig', () => {
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
@ -684,11 +696,12 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -697,7 +710,7 @@ describe('authConfig', () => {
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
@ -718,11 +731,12 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -731,7 +745,7 @@ describe('authConfig', () => {
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
@ -752,11 +766,12 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -765,7 +780,7 @@ describe('authConfig', () => {
expect(firstOidc.endSessionUrl).toBeUndefined();
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
@ -786,11 +801,12 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -799,7 +815,7 @@ describe('authConfig', () => {
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.scope).toEqual(defaultScope);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
@ -820,11 +836,12 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -833,7 +850,7 @@ describe('authConfig', () => {
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.userIdField).toEqual(defaultUserIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
@ -854,11 +871,12 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -867,7 +885,7 @@ describe('authConfig', () => {
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(defaultDisplayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
@ -888,11 +906,12 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -901,7 +920,7 @@ describe('authConfig', () => {
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(
defaultProfilePictureField,
@ -924,11 +943,12 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -937,7 +957,7 @@ describe('authConfig', () => {
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(defaultEmailField);
@ -958,11 +978,12 @@ describe('authConfig', () => {
},
);
const config = authConfig();
expect(config.oidc).toBeDefined();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientId).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
@ -971,7 +992,7 @@ describe('authConfig', () => {
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.endSessionUrl).toEqual(endSessionUrl);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.usernameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
@ -994,7 +1015,7 @@ describe('authConfig', () => {
},
);
expect(() => authConfig()).toThrow(
'"HD_AUTH_OIDC_GITLAB_ISSUER" is required',
'HD_AUTH_OIDC_GITLAB_ISSUER: Required',
);
restore();
});
@ -1012,7 +1033,7 @@ describe('authConfig', () => {
},
);
expect(() => authConfig()).toThrow(
'"HD_AUTH_OIDC_GITLAB_CLIENT_ID" is required',
'HD_AUTH_OIDC_GITLAB_CLIENT_ID: Required',
);
restore();
});
@ -1030,7 +1051,7 @@ describe('authConfig', () => {
},
);
expect(() => authConfig()).toThrow(
'"HD_AUTH_OIDC_GITLAB_CLIENT_SECRET" is required',
'HD_AUTH_OIDC_GITLAB_CLIENT_SECRET: Required',
);
restore();
});
@ -1047,7 +1068,9 @@ describe('authConfig', () => {
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();
});
});

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
*/
import { registerAs } from '@nestjs/config';
import * as fs from 'fs';
import * as Joi from 'joi';
import fs from 'fs';
import z from 'zod';
import { Theme } from './theme.enum';
import {
buildErrorMessage,
ensureNoDuplicatesExist,
parseOptionalBoolean,
parseOptionalNumber,
replaceAuthErrorsWithEnvironmentVariables,
toArrayConfig,
} from './utils';
import {
buildErrorMessage,
extractDescriptionFromZodIssue,
} from './zod-error-message';
export interface InternalIdentifier {
identifier: string;
providerName: string;
}
export interface LDAPConfig extends InternalIdentifier {
url: string;
bindDn?: string;
bindCredentials?: string;
searchBase: string;
searchFilter: string;
searchAttributes: string[];
userIdField: string;
displayNameField: string;
emailField: string;
profilePictureField: string;
tlsCaCerts?: string[];
}
export interface OidcConfig extends InternalIdentifier {
issuer: string;
clientID: string;
clientSecret: string;
theme?: string;
authorizeUrl?: string;
tokenUrl?: string;
userinfoUrl?: string;
endSessionUrl?: string;
scope: string;
userNameField: string;
userIdField: string;
displayNameField: string;
profilePictureField: string;
emailField: string;
enableRegistration?: boolean;
}
export interface AuthConfig {
common: {
allowProfileEdits: boolean;
allowChooseUsername: boolean;
syncSource?: string;
};
session: {
secret: string;
lifetime: number;
};
local: {
enableLogin: 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()
const ldapSchema = z
.object({
identifier: z.string().describe('HD_AUTH_LDAP_SERVERS'),
providerName: z
.string()
.default('LDAP')
.describe('HD_AUTH_LDAP_*_PROVIDER_NAME'),
url: z.string().describe('HD_AUTH_LDAP_*_URL'),
bindDn: z.string().optional().describe('HD_AUTH_LDAP_*_BIND_DN'),
bindCredentials: z
.string()
.optional()
.describe('HD_AUTH_LDAP_*_BIND_CREDENTIALS'),
searchBase: z.string().describe('HD_AUTH_LDAP_*_SEARCH_BASE'),
searchFilter: z
.string()
.default('(uid={{username}})')
.describe('HD_AUTH_LDAP_*_SEARCH_FILTER'),
searchAttributes: z
.array(z.string())
.optional()
.describe('HD_AUTH_LDAP_*_SEARCH_ATTRIBUTES'),
userIdField: z
.string()
.default('uid')
.describe('HD_AUTH_LDAP_*_USER_ID_FIELD'),
displayNameField: z
.string()
.default('displayName')
.describe('HD_AUTH_LDAP_*_DISPLAY_NAME_FIELD'),
emailField: z
.string()
.default('mail')
.describe('HD_AUTH_LDAP_*_EMAIL_FIELD'),
profilePictureField: z
.string()
.default('jpegPhoto')
.describe('HD_AUTH_LDAP_*_PROFILE_PICTURE_FIELD'),
tlsCaCerts: z
.array(
z.string({
// eslint-disable-next-line @typescript-eslint/naming-convention
required_error: 'File not found',
}),
)
.optional()
.describe('HD_AUTH_LDAP_*_TLS_CA_CERTS'),
tlsRejectUnauthorized: z
.boolean()
.default(true)
.describe('HD_AUTH_LDAP_*_TLS_REJECT_UNAUTHORIZED'),
tlsSniName: z.string().optional().describe('HD_AUTH_LDAP_*_TLS_SNI_NAME'),
tlsAllowPartialTrustChain: z
.boolean()
.optional()
.label('HD_AUTH_ALLOW_PROFILE_EDITS'),
allowChooseUsername: Joi.boolean()
.default(true)
.describe('HD_AUTH_LDAP_*_TLS_ALLOW_PARTIAL_TRUST_CHAIN'),
tlsMinVersion: z
.enum(['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'])
.optional()
.label('HD_AUTH_ALLOW_CHOOSE_USERNAME'),
syncSource: Joi.string().optional().label('HD_AUTH_SYNC_SOURCE'),
},
session: {
secret: Joi.string().label('HD_SESSION_SECRET'),
lifetime: Joi.number()
.default(1209600) // 14 * 24 * 60 * 60s = 14 days
.describe('HD_AUTH_LDAP_*_TLS_MIN_VERSION'),
tlsMaxVersion: z
.enum(['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'])
.optional()
.label('HD_SESSION_LIFETIME'),
},
local: {
enableLogin: Joi.boolean()
.default(false)
.optional()
.label('HD_AUTH_LOCAL_ENABLE_LOGIN'),
enableRegister: Joi.boolean()
.default(false)
.optional()
.label('HD_AUTH_LOCAL_ENABLE_REGISTER'),
minimalPasswordStrength: Joi.number()
.default(2)
.min(0)
.max(4)
.optional()
.label('HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH'),
},
ldap: Joi.array()
.items(
Joi.object({
identifier: Joi.string(),
providerName: Joi.string().default('LDAP').optional(),
url: Joi.string(),
bindDn: Joi.string().optional(),
bindCredentials: Joi.string().optional(),
searchBase: Joi.string(),
searchFilter: Joi.string().default('(uid={{username}})').optional(),
searchAttributes: Joi.array().items(Joi.string()).optional(),
userIdField: Joi.string().default('uid').optional(),
displayNameField: Joi.string().default('displayName').optional(),
emailField: Joi.string().default('mail').optional(),
profilePictureField: Joi.string().default('jpegPhoto').optional(),
tlsCaCerts: Joi.array().items(Joi.string()).optional(),
}).optional(),
)
.optional(),
oidc: Joi.array()
.items(
Joi.object({
identifier: Joi.string(),
providerName: Joi.string().default('OpenID Connect').optional(),
issuer: Joi.string(),
clientID: Joi.string(),
clientSecret: Joi.string(),
theme: Joi.string()
.valid(...Object.values(Theme))
.optional(),
authorizeUrl: Joi.string().optional(),
tokenUrl: Joi.string().optional(),
userinfoUrl: Joi.string().optional(),
endSessionUrl: Joi.string().optional(),
scope: Joi.string().default('openid profile email').optional(),
userIdField: Joi.string().default('sub').optional(),
userNameField: Joi.string().default('preferred_username').optional(),
displayNameField: Joi.string().default('name').optional(),
profilePictureField: Joi.string().default('picture').optional(),
emailField: Joi.string().default('email').optional(),
enableRegistration: Joi.boolean().default(true).optional(),
}).optional(),
)
.optional(),
.describe('HD_AUTH_LDAP_*_TLS_MAX_VERSION'),
})
.superRefine((config, ctx) => {
const tlsMin = config.tlsMinVersion?.replace('TLSv', '');
const tlsMax = config.tlsMaxVersion?.replace('TLSv', '');
if (tlsMin && tlsMax && tlsMin > tlsMax) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'TLS min version must be less than or equal to TLS max version',
fatal: true,
});
}
if ((tlsMin && tlsMin < '1.2') || (tlsMax && tlsMax < '1.2')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'For security reasons, consider using TLS version 1.2 or higher',
fatal: false,
});
}
});
const oidcSchema = z.object({
identifier: z.string().describe('HD_AUTH_OIDC_SERVERS'),
providerName: z
.string()
.default('OpenID Connect')
.describe('HD_AUTH_OIDC_*_PROVIDER_NAME'),
issuer: z.string().url().describe('HD_AUTH_OIDC_*_ISSUER'),
clientId: z.string().describe('HD_AUTH_OIDC_*_CLIENT_ID'),
clientSecret: z.string().describe('HD_AUTH_OIDC_*_CLIENT_SECRET'),
theme: z.nativeEnum(Theme).optional().describe('HD_AUTH_OIDC_*_THEME'),
authorizeUrl: z
.string()
.url()
.optional()
.describe('HD_AUTH_OIDC_*_AUTHORIZE_URL'),
tokenUrl: z.string().url().optional().describe('HD_AUTH_OIDC_*_TOKEN_URL'),
userinfoUrl: z
.string()
.url()
.optional()
.describe('HD_AUTH_OIDC_*_USERINFO_URL'),
endSessionUrl: z
.string()
.url()
.optional()
.describe('HD_AUTH_OIDC_*_END_SESSION_URL'),
scope: z
.string()
.default('openid profile email')
.describe('HD_AUTH_OIDC_*_SCOPE'),
usernameField: z
.string()
.default('preferred_username')
.describe('HD_AUTH_OIDC_*_USERNAME_FIELD'),
userIdField: z
.string()
.default('sub')
.describe('HD_AUTH_OIDC_*_USER_ID_FIELD'),
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', () => {
const ldapNames = (
toArrayConfig(process.env.HD_AUTH_LDAP_SERVERS, ',') ?? []
).map((name) => name.toUpperCase());
ensureNoDuplicatesExist('LDAP', ldapNames);
const ldapServers = (process.env.HD_AUTH_LDAP_SERVERS?.split(',') ?? []).map(
(name) => name.toUpperCase(),
);
ensureNoDuplicatesExist('LDAP', ldapServers);
const oidcNames = (
toArrayConfig(process.env.HD_AUTH_OIDC_SERVERS, ',') ?? []
).map((name) => name.toUpperCase());
ensureNoDuplicatesExist('OIDC', oidcNames);
const oidcServers = (process.env.HD_AUTH_OIDC_SERVERS?.split(',') ?? []).map(
(name) => name.toUpperCase(),
);
ensureNoDuplicatesExist('OIDC', oidcServers);
const ldapInstances = ldapNames.map((ldapName) => {
const ldapConfig: Partial<LdapConfig>[] = ldapServers.map((name) => {
const caFiles = toArrayConfig(
process.env[`HD_AUTH_LDAP_${ldapName}_TLS_CERT_PATHS`],
process.env[`HD_AUTH_LDAP_${name}_TLS_CERT_PATHS`],
',',
);
let tlsCaCerts = undefined;
@ -180,106 +226,97 @@ export default registerAs('authConfig', () => {
});
}
return {
identifier: ldapName.toLowerCase(),
providerName: process.env[`HD_AUTH_LDAP_${ldapName}_PROVIDER_NAME`],
url: process.env[`HD_AUTH_LDAP_${ldapName}_URL`],
bindDn: process.env[`HD_AUTH_LDAP_${ldapName}_BIND_DN`],
bindCredentials: process.env[`HD_AUTH_LDAP_${ldapName}_BIND_CREDENTIALS`],
searchBase: process.env[`HD_AUTH_LDAP_${ldapName}_SEARCH_BASE`],
searchFilter: process.env[`HD_AUTH_LDAP_${ldapName}_SEARCH_FILTER`],
searchAttributes: toArrayConfig(
process.env[`HD_AUTH_LDAP_${ldapName}_SEARCH_ATTRIBUTES`],
',',
),
userIdField: process.env[`HD_AUTH_LDAP_${ldapName}_USER_ID_FIELD`],
displayNameField:
process.env[`HD_AUTH_LDAP_${ldapName}_DISPLAY_NAME_FIELD`],
emailField: process.env[`HD_AUTH_LDAP_${ldapName}_EMAIL_FIELD`],
identifier: name.toLowerCase(),
providerName: process.env[`HD_AUTH_LDAP_${name}_PROVIDER_NAME`],
url: process.env[`HD_AUTH_LDAP_${name}_URL`],
bindDn: process.env[`HD_AUTH_LDAP_${name}_BIND_DN`],
bindCredentials: process.env[`HD_AUTH_LDAP_${name}_BIND_CREDENTIALS`],
searchBase: process.env[`HD_AUTH_LDAP_${name}_SEARCH_BASE`],
searchFilter: process.env[`HD_AUTH_LDAP_${name}_SEARCH_FILTER`],
searchAttributes:
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`],
emailField: process.env[`HD_AUTH_LDAP_${name}_EMAIL_FIELD`],
profilePictureField:
process.env[`HD_AUTH_LDAP_${ldapName}_PROFILE_PICTURE_FIELD`],
tlsCaCerts: tlsCaCerts,
process.env[`HD_AUTH_LDAP_${name}_PROFILE_PICTURE_FIELD`],
// 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) => ({
identifier: oidcName.toLowerCase(),
providerName: process.env[`HD_AUTH_OIDC_${oidcName}_PROVIDER_NAME`],
issuer: process.env[`HD_AUTH_OIDC_${oidcName}_ISSUER`],
clientID: process.env[`HD_AUTH_OIDC_${oidcName}_CLIENT_ID`],
clientSecret: process.env[`HD_AUTH_OIDC_${oidcName}_CLIENT_SECRET`],
theme: process.env[`HD_AUTH_OIDC_${oidcName}_THEME`],
authorizeUrl: process.env[`HD_AUTH_OIDC_${oidcName}_AUTHORIZE_URL`],
tokenUrl: process.env[`HD_AUTH_OIDC_${oidcName}_TOKEN_URL`],
userinfoUrl: process.env[`HD_AUTH_OIDC_${oidcName}_USERINFO_URL`],
endSessionUrl: process.env[`HD_AUTH_OIDC_${oidcName}_END_SESSION_URL`],
scope: process.env[`HD_AUTH_OIDC_${oidcName}_SCOPE`],
userIdField: process.env[`HD_AUTH_OIDC_${oidcName}_USER_ID_FIELD`],
userNameField: process.env[`HD_AUTH_OIDC_${oidcName}_USER_NAME_FIELD`],
displayNameField:
process.env[`HD_AUTH_OIDC_${oidcName}_DISPLAY_NAME_FIELD`],
const oidcConfig: Partial<OidcConfig>[] = oidcServers.map((name) => ({
identifier: name.toLowerCase(),
providerName: process.env[`HD_AUTH_OIDC_${name}_PROVIDER_NAME`],
issuer: process.env[`HD_AUTH_OIDC_${name}_ISSUER`],
clientId: process.env[`HD_AUTH_OIDC_${name}_CLIENT_ID`],
clientSecret: process.env[`HD_AUTH_OIDC_${name}_CLIENT_SECRET`],
theme: process.env[`HD_AUTH_OIDC_${name}_THEME`] as Theme | undefined,
authorizeUrl: process.env[`HD_AUTH_OIDC_${name}_AUTHORIZE_URL`],
tokenUrl: process.env[`HD_AUTH_OIDC_${name}_TOKEN_URL`],
userinfoUrl: process.env[`HD_AUTH_OIDC_${name}_USERINFO_URL`],
endSessionUrl: process.env[`HD_AUTH_OIDC_${name}_END_SESSION_URL`],
scope: process.env[`HD_AUTH_OIDC_${name}_SCOPE`],
userIdField: process.env[`HD_AUTH_OIDC_${name}_USER_ID_FIELD`],
userNameField: process.env[`HD_AUTH_OIDC_${name}_USER_NAME_FIELD`],
displayNameField: process.env[`HD_AUTH_OIDC_${name}_DISPLAY_NAME_FIELD`],
profilePictureField:
process.env[`HD_AUTH_OIDC_${oidcName}_PROFILE_PICTURE_FIELD`],
emailField: process.env[`HD_AUTH_OIDC_${oidcName}_EMAIL_FIELD`],
process.env[`HD_AUTH_OIDC_${name}_PROFILE_PICTURE_FIELD`],
emailField: process.env[`HD_AUTH_OIDC_${name}_EMAIL_FIELD`],
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;
if (syncSource !== undefined) {
syncSource = syncSource.toLowerCase();
}
const authConfig = schema.safeParse({
common: {
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) {
const errorMessages = authConfig.error.details
.map((detail) => detail.message)
.map((error) =>
replaceAuthErrorsWithEnvironmentVariables(
error,
'ldap',
'HD_AUTH_LDAP_',
ldapNames,
),
)
.map((error) =>
replaceAuthErrorsWithEnvironmentVariables(
error,
'oidc',
'HD_AUTH_OIDC_',
oidcNames,
),
);
const errorMessages = authConfig.error.errors.map((issue) =>
extractDescriptionFromZodIssue(issue, 'HD_AUTH', {
ldap: ldapServers,
oidc: oidcServers,
}),
);
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
*/
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 {
enable: boolean;
reportURI: string;
}
const cspSchema = Joi.object({
enable: Joi.boolean().default(true).optional().label('HD_CSP_ENABLE'),
reportURI: Joi.string().optional().label('HD_CSP_REPORT_URI'),
const cspSchema = z.object({
enable: z.boolean().default(true).describe('HD_CSP_ENABLED'),
reportURI: z.string().optional().describe('HD_CSP_REPORT_URI'),
});
export type CspConfig = z.infer<typeof cspSchema>;
export default registerAs('cspConfig', () => {
if (
process.env.HD_CSP_ENABLE !== undefined ||
@ -28,21 +30,15 @@ export default registerAs('cspConfig', () => {
);
}
const cspConfig = cspSchema.validate(
{
enable: process.env.HD_CSP_ENABLE || true,
reportURI: process.env.HD_CSP_REPORT_URI,
},
{
abortEarly: false,
presence: 'required',
},
);
const cspConfig = cspSchema.safeParse({
enable: parseOptionalBoolean(process.env.HD_CSP_ENABLED),
reportURI: process.env.HD_CSP_REPORT_URI,
});
if (cspConfig.error) {
const errorMessages = cspConfig.error.details.map(
(detail) => detail.message,
const errorMessages = cspConfig.error.errors.map((issue) =>
extractDescriptionFromZodIssue(issue, 'HD_CSP'),
);
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
*/
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 {
branding: {
customName: string | null;
customLogo: string | null;
};
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'),
const schema = z.object({
branding: z.object({
customName: z.string().or(z.null()).describe('HD_CUSTOM_NAME'),
customLogo: z.string().url().or(z.null()).describe('HD_CUSTOM_LOGO'),
}),
specialUrls: Joi.object({
privacy: Joi.string()
.uri({
scheme: /https?/,
})
.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'),
specialUrls: z.object({
privacy: z.string().url().or(z.null()).describe('HD_PRIVACY_URL'),
termsOfUse: z.string().url().or(z.null()).describe('HD_TERMS_OF_USE_URL'),
imprint: z.string().url().or(z.null()).describe('HD_IMPRINT_URL'),
}),
});
export type CustomizationConfig = z.infer<typeof schema>;
export default registerAs('customizationConfig', () => {
const customizationConfig = schema.validate(
{
branding: {
customName: process.env.HD_CUSTOM_NAME ?? 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,
},
const customizationConfig = schema.safeParse({
branding: {
customName: process.env.HD_CUSTOM_NAME ?? null,
customLogo: process.env.HD_CUSTOM_LOGO ?? null,
},
{
abortEarly: false,
presence: 'required',
specialUrls: {
privacy: process.env.HD_PRIVACY_URL ?? null,
termsOfUse: process.env.HD_TERMS_OF_USE_URL ?? null,
imprint: process.env.HD_IMPRINT_URL ?? null,
},
);
});
if (customizationConfig.error) {
const errorMessages = customizationConfig.error.details.map(
(detail) => detail.message,
const errorMessages = customizationConfig.error.errors.map((issue) =>
extractDescriptionFromZodIssue(issue, 'HD'),
);
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
*/
import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';
import z from 'zod';
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 {
username: string;
password: string;
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 sqliteDbSchema = z.object({
type: z.literal(DatabaseType.SQLITE).describe('HD_DATABASE_TYPE'),
name: z.string().describe('HD_DATABASE_NAME'),
});
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', () => {
const databaseConfig = databaseSchema.validate(
{
type: process.env.HD_DATABASE_TYPE,
username: process.env.HD_DATABASE_USER,
password: process.env.HD_DATABASE_PASS,
database: process.env.HD_DATABASE_NAME,
host: process.env.HD_DATABASE_HOST,
port: parseOptionalNumber(process.env.HD_DATABASE_PORT),
},
{
abortEarly: false,
presence: 'required',
},
);
const databaseConfig = dbSchema.safeParse({
type: process.env.HD_DATABASE_TYPE,
username: process.env.HD_DATABASE_USERNAME,
password: process.env.HD_DATABASE_PASSWORD,
name: process.env.HD_DATABASE_NAME,
host: process.env.HD_DATABASE_HOST,
port: parseOptionalNumber(process.env.HD_DATABASE_PORT),
});
if (databaseConfig.error) {
const errorMessages = databaseConfig.error.details.map(
(detail) => detail.message,
const errorMessages = databaseConfig.error.errors.map((issue) =>
extractDescriptionFromZodIssue(issue, 'HD_DATABASE'),
);
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
*/
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 {
plantUmlServer: string | null;
imageProxy: string;
}
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'),
const schema = z.object({
plantumlServer: z.string().url().or(z.null()).describe('HD_PLANTUML_SERVER'),
imageProxy: z.string().url().or(z.null()).describe('HD_IMAGE_PROXY'),
});
export type ExternalServicesConfig = z.infer<typeof schema>;
export default registerAs('externalServicesConfig', () => {
if (process.env.HD_IMAGE_PROXY !== undefined) {
throw new Error(
"HD_IMAGE_PROXY is currently not yet supported. Please don't configure it",
);
}
const externalConfig = schema.validate(
{
plantUmlServer: process.env.HD_PLANTUML_SERVER ?? null,
imageProxy: process.env.HD_IMAGE_PROXY,
},
{
abortEarly: false,
presence: 'required',
},
);
const externalConfig = schema.safeParse({
plantumlServer: process.env.HD_PLANTUML_SERVER ?? null,
imageProxy: process.env.HD_IMAGE_PROXY ?? null,
});
if (externalConfig.error) {
const errorMessages = externalConfig.error.details.map(
(detail) => detail.message,
const errorMessages = externalConfig.error.errors.map((issue) =>
extractDescriptionFromZodIssue(issue, 'HD'),
);
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
*/
import mockedEnv from 'mocked-env';
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', () => {
// Filesystem
@ -41,7 +47,7 @@ describe('mediaConfig', () => {
clear: true,
},
);
const config = mediaConfig();
const config = mediaConfig() as { backend: FilesystemMediaConfig };
expect(config.backend.use).toEqual(BackendType.FILESYSTEM);
expect(config.backend.filesystem.uploadPath).toEqual(uploadPath);
restore();
@ -64,12 +70,12 @@ describe('mediaConfig', () => {
clear: true,
},
);
const config = mediaConfig();
const config = mediaConfig() as { backend: S3MediaConfig };
expect(config.backend.use).toEqual(BackendType.S3);
expect(config.backend.s3.accessKeyId).toEqual(accessKeyId);
expect(config.backend.s3.secretAccessKey).toEqual(secretAccessKey);
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.pathStyle).toEqual(pathStyle);
restore();
@ -88,7 +94,7 @@ describe('mediaConfig', () => {
clear: true,
},
);
const config = mediaConfig();
const config = mediaConfig() as { backend: AzureMediaConfig };
expect(config.backend.use).toEqual(BackendType.AZURE);
expect(config.backend.azure.connectionString).toEqual(
azureConnectionString,
@ -109,9 +115,9 @@ describe('mediaConfig', () => {
clear: true,
},
);
const config = mediaConfig();
const config = mediaConfig() as { backend: ImgurMediaConfig };
expect(config.backend.use).toEqual(BackendType.IMGUR);
expect(config.backend.imgur.clientID).toEqual(clientID);
expect(config.backend.imgur.clientId).toEqual(clientID);
restore();
});
@ -129,7 +135,7 @@ describe('mediaConfig', () => {
clear: true,
},
);
const config = mediaConfig();
const config = mediaConfig() as { backend: WebdavMediaConfig };
expect(config.backend.use).toEqual(BackendType.WEBDAV);
expect(config.backend.webdav.connectionString).toEqual(
webdavConnectionString,
@ -154,7 +160,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH" is required',
'HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH: Required',
);
restore();
});
@ -176,7 +182,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_S3_ACCESS_KEY" is required',
'HD_MEDIA_BACKEND_S3_ACCESS_KEY_ID: Required',
);
restore();
});
@ -195,7 +201,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_S3_SECRET_KEY" is required',
'HD_MEDIA_BACKEND_S3_SECRET_ACCESS_KEY: Required',
);
restore();
});
@ -214,7 +220,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_S3_BUCKET" is required',
'HD_MEDIA_BACKEND_S3_BUCKET: Required',
);
restore();
});
@ -233,7 +239,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_S3_ENDPOINT" is required',
'HD_MEDIA_BACKEND_S3_ENDPOINT: Required',
);
restore();
});
@ -253,27 +259,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_S3_ENDPOINT" must be a valid uri with a scheme matching the ^https? pattern',
);
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',
'HD_MEDIA_BACKEND_S3_ENDPOINT: Invalid url',
);
restore();
});
@ -293,7 +279,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING" is required',
'HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING: Required',
);
restore();
});
@ -310,7 +296,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_AZURE_CONTAINER" is required',
'HD_MEDIA_BACKEND_AZURE_CONTAINER: Required',
);
restore();
});
@ -329,7 +315,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_IMGUR_CLIENT_ID" is required',
'HD_MEDIA_BACKEND_IMGUR_CLIENT_ID: Required',
);
restore();
});
@ -350,7 +336,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING" is required',
'HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING: Required',
);
restore();
});
@ -369,7 +355,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING" must be a valid uri',
'HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING: Invalid url',
);
restore();
});
@ -387,7 +373,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL" is required',
'HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL: Required',
);
restore();
});
@ -406,7 +392,7 @@ describe('mediaConfig', () => {
},
);
expect(() => mediaConfig()).toThrow(
'"HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL" must be a valid uri',
'HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL: Invalid url',
);
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
*/
import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';
import z from 'zod';
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 {
backend: MediaBackendConfig;
}
export interface MediaBackendConfig {
use: BackendType;
filesystem: {
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 azureSchema = z.object({
use: z.literal(BackendType.AZURE),
azure: z.object({
connectionString: z
.string()
.describe('HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING'),
container: z.string().describe('HD_MEDIA_BACKEND_AZURE_CONTAINER'),
}),
});
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', () => {
const mediaConfig = mediaSchema.validate(
{
backend: {
use: process.env.HD_MEDIA_BACKEND,
filesystem: {
uploadPath: process.env.HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH,
},
s3: {
accessKeyId: process.env.HD_MEDIA_BACKEND_S3_ACCESS_KEY,
secretAccessKey: process.env.HD_MEDIA_BACKEND_S3_SECRET_KEY,
bucket: process.env.HD_MEDIA_BACKEND_S3_BUCKET,
endPoint: process.env.HD_MEDIA_BACKEND_S3_ENDPOINT,
region: process.env.HD_MEDIA_BACKEND_S3_REGION,
pathStyle: parseOptionalBoolean(
process.env.HD_MEDIA_BACKEND_S3_PATH_STYLE,
),
},
azure: {
connectionString:
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,
},
webdav: {
connectionString:
process.env.HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING,
uploadDir: process.env.HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR,
publicUrl: process.env.HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL,
},
const mediaConfig = schema.safeParse({
backend: {
use: process.env.HD_MEDIA_BACKEND,
filesystem: {
uploadPath: process.env.HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH,
},
s3: {
accessKeyId: process.env.HD_MEDIA_BACKEND_S3_ACCESS_KEY,
secretAccessKey: process.env.HD_MEDIA_BACKEND_S3_SECRET_KEY,
bucket: process.env.HD_MEDIA_BACKEND_S3_BUCKET,
endpoint: process.env.HD_MEDIA_BACKEND_S3_ENDPOINT,
region: process.env.HD_MEDIA_BACKEND_S3_REGION,
pathStyle: parseOptionalBoolean(
process.env.HD_MEDIA_BACKEND_S3_PATH_STYLE,
),
},
azure: {
connectionString: 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,
},
webdav: {
connectionString: process.env.HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING,
uploadDir: process.env.HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR,
publicUrl: process.env.HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL,
},
},
{
abortEarly: false,
presence: 'required',
},
);
});
if (mediaConfig.error) {
const errorMessages = mediaConfig.error.details.map(
(detail) => detail.message,
const errorMessages = mediaConfig.error.errors.map((issue) =>
extractDescriptionFromZodIssue(issue, 'HD_MEDIA'),
);
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
*/
@ -13,7 +13,7 @@ export function createDefaultMockAppConfig(): AppConfig {
return {
baseUrl: 'md.example.com',
rendererBaseUrl: 'md-renderer.example.com',
port: 3000,
backendPort: 3000,
loglevel: Loglevel.ERROR,
showLogTimestamp: true,
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
*/
@ -13,7 +13,7 @@ export function createDefaultMockDatabaseConfig(): DatabaseConfig {
return {
type: (process.env.HEDGEDOC_TEST_DB_TYPE ||
DatabaseType.SQLITE) as DatabaseType,
database: 'hedgedoc',
name: 'hedgedoc',
password: 'hedgedoc',
host: 'localhost',
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
*/
@ -10,7 +10,7 @@ import { ExternalServicesConfig } from '../external-services.config';
export function createDefaultMockExternalServicesConfig(): ExternalServicesConfig {
return {
plantUmlServer: 'https://plantuml.example.com',
plantumlServer: 'https://plantuml.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
*/
@ -17,26 +17,6 @@ export function createDefaultMockMediaConfig(): MediaConfig {
uploadPath:
'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 */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
HD_REVISION_RETENTION_DAYS: retentionDays.toString(),
/* eslint-enable @typescript-eslint/naming-convention */
@ -58,8 +58,8 @@ describe('noteConfig', () => {
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -86,8 +86,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteId,
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -115,8 +115,8 @@ describe('noteConfig', () => {
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -145,7 +145,7 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -174,7 +174,7 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -203,7 +203,7 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
@ -231,8 +231,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -263,8 +263,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: invalidforbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -273,7 +273,7 @@ describe('noteConfig', () => {
},
);
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();
});
@ -284,8 +284,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: negativeMaxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -294,7 +294,7 @@ describe('noteConfig', () => {
},
);
expect(() => noteConfig()).toThrow(
'"HD_MAX_DOCUMENT_LENGTH" must be a positive number',
'HD_MAX_DOCUMENT_LENGTH: Number must be greater than 0',
);
restore();
});
@ -305,8 +305,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: floatMaxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -315,7 +315,7 @@ describe('noteConfig', () => {
},
);
expect(() => noteConfig()).toThrow(
'"HD_MAX_DOCUMENT_LENGTH" must be an integer',
'HD_MAX_DOCUMENT_LENGTH: Expected integer, received float',
);
restore();
});
@ -326,8 +326,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: invalidMaxDocumentLength,
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -336,19 +336,19 @@ describe('noteConfig', () => {
},
);
expect(() => noteConfig()).toThrow(
'"HD_MAX_DOCUMENT_LENGTH" must be a number',
'HD_MAX_DOCUMENT_LENGTH: Expected number, received nan',
);
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(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: wrongDefaultPermission,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: wrongDefaultPermission,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -357,19 +357,19 @@ describe('noteConfig', () => {
},
);
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();
});
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(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: wrongDefaultPermission,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: wrongDefaultPermission,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -378,7 +378,7 @@ describe('noteConfig', () => {
},
);
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();
});
@ -399,7 +399,7 @@ describe('noteConfig', () => {
},
);
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();
});
@ -410,8 +410,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: 'deny',
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -420,19 +420,19 @@ describe('noteConfig', () => {
},
);
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();
});
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(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.WRITE,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.WRITE,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -441,19 +441,19 @@ describe('noteConfig', () => {
},
);
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();
});
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(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.WRITE,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.NONE,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.WRITE,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.NONE,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -462,19 +462,19 @@ describe('noteConfig', () => {
},
);
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();
});
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(
{
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.NONE,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.NONE,
HD_GUEST_ACCESS: guestAccess,
/* eslint-enable @typescript-eslint/naming-convention */
},
@ -483,7 +483,7 @@ describe('noteConfig', () => {
},
);
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();
});
@ -494,8 +494,8 @@ describe('noteConfig', () => {
/* eslint-disable @typescript-eslint/naming-convention */
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_EVERYONE: DefaultAccessLevel.READ,
HD_PERMISSIONS_DEFAULT_LOGGED_IN: DefaultAccessLevel.READ,
HD_GUEST_ACCESS: guestAccess,
HD_REVISION_RETENTION_DAYS: (-1).toString(),
/* eslint-enable @typescript-eslint/naming-convention */
@ -505,7 +505,7 @@ describe('noteConfig', () => {
},
);
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();
});

View file

@ -5,72 +5,67 @@
*/
import { GuestAccess } from '@hedgedoc/commons';
import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';
import z from 'zod';
import {
DefaultAccessLevel,
getDefaultAccessLevelOrdinal,
} 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 {
forbiddenNoteIds: string[];
maxDocumentLength: number;
guestAccess: GuestAccess;
permissions: {
default: {
everyone: DefaultAccessLevel;
loggedIn: DefaultAccessLevel;
};
};
revisionRetentionDays: number;
}
const schema = Joi.object<NoteConfig>({
forbiddenNoteIds: Joi.array()
.items(Joi.string())
const schema = z.object({
forbiddenNoteIds: z
.array(z.string().min(1))
.optional()
.default([])
.label('HD_FORBIDDEN_NOTE_IDS'),
maxDocumentLength: Joi.number()
.default(100000)
.describe('HD_FORBIDDEN_NOTE_IDS'),
maxDocumentLength: z
.number()
.int()
.positive()
.integer()
.optional()
.label('HD_MAX_DOCUMENT_LENGTH'),
guestAccess: Joi.string()
.valid(...Object.values(GuestAccess))
.default(100000)
.describe('HD_MAX_DOCUMENT_LENGTH'),
guestAccess: z
.nativeEnum(GuestAccess)
.optional()
.default(GuestAccess.WRITE)
.label('HD_GUEST_ACCESS'),
permissions: {
default: {
everyone: Joi.string()
.valid(...Object.values(DefaultAccessLevel))
.describe('HD_GUEST_ACCESS'),
permissions: z.object({
default: z.object({
everyone: z
.nativeEnum(DefaultAccessLevel)
.optional()
.default(DefaultAccessLevel.READ)
.label('HD_PERMISSION_DEFAULT_EVERYONE'),
loggedIn: Joi.string()
.valid(...Object.values(DefaultAccessLevel))
.describe('HD_PERMISSIONS_DEFAULT_EVERYONE'),
loggedIn: z
.nativeEnum(DefaultAccessLevel)
.optional()
.default(DefaultAccessLevel.WRITE)
.label('HD_PERMISSION_DEFAULT_LOGGED_IN'),
},
},
revisionRetentionDays: Joi.number()
.integer()
.default(0)
.min(0)
.describe('HD_PERMISSIONS_DEFAULT_LOGGED_IN'),
}),
}),
revisionRetentionDays: z
.number()
.int()
.nonnegative()
.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 {
const everyoneDefaultSet =
process.env.HD_PERMISSION_DEFAULT_EVERYONE !== undefined;
process.env.HD_PERMISSIONS_DEFAULT_EVERYONE !== undefined;
if (config.guestAccess === GuestAccess.DENY && everyoneDefaultSet) {
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)
) {
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', () => {
const noteConfig = schema.validate(
{
forbiddenNoteIds: toArrayConfig(process.env.HD_FORBIDDEN_NOTE_IDS, ','),
maxDocumentLength: parseOptionalNumber(
process.env.HD_MAX_DOCUMENT_LENGTH,
),
guestAccess: process.env.HD_GUEST_ACCESS,
permissions: {
default: {
everyone: process.env.HD_PERMISSION_DEFAULT_EVERYONE,
loggedIn: process.env.HD_PERMISSION_DEFAULT_LOGGED_IN,
},
const noteConfig = schema.safeParse({
forbiddenNoteIds: toArrayConfig(process.env.HD_FORBIDDEN_NOTE_IDS, ','),
maxDocumentLength: parseOptionalNumber(process.env.HD_MAX_DOCUMENT_LENGTH),
guestAccess: process.env.HD_GUEST_ACCESS,
permissions: {
default: {
everyone: process.env.HD_PERMISSIONS_DEFAULT_EVERYONE,
loggedIn: process.env.HD_PERMISSIONS_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) {
const errorMessages = noteConfig.error.details.map(
(detail) => detail.message,
const errorMessages = noteConfig.error.errors.map((issue) =>
extractDescriptionFromZodIssue(issue, 'HD'),
);
throw new Error(buildErrorMessage(errorMessages));
}
const config = noteConfig.value;
const config = noteConfig.data;
checkEveryoneConfigIsConsistent(config);
checkLoggedInUsersHaveHigherDefaultPermissionsThanGuests(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
*/
@ -10,7 +10,6 @@ import {
needToLog,
parseOptionalBoolean,
parseOptionalNumber,
replaceAuthErrorsWithEnvironmentVariables,
toArrayConfig,
} from './utils';
@ -39,13 +38,13 @@ describe('config utils', () => {
});
it('throws error if there is a duplicate', () => {
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', () => {
expect(() =>
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', () => {
@ -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', () => {
it('currentLevel 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
*/
@ -22,7 +22,7 @@ export function ensureNoDuplicatesExist(
throw new Error(
`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());
}
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(
currentLoglevel: 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',
providerName: 'oidcTestProviderName',
issuer: 'oidcTestIssuer',
clientID: 'oidcTestId',
clientId: 'oidcTestId',
clientSecret: 'oidcTestSecret',
scope: 'openid profile email',
userIdField: '',
userNameField: '',
usernameField: '',
displayNameField: '',
profilePictureField: '',
emailField: '',
@ -82,7 +82,7 @@ describe('FrontendConfigService', () => {
const appConfig: AppConfig = {
baseUrl: domain,
rendererBaseUrl: 'https://renderer.example.org',
port: 3000,
backendPort: 3000,
loglevel: Loglevel.ERROR,
showLogTimestamp: false,
persistInterval: 10,
@ -182,7 +182,7 @@ describe('FrontendConfigService', () => {
const appConfig: AppConfig = {
baseUrl: domain,
rendererBaseUrl: 'https://renderer.example.org',
port: 3000,
backendPort: 3000,
loglevel: Loglevel.ERROR,
showLogTimestamp: false,
persistInterval: 10,
@ -207,7 +207,7 @@ describe('FrontendConfigService', () => {
},
};
const externalServicesConfig: ExternalServicesConfig = {
plantUmlServer: plantUmlServer,
plantumlServer: plantUmlServer,
imageProxy: imageProxy,
};
const noteConfig: NoteConfig = {

View file

@ -52,8 +52,8 @@ export class FrontendConfigService {
authProviders: this.getAuthProviders(),
branding: this.getBranding(),
maxDocumentLength: this.noteConfig.maxDocumentLength,
plantUmlServer: this.externalServicesConfig.plantUmlServer
? new URL(this.externalServicesConfig.plantUmlServer).toString()
plantUmlServer: this.externalServicesConfig.plantumlServer
? new URL(this.externalServicesConfig.plantumlServer).toString()
: null,
specialUrls: this.getSpecialUrls(),
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
*/
@ -44,8 +44,8 @@ async function bootstrap(): Promise<void> {
await setupApp(app, appConfig, authConfig, mediaConfig, logger);
// Start the server
await app.listen(appConfig.port);
logger.warn(`Listening on port ${appConfig.port}`, 'AppBootstrap');
await app.listen(appConfig.backendPort);
logger.warn(`Listening on port ${appConfig.backendPort}`, 'AppBootstrap');
}
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
*/
@ -14,7 +14,10 @@ import {
import { Inject, Injectable } from '@nestjs/common';
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 { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface';
@ -22,7 +25,7 @@ import { BackendType } from './backend-type.enum';
@Injectable()
export class AzureBackend implements MediaBackend {
private config: MediaConfig['backend']['azure'];
private config: AzureMediaConfig['azure'];
private client: ContainerClient;
private readonly credential: StorageSharedKeyCredential;
@ -32,7 +35,7 @@ export class AzureBackend implements MediaBackend {
private mediaConfig: MediaConfig,
) {
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) {
// only create the client if the backend is configured to azure
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
*/
@ -8,7 +8,10 @@ import { FileTypeResult } from 'file-type';
import { promises as fs } from 'fs';
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 { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface';
@ -23,7 +26,9 @@ export class FilesystemBackend implements MediaBackend {
private mediaConfig: MediaConfig,
) {
this.logger.setContext(FilesystemBackend.name);
this.uploadDirectory = this.mediaConfig.backend.filesystem.uploadPath;
this.uploadDirectory = (
this.mediaConfig.backend as FilesystemMediaConfig
).filesystem.uploadPath;
}
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
*/
@ -7,7 +7,10 @@ import { Inject, Injectable } from '@nestjs/common';
import fetch, { Response } from 'node-fetch';
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 { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface';
@ -26,7 +29,7 @@ interface ImgurBackendData {
@Injectable()
export class ImgurBackend implements MediaBackend {
private config: MediaConfig['backend']['imgur'];
private config: ImgurMediaConfig['imgur'];
constructor(
private readonly logger: ConsoleLoggerService,
@ -34,7 +37,7 @@ export class ImgurBackend implements MediaBackend {
private mediaConfig: MediaConfig,
) {
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> {
@ -46,7 +49,7 @@ export class ImgurBackend implements MediaBackend {
method: 'POST',
body: params,
// 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) => res.json())) as UploadResult;
@ -80,7 +83,7 @@ export class ImgurBackend implements MediaBackend {
{
method: 'DELETE',
// 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);

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
*/
@ -48,7 +48,7 @@ describe('s3 backend', () => {
accessKeyId: mockedS3AccessKeyId,
secretAccessKey: mockedS3SecretAccessKey,
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
*/
@ -8,7 +8,10 @@ import { FileTypeResult } from 'file-type';
import { Client } from 'minio';
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 { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface';
@ -16,7 +19,7 @@ import { BackendType } from './backend-type.enum';
@Injectable()
export class S3Backend implements MediaBackend {
private config: MediaConfig['backend']['s3'];
private config: S3MediaConfig['s3'];
private client: Client;
private static determinePort(url: URL): number | undefined {
@ -34,7 +37,7 @@ export class S3Backend implements MediaBackend {
return;
}
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:';
this.client = new Client({
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
*/
@ -8,7 +8,10 @@ import { FileTypeResult } from 'file-type';
import fetch, { Response } from 'node-fetch';
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 { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface';
@ -16,7 +19,7 @@ import { BackendType } from './backend-type.enum';
@Injectable()
export class WebdavBackend implements MediaBackend {
private config: MediaConfig['backend']['webdav'];
private config: WebdavMediaConfig['webdav'];
private authHeader: string;
private readonly baseUrl: string;

View file

@ -10,12 +10,12 @@ We officially support and test these databases:
We don't necessarily support MySQL.
<!-- markdownlint-disable proper-names -->
| 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_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_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_PASS` | - | `password` | The password to log into the database. *Only if you're **not** using `sqlite`.* |
| 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_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_PORT` | - | `5432` | The port, where the database runs. *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_PASSWORD` | - | `password` | The password to log into the database. *Only if you're **not** using `sqlite`.* |
<!-- markdownlint-enable proper-names -->

View file

@ -1,11 +1,11 @@
# Notes
| 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_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_PERMISSION_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_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. |
| 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_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_PERMISSIONS_DEFAULT_LOGGED_IN` | `write` | `none`, `read`, `write` | The default permission for the "logged-in" group that is set on new notes. |
| `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_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. |