mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-09 13:51:57 -04:00
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:
parent
91ebd519a8
commit
926f7a5e49
37 changed files with 1299 additions and 994 deletions
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
68
backend/src/config/customization.config.spec.ts
Normal file
68
backend/src/config/customization.config.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
|
|
169
backend/src/config/database.config.spec.ts
Normal file
169
backend/src/config/database.config.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
|
|
64
backend/src/config/external-services.config.spec.ts
Normal file
64
backend/src/config/external-services.config.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
53
backend/src/config/zod-error-message.spec.ts
Normal file
53
backend/src/config/zod-error-message.spec.ts
Normal 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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
43
backend/src/config/zod-error-message.ts
Normal file
43
backend/src/config/zod-error-message.ts
Normal 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;
|
||||
}
|
|
@ -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 = {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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. |
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue