diff --git a/commons/src/parse-url/base-url-from-env-extractor.spec.ts b/commons/src/parse-url/base-url-from-env-extractor.spec.ts new file mode 100644 index 000000000..e9e67e724 --- /dev/null +++ b/commons/src/parse-url/base-url-from-env-extractor.spec.ts @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { BaseUrlFromEnvExtractor } from './base-url-from-env-extractor.js' +import { describe, it, expect } from '@jest/globals' +import { NoSubdirectoryAllowedError, NoValidUrlError } from './errors.js' +import { BaseUrls } from './base-urls.types.js' + +describe('BaseUrlFromEnvExtractor', () => { + it('should return the correctly parsed values if all are set', () => { + process.env.HD_BASE_URL = 'https://editor.example.org/' + process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/' + process.env.HD_SSR_API_URL = 'https://internal.example.org/' + const sut = new BaseUrlFromEnvExtractor() + const result = sut.extractBaseUrls() + + expect(result).toStrictEqual({ + editor: 'https://editor.example.org/', + renderer: 'https://renderer.example.org/', + ssrApi: 'https://internal.example.org/' + } as BaseUrls) + }) + + it('should throw an error if no base url is set', () => { + process.env.HD_BASE_URL = undefined + const sut = new BaseUrlFromEnvExtractor() + + expect(() => sut.extractBaseUrls()).toThrow(NoValidUrlError) + }) + + it("should throw an error if editor base url isn't an URL", () => { + process.env.HD_BASE_URL = 'bibedibabedibu' + process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/' + process.env.HD_SSR_API_URL = 'https://internal.example.org/' + const sut = new BaseUrlFromEnvExtractor() + + expect(() => sut.extractBaseUrls()).toThrow(NoValidUrlError) + }) + + it('should throw an error if renderer base url is set but no valid URL', () => { + process.env.HD_BASE_URL = 'https://editor.example.org/' + process.env.HD_RENDERER_BASE_URL = 'bibedibabedibu' + process.env.HD_SSR_API_URL = 'https://internal.example.org/' + const sut = new BaseUrlFromEnvExtractor() + + expect(() => sut.extractBaseUrls()).toThrow(NoValidUrlError) + }) + + it('should throw an error if ssr api base url is set but no valid URL', () => { + process.env.HD_BASE_URL = 'https://editor.example.org/' + process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/' + process.env.HD_SSR_API_URL = 'bibedibabedibu' + const sut = new BaseUrlFromEnvExtractor() + + expect(() => sut.extractBaseUrls()).toThrow(NoValidUrlError) + }) + + it('should throw an error if editor base url contains a path', () => { + process.env.HD_BASE_URL = 'https://editor.example.org/subpath/' + process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/' + const sut = new BaseUrlFromEnvExtractor() + + expect(() => sut.extractBaseUrls()).toThrow(NoSubdirectoryAllowedError) + }) + + it('should throw an error if renderer base url contains a path', () => { + process.env.HD_BASE_URL = 'https://editor.example.org/' + process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/subpath/' + const sut = new BaseUrlFromEnvExtractor() + + expect(() => sut.extractBaseUrls()).toThrow(NoSubdirectoryAllowedError) + }) + + it('should throw an error if ssr api url contains a path', () => { + process.env.HD_BASE_URL = 'https://editor.example.org/' + process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/' + process.env.HD_SSR_API_URL = 'https://internal.example.org/subpath/' + const sut = new BaseUrlFromEnvExtractor() + + expect(() => sut.extractBaseUrls()).toThrow(NoSubdirectoryAllowedError) + }) + + it('should copy editor base url to renderer base url if renderer base url is omitted', () => { + process.env.HD_BASE_URL = 'https://editor.example.org/' + delete process.env.HD_RENDERER_BASE_URL + process.env.HD_SSR_API_URL = 'https://internal.example.org/' + const sut = new BaseUrlFromEnvExtractor() + const result = sut.extractBaseUrls() + + expect(result).toStrictEqual({ + editor: 'https://editor.example.org/', + renderer: 'https://editor.example.org/', + ssrApi: 'https://internal.example.org/' + } as BaseUrls) + }) + + it('should copy editor base url to ssr api url if ssr api url is omitted', () => { + process.env.HD_BASE_URL = 'https://editor.example.org/' + process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/' + delete process.env.HD_SSR_API_URL + const sut = new BaseUrlFromEnvExtractor() + const result = sut.extractBaseUrls() + + expect(result).toStrictEqual({ + editor: 'https://editor.example.org/', + renderer: 'https://renderer.example.org/', + ssrApi: 'https://editor.example.org/' + } as BaseUrls) + }) +}) diff --git a/commons/src/parse-url/base-url-from-env-extractor.ts b/commons/src/parse-url/base-url-from-env-extractor.ts new file mode 100644 index 000000000..82139eba7 --- /dev/null +++ b/commons/src/parse-url/base-url-from-env-extractor.ts @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { isTestMode, isBuildTime, Logger } from '../utils/index.js' +import { parseUrl } from './parse-url.js' +import { NoValidUrlError } from './errors.js' +import { Optional } from '@mrdrogdrog/optional' +import { BaseUrls } from './base-urls.types.js' + +/** + * Extracts and caches the editor and renderer base urls from the environment variables. + */ +export class BaseUrlFromEnvExtractor { + private baseUrls: BaseUrls | undefined + private readonly logger: Logger | undefined + + constructor(withLogging: boolean = true) { + this.logger = withLogging ? new Logger('Base URL Configuration') : undefined + } + + private extractEditorBaseUrlFromEnv(): URL { + return parseUrl(process.env.HD_BASE_URL).orElseThrow( + () => new NoValidUrlError('HD_BASE_URL') + ) + } + + private extractExtraUrlFromEnv(envVarName: string, editorBaseUrl: URL): URL { + if (isTestMode) { + this.logger?.info('Test mode activated. Using editor base url.') + return editorBaseUrl + } + + const rendererBaseUrl = Optional.ofNullable(process.env[envVarName]) + .filter((value) => value !== '') + .map((value) => + parseUrl(value).orElseThrow(() => new NoValidUrlError(envVarName)) + ) + .orElse(undefined) + + if (rendererBaseUrl === undefined) { + this.logger?.info( + `${envVarName} is unset. Using editor base url for renderer.` + ) + return editorBaseUrl + } else { + return rendererBaseUrl + } + } + + private renewBaseUrls(): BaseUrls { + const editorBaseUrl = this.extractEditorBaseUrlFromEnv() + const rendererBaseUrl = this.extractExtraUrlFromEnv( + 'HD_RENDERER_BASE_URL', + editorBaseUrl + ) + const ssrApiUrl = this.extractExtraUrlFromEnv( + 'HD_SSR_API_URL', + editorBaseUrl + ) + + return { + editor: editorBaseUrl.toString(), + renderer: rendererBaseUrl.toString(), + ssrApi: ssrApiUrl.toString() + } + } + + /** + * Extracts the editor and renderer base urls from the environment variables. + * + * @return An {@link Optional} with the base urls. + */ + public extractBaseUrls(): BaseUrls { + if (isBuildTime) { + return { + editor: 'https://example.org/', + renderer: 'https://example.org/', + ssrApi: 'https://example.org/' + } + } + + if (this.baseUrls === undefined) { + this.baseUrls = this.renewBaseUrls() + this.logBaseUrls() + } + return this.baseUrls + } + + private logBaseUrls() { + if (this.baseUrls === undefined) { + return + } + this.logger?.info('Editor base URL', this.baseUrls.editor.toString()) + this.logger?.info('Renderer base URL', this.baseUrls.renderer.toString()) + } +} diff --git a/commons/src/parse-url/base-urls.types.ts b/commons/src/parse-url/base-urls.types.ts new file mode 100644 index 000000000..a8a0af7c5 --- /dev/null +++ b/commons/src/parse-url/base-urls.types.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * Defines the URLs that are returned by the {@link BaseUrlFromEnvExtractor}. + */ +export interface BaseUrls { + renderer: string + editor: string + ssrApi: string +} diff --git a/commons/src/parse-url/errors.ts b/commons/src/parse-url/errors.ts index 3a8079bde..f96fedb7b 100644 --- a/commons/src/parse-url/errors.ts +++ b/commons/src/parse-url/errors.ts @@ -4,14 +4,29 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +/** + * Thrown if an {@link URL} contains a subdirectory. + */ export class NoSubdirectoryAllowedError extends Error { constructor() { super('Subdirectories are not allowed') } } +/** + * Thrown if the protocol of an {@link URL} isn't https or http. + */ export class WrongProtocolError extends Error { constructor() { super('Protocol must be HTTP or HTTPS') } } + +/** + * Thrown if a value isn't a valid {@link URL}. + */ +export class NoValidUrlError extends Error { + constructor(varName: string) { + super(`${varName} is no valid URL`) + } +} diff --git a/commons/src/parse-url/index.ts b/commons/src/parse-url/index.ts index bf1668895..2c6a9683a 100644 --- a/commons/src/parse-url/index.ts +++ b/commons/src/parse-url/index.ts @@ -6,3 +6,5 @@ export { parseUrl } from './parse-url.js' export { NoSubdirectoryAllowedError, WrongProtocolError } from './errors.js' +export { BaseUrlFromEnvExtractor } from './base-url-from-env-extractor.js' +export type { BaseUrls } from './base-urls.types.js' diff --git a/commons/src/parse-url/parse-url.spec.ts b/commons/src/parse-url/parse-url.spec.ts index 28a69f209..ca602bf2d 100644 --- a/commons/src/parse-url/parse-url.spec.ts +++ b/commons/src/parse-url/parse-url.spec.ts @@ -33,7 +33,7 @@ describe('validate url', () => { }) }) - describe('trailing slash', () => { + describe('subpaths', () => { it('accepts urls with just domain with trailing slash', () => { expect(parseUrl('http://example.org/').get().toString()).toEqual( 'http://example.org/' diff --git a/frontend/build.sh b/frontend/build.sh index be318884f..55b45974b 100755 --- a/frontend/build.sh +++ b/frontend/build.sh @@ -45,4 +45,15 @@ rm -f dist/frontend/.env rm -rf dist/frontend/public/public rm -rf dist/frontend/src +echo "🦔 > Patching env var check into prod build" + +cat << EOF > dist/frontend/server.js.new +const { BaseUrlFromEnvExtractor } = require('@hedgedoc/commons') +new BaseUrlFromEnvExtractor(true).extractBaseUrls() + +EOF +cat dist/frontend/server.js >> dist/frontend/server.js.new +rm dist/frontend/server.js +mv dist/frontend/server.js.new dist/frontend/server.js + echo "🦔 > Done! You can run the build by going into the dist directory and executing \`node frontend/server.js\`" diff --git a/frontend/next.config.js b/frontend/next.config.js index d0ed388b8..17d254052 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -const { isMockMode, isTestMode, isProfilingMode, isBuildTime, Logger } = require('@hedgedoc/commons') +const { isMockMode, isTestMode, isProfilingMode, isBuildTime, Logger, BaseUrlFromEnvExtractor } = require('@hedgedoc/commons') const path = require('path') const CopyWebpackPlugin = require('copy-webpack-plugin') const withBundleAnalyzer = require('@next/bundle-analyzer')({ @@ -13,6 +13,7 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({ const logger = new Logger('Bootstrap') logger.info('Node environment is', process.env.NODE_ENV) +new BaseUrlFromEnvExtractor(false).extractBaseUrls() if (isTestMode) { logger.warn(`This build runs in test mode. This means: diff --git a/frontend/src/app/(editor)/[id]/page.tsx b/frontend/src/app/(editor)/[id]/page.tsx index 29422d35e..8e1f721ef 100644 --- a/frontend/src/app/(editor)/[id]/page.tsx +++ b/frontend/src/app/(editor)/[id]/page.tsx @@ -5,8 +5,8 @@ */ import { getNote } from '../../../api/notes' import { redirect } from 'next/navigation' -import { baseUrlFromEnvExtractor } from '../../../utils/base-url-from-env-extractor' import { notFound } from 'next/navigation' +import { baseUrlFromEnvExtractor } from '../../../utils/base-url-singelton' interface PageProps { params: { id: string | undefined } diff --git a/frontend/src/app/(editor)/layout.tsx b/frontend/src/app/(editor)/layout.tsx index 57c9fb440..4980a2b65 100644 --- a/frontend/src/app/(editor)/layout.tsx +++ b/frontend/src/app/(editor)/layout.tsx @@ -12,12 +12,12 @@ import { DarkMode } from '../../components/layout/dark-mode/dark-mode' import { ExpectedOriginBoundary } from '../../components/layout/expected-origin-boundary' import { UiNotificationBoundary } from '../../components/notifications/ui-notification-boundary' import { StoreProvider } from '../../redux/store-provider' -import { baseUrlFromEnvExtractor } from '../../utils/base-url-from-env-extractor' import { configureLuxon } from '../../utils/configure-luxon' import type { Metadata } from 'next' import type { PropsWithChildren } from 'react' import React from 'react' import { getConfig } from '../../api/config' +import { baseUrlFromEnvExtractor } from '../../utils/base-url-singelton' configureLuxon() diff --git a/frontend/src/app/(render)/layout.tsx b/frontend/src/app/(render)/layout.tsx index 8afb48915..02d41075d 100644 --- a/frontend/src/app/(render)/layout.tsx +++ b/frontend/src/app/(render)/layout.tsx @@ -9,9 +9,9 @@ import { BaseUrlContextProvider } from '../../components/common/base-url/base-ur import { FrontendConfigContextProvider } from '../../components/common/frontend-config-context/frontend-config-context-provider' import { ExpectedOriginBoundary } from '../../components/layout/expected-origin-boundary' import { StoreProvider } from '../../redux/store-provider' -import { baseUrlFromEnvExtractor } from '../../utils/base-url-from-env-extractor' import React from 'react' import { getConfig } from '../../api/config' +import { baseUrlFromEnvExtractor } from '../../utils/base-url-singelton' export default async function RootLayout({ children }: { children: React.ReactNode }) { const baseUrls = baseUrlFromEnvExtractor.extractBaseUrls() diff --git a/frontend/src/components/common/base-url/base-url-context-provider.tsx b/frontend/src/components/common/base-url/base-url-context-provider.tsx index aa4d2c926..d86f097a9 100644 --- a/frontend/src/components/common/base-url/base-url-context-provider.tsx +++ b/frontend/src/components/common/base-url/base-url-context-provider.tsx @@ -1,4 +1,5 @@ 'use client' +import type { BaseUrls } from '@hedgedoc/commons' /* * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * @@ -7,11 +8,6 @@ import type { PropsWithChildren } from 'react' import React, { createContext } from 'react' -export interface BaseUrls { - renderer: string - editor: string -} - interface BaseUrlContextProviderProps { baseUrls?: BaseUrls } diff --git a/frontend/src/utils/base-url-from-env-extractor.spec.ts b/frontend/src/utils/base-url-from-env-extractor.spec.ts deleted file mode 100644 index cbf66b3af..000000000 --- a/frontend/src/utils/base-url-from-env-extractor.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { BaseUrlFromEnvExtractor } from './base-url-from-env-extractor' - -describe('BaseUrlFromEnvExtractor', () => { - it('should return the base urls if both are valid urls', () => { - process.env.HD_BASE_URL = 'https://editor.example.org/' - process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/' - const sut = new BaseUrlFromEnvExtractor() - - expect(sut.extractBaseUrls()).toStrictEqual({ - renderer: 'https://renderer.example.org/', - editor: 'https://editor.example.org/' - }) - }) - - it('should return an empty optional if no var is set', () => { - process.env.HD_BASE_URL = undefined - process.env.HD_RENDERER_BASE_URL = undefined - const sut = new BaseUrlFromEnvExtractor() - - expect(() => sut.extractBaseUrls()).toThrow() - }) - - it("should return an empty optional if editor base url isn't an URL", () => { - process.env.HD_BASE_URL = 'bibedibabedibu' - process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/' - const sut = new BaseUrlFromEnvExtractor() - - expect(() => sut.extractBaseUrls()).toThrow() - }) - - it("should return an empty optional if renderer base url isn't an URL", () => { - process.env.HD_BASE_URL = 'https://editor.example.org/' - process.env.HD_RENDERER_BASE_URL = 'bibedibabedibu' - const sut = new BaseUrlFromEnvExtractor() - - expect(() => sut.extractBaseUrls()).toThrow() - }) - - it("should return an optional if editor base url isn't ending with a slash", () => { - process.env.HD_BASE_URL = 'https://editor.example.org' - process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org/' - const sut = new BaseUrlFromEnvExtractor() - - expect(sut.extractBaseUrls()).toStrictEqual({ - renderer: 'https://renderer.example.org/', - editor: 'https://editor.example.org/' - }) - }) - - it("should return an optional if renderer base url isn't ending with a slash", () => { - process.env.HD_BASE_URL = 'https://editor.example.org/' - process.env.HD_RENDERER_BASE_URL = 'https://renderer.example.org' - const sut = new BaseUrlFromEnvExtractor() - - expect(sut.extractBaseUrls()).toStrictEqual({ - renderer: 'https://renderer.example.org/', - editor: 'https://editor.example.org/' - }) - }) - - it('should copy editor base url to renderer base url if renderer base url is omitted', () => { - process.env.HD_BASE_URL = 'https://editor.example.org/' - delete process.env.HD_RENDERER_BASE_URL - const sut = new BaseUrlFromEnvExtractor() - - expect(sut.extractBaseUrls()).toStrictEqual({ - renderer: 'https://editor.example.org/', - editor: 'https://editor.example.org/' - }) - }) -}) diff --git a/frontend/src/utils/base-url-from-env-extractor.ts b/frontend/src/utils/base-url-from-env-extractor.ts deleted file mode 100644 index 858c5ed2e..000000000 --- a/frontend/src/utils/base-url-from-env-extractor.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { BaseUrls } from '../components/common/base-url/base-url-context-provider' -import { NoSubdirectoryAllowedError, parseUrl, Logger, isTestMode, isBuildTime } from '@hedgedoc/commons' -import { Optional } from '@mrdrogdrog/optional' - -/** - * Extracts and caches the editor and renderer base urls from the environment variables. - */ -export class BaseUrlFromEnvExtractor { - private baseUrls: BaseUrls | undefined - private readonly logger = new Logger('Base URL Configuration') - - private extractUrlFromEnvVar(envVarName: string, envVarValue: string | undefined): Optional { - try { - return parseUrl(envVarValue) - } catch (error) { - if (error instanceof NoSubdirectoryAllowedError) { - this.logger.error(error.message) - return Optional.empty() - } else { - throw error - } - } - } - - private extractEditorBaseUrlFromEnv(): Optional { - const envValue = this.extractUrlFromEnvVar('HD_BASE_URL', process.env.HD_BASE_URL) - if (envValue.isEmpty()) { - this.logger.error("HD_BASE_URL isn't a valid URL!") - } - return envValue - } - - private extractRendererBaseUrlFromEnv(editorBaseUrl: URL): Optional { - if (isTestMode) { - this.logger.info('Test mode activated. Using editor base url for renderer.') - return Optional.of(editorBaseUrl) - } - - if (!process.env.HD_RENDERER_BASE_URL) { - this.logger.info('HD_RENDERER_BASE_URL is unset. Using editor base url for renderer.') - return Optional.of(editorBaseUrl) - } - - return this.extractUrlFromEnvVar('HD_RENDERER_BASE_URL', process.env.HD_RENDERER_BASE_URL) - } - - private renewBaseUrls(): BaseUrls { - return this.extractEditorBaseUrlFromEnv() - .flatMap((editorBaseUrl) => - this.extractRendererBaseUrlFromEnv(editorBaseUrl).map((rendererBaseUrl) => { - return { - editor: editorBaseUrl.toString(), - renderer: rendererBaseUrl.toString() - } - }) - ) - .orElseThrow(() => new Error('couldnt parse env vars')) - } - - /** - * Extracts the editor and renderer base urls from the environment variables. - * - * @return An {@link Optional} with the base urls. - */ - public extractBaseUrls(): BaseUrls { - if (isBuildTime) { - return { - editor: 'https://example.org/', - renderer: 'https://example.org/' - } - } - - if (this.baseUrls === undefined) { - this.baseUrls = this.renewBaseUrls() - this.logBaseUrls() - } - return this.baseUrls - } - - private logBaseUrls() { - if (this.baseUrls === undefined) { - return - } - this.logger.info('Editor base URL', this.baseUrls.editor.toString()) - this.logger.info('Renderer base URL', this.baseUrls.renderer.toString()) - } -} - -export const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor() diff --git a/frontend/src/utils/base-url-singelton.ts b/frontend/src/utils/base-url-singelton.ts new file mode 100644 index 000000000..60127a7f6 --- /dev/null +++ b/frontend/src/utils/base-url-singelton.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { BaseUrlFromEnvExtractor } from '@hedgedoc/commons' + +export const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()