diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 96e241fc2..1aa3f645b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/auth/ldap/ldap.service.ts b/backend/src/auth/ldap/ldap.service.ts index 23f48a3f3..e05cadb95 100644 --- a/backend/src/auth/ldap/ldap.service.ts +++ b/backend/src/auth/ldap/ldap.service.ts @@ -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 { @@ -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) { diff --git a/backend/src/auth/oidc/oidc.service.ts b/backend/src/auth/oidc/oidc.service.ts index 49e1d19b0..5d3eb4903 100644 --- a/backend/src/auth/oidc/oidc.service.ts +++ b/backend/src/auth/oidc/oidc.service.ts @@ -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; const displayName = OidcService.getResponseFieldValue( diff --git a/backend/src/config/app.config.spec.ts b/backend/src/config/app.config.spec.ts index e921cd8a9..6a4170264 100644 --- a/backend/src/config/app.config.spec.ts +++ b/backend/src/config/app.config.spec.ts @@ -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(); }); diff --git a/backend/src/config/app.config.ts b/backend/src/config/app.config.ts index 4c7722bdd..8dddb2fdf 100644 --- a/backend/src/config/app.config.ts +++ b/backend/src/config/app.config.ts @@ -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; 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; }); diff --git a/backend/src/config/auth.config.spec.ts b/backend/src/config/auth.config.spec.ts index 06b495109..22f9951b6 100644 --- a/backend/src/config/auth.config.spec.ts +++ b/backend/src/config/auth.config.spec.ts @@ -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(); }); }); diff --git a/backend/src/config/auth.config.ts b/backend/src/config/auth.config.ts index 2582d47e6..c4feee70d 100644 --- a/backend/src/config/auth.config.ts +++ b/backend/src/config/auth.config.ts @@ -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; +export type LdapConfig = z.infer; +export type OidcConfig = z.infer; + 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[] = 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[] = 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; }); diff --git a/backend/src/config/csp.config.ts b/backend/src/config/csp.config.ts index 054058832..b6f67d685 100644 --- a/backend/src/config/csp.config.ts +++ b/backend/src/config/csp.config.ts @@ -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; + 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; }); diff --git a/backend/src/config/customization.config.spec.ts b/backend/src/config/customization.config.spec.ts new file mode 100644 index 000000000..cae9b4f38 --- /dev/null +++ b/backend/src/config/customization.config.spec.ts @@ -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(); + }); +}); diff --git a/backend/src/config/customization.config.ts b/backend/src/config/customization.config.ts index 74805dc12..abe03f3dc 100644 --- a/backend/src/config/customization.config.ts +++ b/backend/src/config/customization.config.ts @@ -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; + 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; }); diff --git a/backend/src/config/database.config.spec.ts b/backend/src/config/database.config.spec.ts new file mode 100644 index 000000000..fffd53f3c --- /dev/null +++ b/backend/src/config/database.config.spec.ts @@ -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(); + }); +}); diff --git a/backend/src/config/database.config.ts b/backend/src/config/database.config.ts index 6286c686c..2deccc1b3 100644 --- a/backend/src/config/database.config.ts +++ b/backend/src/config/database.config.ts @@ -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; +export type PostgresDatabaseConfig = z.infer; +export type MariadbDatabaseConfig = z.infer; +export type MySQLDatabaseConfig = z.infer; +export type DatabaseConfig = z.infer; + 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; }); diff --git a/backend/src/config/external-services.config.spec.ts b/backend/src/config/external-services.config.spec.ts new file mode 100644 index 000000000..59e3f6362 --- /dev/null +++ b/backend/src/config/external-services.config.spec.ts @@ -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(); + }); +}); diff --git a/backend/src/config/external-services.config.ts b/backend/src/config/external-services.config.ts index 532e2f7e2..77a8cf549 100644 --- a/backend/src/config/external-services.config.ts +++ b/backend/src/config/external-services.config.ts @@ -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; + 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; }); diff --git a/backend/src/config/media.config.spec.ts b/backend/src/config/media.config.spec.ts index 950c014d7..84740b502 100644 --- a/backend/src/config/media.config.spec.ts +++ b/backend/src/config/media.config.spec.ts @@ -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(); }); diff --git a/backend/src/config/media.config.ts b/backend/src/config/media.config.ts index a47ddbb41..16c862345 100644 --- a/backend/src/config/media.config.ts +++ b/backend/src/config/media.config.ts @@ -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; +export type AzureMediaConfig = z.infer; +export type FilesystemMediaConfig = z.infer; +export type ImgurMediaConfig = z.infer; +export type S3MediaConfig = z.infer; +export type WebdavMediaConfig = z.infer; + 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; }); diff --git a/backend/src/config/mock/app.config.mock.ts b/backend/src/config/mock/app.config.mock.ts index 6af4c3803..0ac693a4c 100644 --- a/backend/src/config/mock/app.config.mock.ts +++ b/backend/src/config/mock/app.config.mock.ts @@ -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, diff --git a/backend/src/config/mock/database.config.mock.ts b/backend/src/config/mock/database.config.mock.ts index 6516d00fb..211688d65 100644 --- a/backend/src/config/mock/database.config.mock.ts +++ b/backend/src/config/mock/database.config.mock.ts @@ -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, diff --git a/backend/src/config/mock/external-services.config.mock.ts b/backend/src/config/mock/external-services.config.mock.ts index b09d7e5a8..9415913f4 100644 --- a/backend/src/config/mock/external-services.config.mock.ts +++ b/backend/src/config/mock/external-services.config.mock.ts @@ -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', }; } diff --git a/backend/src/config/mock/media.config.mock.ts b/backend/src/config/mock/media.config.mock.ts index 96ee1c824..3a36bf800 100644 --- a/backend/src/config/mock/media.config.mock.ts +++ b/backend/src/config/mock/media.config.mock.ts @@ -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: '', - }, }, }; } diff --git a/backend/src/config/note.config.spec.ts b/backend/src/config/note.config.spec.ts index 38f2d0572..58b75d712 100644 --- a/backend/src/config/note.config.spec.ts +++ b/backend/src/config/note.config.spec.ts @@ -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(); }); diff --git a/backend/src/config/note.config.ts b/backend/src/config/note.config.ts index 58126835a..9be7eb3a4 100644 --- a/backend/src/config/note.config.ts +++ b/backend/src/config/note.config.ts @@ -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({ - 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; + 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; diff --git a/backend/src/config/utils.spec.ts b/backend/src/config/utils.spec.ts index c8d20fba3..526086c67 100644 --- a/backend/src/config/utils.spec.ts +++ b/backend/src/config/utils.spec.ts @@ -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; diff --git a/backend/src/config/utils.ts b/backend/src/config/utils.ts index 2241aa4c1..f0c44d72d 100644 --- a/backend/src/config/utils.ts +++ b/backend/src/config/utils.ts @@ -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, diff --git a/backend/src/config/zod-error-message.spec.ts b/backend/src/config/zod-error-message.spec.ts new file mode 100644 index 000000000..c9db9c090 --- /dev/null +++ b/backend/src/config/zod-error-message.spec.ts @@ -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`, + ); + }); + }); +}); diff --git a/backend/src/config/zod-error-message.ts b/backend/src/config/zod-error-message.ts new file mode 100644 index 000000000..78c69e0fa --- /dev/null +++ b/backend/src/config/zod-error-message.ts @@ -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 { + 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; +} diff --git a/backend/src/frontend-config/frontend-config.service.spec.ts b/backend/src/frontend-config/frontend-config.service.spec.ts index 01bb5a34c..249e8368f 100644 --- a/backend/src/frontend-config/frontend-config.service.spec.ts +++ b/backend/src/frontend-config/frontend-config.service.spec.ts @@ -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 = { diff --git a/backend/src/frontend-config/frontend-config.service.ts b/backend/src/frontend-config/frontend-config.service.ts index 033853cbb..aba04ce06 100644 --- a/backend/src/frontend-config/frontend-config.service.ts +++ b/backend/src/frontend-config/frontend-config.service.ts @@ -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, diff --git a/backend/src/main.ts b/backend/src/main.ts index b7c85ba85..9d7cb9ab3 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -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 { 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(); diff --git a/backend/src/media/backends/azure-backend.ts b/backend/src/media/backends/azure-backend.ts index eca45765d..b007f1a47 100644 --- a/backend/src/media/backends/azure-backend.ts +++ b/backend/src/media/backends/azure-backend.ts @@ -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( diff --git a/backend/src/media/backends/filesystem-backend.ts b/backend/src/media/backends/filesystem-backend.ts index 1eb343649..4434c8419 100644 --- a/backend/src/media/backends/filesystem-backend.ts +++ b/backend/src/media/backends/filesystem-backend.ts @@ -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( diff --git a/backend/src/media/backends/imgur-backend.ts b/backend/src/media/backends/imgur-backend.ts index 4cfba07dc..825cbe937 100644 --- a/backend/src/media/backends/imgur-backend.ts +++ b/backend/src/media/backends/imgur-backend.ts @@ -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 { @@ -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); diff --git a/backend/src/media/backends/s3-backend.spec.ts b/backend/src/media/backends/s3-backend.spec.ts index fd95df57e..948cb0db5 100644 --- a/backend/src/media/backends/s3-backend.spec.ts +++ b/backend/src/media/backends/s3-backend.spec.ts @@ -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, }, }, }); diff --git a/backend/src/media/backends/s3-backend.ts b/backend/src/media/backends/s3-backend.ts index 03a4d1092..7d28b1e26 100644 --- a/backend/src/media/backends/s3-backend.ts +++ b/backend/src/media/backends/s3-backend.ts @@ -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, diff --git a/backend/src/media/backends/webdav-backend.ts b/backend/src/media/backends/webdav-backend.ts index cc5c6d552..a98d5261f 100644 --- a/backend/src/media/backends/webdav-backend.ts +++ b/backend/src/media/backends/webdav-backend.ts @@ -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; diff --git a/docs/content/references/config/database.md b/docs/content/references/config/database.md index b6f3b8344..99d688a48 100644 --- a/docs/content/references/config/database.md +++ b/docs/content/references/config/database.md @@ -10,12 +10,12 @@ We officially support and test these databases: We don't necessarily support MySQL. -| 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`.* | diff --git a/docs/content/references/config/notes.md b/docs/content/references/config/notes.md index e3f4b5579..4487d6b7f 100644 --- a/docs/content/references/config/notes.md +++ b/docs/content/references/config/notes.md @@ -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. |