feat: fetch frontend config in server side rendering

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-04-04 17:27:20 +02:00
parent 312d1adf6f
commit 24f1b2a361
41 changed files with 270 additions and 220 deletions

View file

@ -1,10 +1,10 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import type { Config } from './types'
import type { FrontendConfig } from './types'
/**
* Fetches the frontend config from the backend.
@ -12,7 +12,7 @@ import type { Config } from './types'
* @return The frontend config.
* @throws {Error} when the api request wasn't successful.
*/
export const getConfig = async (): Promise<Config> => {
const response = await new GetApiRequestBuilder<Config>('config').sendRequest()
export const getConfig = async (baseUrl?: string): Promise<FrontendConfig> => {
const response = await new GetApiRequestBuilder<FrontendConfig>('config', baseUrl).sendRequest()
return response.asParsedJsonObject()
}

View file

@ -1,10 +1,10 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface Config {
export interface FrontendConfig {
allowAnonymous: boolean
allowRegister: boolean
authProviders: AuthProvider[]

View file

@ -1,18 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getConfig } from '../../../api/config'
import { setConfig } from '../../../redux/config/methods'
/**
* Get the {@link Config frontend config} and save it in the global application state.
*/
export const fetchFrontendConfig = async (): Promise<void> => {
const config = await getConfig()
if (!config) {
return Promise.reject(new Error('Config empty!'))
}
setConfig(config)
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -7,7 +7,6 @@ import { refreshHistoryState } from '../../../redux/history/methods'
import { Logger } from '../../../utils/logger'
import { isDevMode, isTestMode } from '../../../utils/test-modes'
import { fetchAndSetUser } from '../../login-page/auth/utils'
import { fetchFrontendConfig } from './fetch-frontend-config'
import { loadDarkMode } from './load-dark-mode'
import { setUpI18n } from './setupI18n'
@ -55,10 +54,6 @@ export const createSetUpTaskList = (): InitTask[] => {
name: 'Load Translations',
task: setUpI18n
},
{
name: 'Load config',
task: fetchFrontendConfig
},
{
name: 'Fetch user information',
task: fetchUserInformation

View file

@ -1,10 +1,10 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { createContext, useState } from 'react'
import type { PropsWithChildren } from 'react'
import React, { createContext, useState } from 'react'
export interface BaseUrls {
renderer: string
@ -28,9 +28,8 @@ export const BaseUrlContextProvider: React.FC<PropsWithChildren<BaseUrlContextPr
children
}) => {
const [baseUrlState] = useState<undefined | BaseUrls>(() => baseUrls)
return baseUrlState === undefined ? (
<div className={'text-white'}>HedgeDoc is not configured correctly! Please check the server log.</div>
<span className={'text-white bg-dark'}>HedgeDoc is not configured correctly! Please check the server log.</span>
) : (
<baseUrlContext.Provider value={baseUrlState}>{children}</baseUrlContext.Provider>
)

View file

@ -1,9 +1,9 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { useFrontendConfig } from '../frontend-config-context/use-frontend-config'
import { ShowIf } from '../show-if/show-if'
import styles from './branding.module.scss'
import React, { useMemo } from 'react'
@ -21,7 +21,7 @@ export interface BrandingProps {
* @param delimiter If the delimiter between the HedgeDoc logo and the branding should be shown.
*/
export const Branding: React.FC<BrandingProps> = ({ inline = false, delimiter = true }) => {
const branding = useApplicationState((state) => state.config.branding)
const branding = useFrontendConfig().branding
const showBranding = !!branding.name || !!branding.logo
const brandingDom = useMemo(() => {

View file

@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { FrontendConfig } from '../../../api/config/types'
import { createContext } from 'react'
export const frontendConfigContext = createContext<FrontendConfig | undefined>(undefined)

View file

@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getConfig } from '../../../api/config'
import type { FrontendConfig } from '../../../api/config/types'
import { useBaseUrl } from '../../../hooks/common/use-base-url'
import { Logger } from '../../../utils/logger'
import { frontendConfigContext } from './context'
import type { PropsWithChildren } from 'react'
import React, { useEffect, useState } from 'react'
const logger = new Logger('FrontendConfigContextProvider')
interface FrontendConfigContextProviderProps extends PropsWithChildren {
config?: FrontendConfig
}
/**
* Provides the given frontend configuration in a context or renders an error message otherwise.
*
* @param config the frontend config to provoide
* @param children the react elements to show if the config is valid
*/
export const FrontendConfigContextProvider: React.FC<FrontendConfigContextProviderProps> = ({ config, children }) => {
const [configState, setConfigState] = useState<undefined | FrontendConfig>(() => config)
const baseUrl = useBaseUrl()
useEffect(() => {
if (config === undefined && configState === undefined) {
logger.debug('Fetching Config client side')
getConfig(baseUrl)
.then((config) => setConfigState(config))
.catch((error) => logger.error(error))
}
}, [baseUrl, config, configState])
return configState === undefined ? (
<span className={'text-white bg-dark'}>No frontend config received! Please check the server log.</span>
) : (
<frontendConfigContext.Provider value={configState}>{children}</frontendConfigContext.Provider>
)
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { FrontendConfig } from '../../../api/config/types'
import { frontendConfigContext } from './context'
import { Optional } from '@mrdrogdrog/optional'
import { useContext } from 'react'
/**
* Retrieves the current frontend config from the next react context.
*/
export const useFrontendConfig = (): FrontendConfig => {
return Optional.ofNullable(useContext(frontendConfigContext)).orElseThrow(
() => new Error('No frontend config context found. Did you forget to use the provider component?')
)
}

View file

@ -22,7 +22,7 @@ jest.mock('../../../hooks/common/use-base-url')
describe('motd modal', () => {
beforeAll(async () => {
jest.spyOn(UseBaseUrlModule, 'useBaseUrl').mockImplementation(() => 'https://example.org')
jest.spyOn(UseBaseUrlModule, 'useBaseUrl').mockImplementation(() => new URL('https://example.org'))
await mockI18n()
})

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { useBaseUrl } from '../../../../hooks/common/use-base-url'
import { CopyableField } from '../../../common/copyable/copyable-field/copyable-field'
import React, { useMemo } from 'react'
export enum LinkType {
EDITOR = 'n',
SLIDESHOW = 'p',
DOCUMENT = 's'
}
export interface LinkFieldProps {
type: LinkType
}
/**
* Renders a specific URL to the current note.
* @param type defines the URL type. (editor, read only document, slideshow, etc.)
*/
export const NoteUrlField: React.FC<LinkFieldProps> = ({ type }) => {
const baseUrl = useBaseUrl()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
const url = useMemo(() => {
const url = new URL(baseUrl)
url.pathname += `${type}/${noteIdentifier}`
return url.toString()
}, [baseUrl, noteIdentifier, type])
return <CopyableField content={url} shareOriginUrl={url} />
}

View file

@ -4,11 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { useBaseUrl } from '../../../../hooks/common/use-base-url'
import { CopyableField } from '../../../common/copyable/copyable-field/copyable-field'
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
import { CommonModal } from '../../../common/modals/common-modal'
import { ShowIf } from '../../../common/show-if/show-if'
import { LinkType, NoteUrlField } from './note-url-field'
import { NoteType } from '@hedgedoc/commons'
import React from 'react'
import { Modal } from 'react-bootstrap'
@ -23,21 +22,19 @@ import { Trans, useTranslation } from 'react-i18next'
export const ShareModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation()
const noteFrontmatter = useApplicationState((state) => state.noteDetails.frontmatter)
const baseUrl = useBaseUrl()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
return (
<CommonModal show={show} onHide={onHide} showCloseButton={true} titleI18nKey={'editor.modal.shareLink.title'}>
<Modal.Body>
<Trans i18nKey={'editor.modal.shareLink.editorDescription'} />
<CopyableField content={`${baseUrl}n/${noteIdentifier}`} shareOriginUrl={`${baseUrl}n/${noteIdentifier}`} />
<NoteUrlField type={LinkType.EDITOR} />
<ShowIf condition={noteFrontmatter.type === NoteType.SLIDE}>
<Trans i18nKey={'editor.modal.shareLink.slidesDescription'} />
<CopyableField content={`${baseUrl}p/${noteIdentifier}`} shareOriginUrl={`${baseUrl}p/${noteIdentifier}`} />
<NoteUrlField type={LinkType.SLIDESHOW} />
</ShowIf>
<ShowIf condition={noteFrontmatter.type === NoteType.DOCUMENT}>
<Trans i18nKey={'editor.modal.shareLink.viewOnlyDescription'} />
<CopyableField content={`${baseUrl}s/${noteIdentifier}`} shareOriginUrl={`${baseUrl}s/${noteIdentifier}`} />
<NoteUrlField type={LinkType.DOCUMENT} />
</ShowIf>
</Modal.Body>
</CommonModal>

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -22,7 +22,7 @@ export const useWebsocketUrl = (): URL | undefined => {
return LOCAL_FALLBACK_URL
}
try {
const backendBaseUrlParsed = new URL(baseUrl, window.location.toString())
const backendBaseUrlParsed = new URL(baseUrl)
backendBaseUrlParsed.protocol = backendBaseUrlParsed.protocol === 'https:' ? 'wss:' : 'ws:'
backendBaseUrlParsed.pathname += 'realtime'
return backendBaseUrlParsed.toString()

View file

@ -3,8 +3,8 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { cypressId } from '../../../../utils/cypress-attribute'
import { useFrontendConfig } from '../../../common/frontend-config-context/use-frontend-config'
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
import { CommonModal } from '../../../common/modals/common-modal'
import React from 'react'
@ -19,7 +19,7 @@ import { Trans, useTranslation } from 'react-i18next'
*/
export const MaxLengthWarningModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation()
const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength)
const maxDocumentLength = useFrontendConfig().maxDocumentLength
return (
<CommonModal

View file

@ -1,11 +1,11 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { useBooleanState } from '../../../../hooks/common/use-boolean-state'
import { useNoteMarkdownContent } from '../../../../hooks/common/use-note-markdown-content'
import { useFrontendConfig } from '../../../common/frontend-config-context/use-frontend-config'
import { MaxLengthWarningModal } from './max-length-warning-modal'
import React, { useEffect, useRef } from 'react'
@ -15,7 +15,7 @@ import React, { useEffect, useRef } from 'react'
export const MaxLengthWarning: React.FC = () => {
const [modalVisibility, showModal, closeModal] = useBooleanState()
const maxLengthWarningAlreadyShown = useRef(false)
const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength)
const maxDocumentLength = useFrontendConfig().maxDocumentLength
const markdownContent = useNoteMarkdownContent()
useEffect(() => {

View file

@ -1,10 +1,11 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { cypressId } from '../../../../utils/cypress-attribute'
import { useFrontendConfig } from '../../../common/frontend-config-context/use-frontend-config'
import React, { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
@ -14,7 +15,7 @@ import { Trans, useTranslation } from 'react-i18next'
export const RemainingCharactersInfo: React.FC = () => {
const { t } = useTranslation()
const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength)
const maxDocumentLength = useFrontendConfig().maxDocumentLength
const contentLength = useApplicationState((state) => state.noteDetails.markdownContent.plain.length)
const remainingCharacters = useMemo(() => maxDocumentLength - contentLength, [contentLength, maxDocumentLength])

View file

@ -1,15 +1,15 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../hooks/common/use-application-state'
import links from '../../../links.json'
import { useFrontendConfig } from '../../common/frontend-config-context/use-frontend-config'
import { ExternalLink } from '../../common/links/external-link'
import { TranslatedExternalLink } from '../../common/links/translated-external-link'
import { TranslatedInternalLink } from '../../common/links/translated-internal-link'
import { VersionInfoLink } from './version-info/version-info-link'
import React, { Fragment } from 'react'
import React, { Fragment, useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
/**
@ -18,8 +18,11 @@ import { Trans, useTranslation } from 'react-i18next'
export const PoweredByLinks: React.FC = () => {
useTranslation()
const specialUrls: [string, string][] = useApplicationState((state) =>
Object.entries(state.config.specialUrls).map(([i18nkey, url]) => [i18nkey, String(url)])
const rawSpecialUrls = useFrontendConfig().specialUrls
const specialUrls = useMemo(
() => Object.entries(rawSpecialUrls).map(([i18nkey, url]) => [i18nkey, String(url)]),
[rawSpecialUrls]
)
return (

View file

@ -4,10 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { BackendVersion } from '../../../../api/config/types'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import links from '../../../../links.json'
import { cypressId } from '../../../../utils/cypress-attribute'
import { CopyableField } from '../../../common/copyable/copyable-field/copyable-field'
import { useFrontendConfig } from '../../../common/frontend-config-context/use-frontend-config'
import { TranslatedExternalLink } from '../../../common/links/translated-external-link'
import type { CommonModalProps } from '../../../common/modals/common-modal'
import { CommonModal } from '../../../common/modals/common-modal'
@ -22,7 +22,7 @@ import { Modal } from 'react-bootstrap'
* @param show If the modal should be shown.
*/
export const VersionInfoModal: React.FC<CommonModalProps> = ({ onHide, show }) => {
const serverVersion: BackendVersion = useApplicationState((state) => state.config.version)
const serverVersion: BackendVersion = useFrontendConfig().version
const backendVersion = useMemo(() => {
const version = `${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`

View file

@ -1,10 +1,10 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { cypressId } from '../../../utils/cypress-attribute'
import { useFrontendConfig } from '../../common/frontend-config-context/use-frontend-config'
import { ShowIf } from '../../common/show-if/show-if'
import { filterOneClickProviders } from '../../login-page/auth/utils'
import { getOneClickProviderMetadata } from '../../login-page/auth/utils/get-one-click-provider-metadata'
@ -25,7 +25,7 @@ export type SignInButtonProps = Omit<ButtonProps, 'href'>
*/
export const SignInButton: React.FC<SignInButtonProps> = ({ variant, ...props }) => {
const { t } = useTranslation()
const authProviders = useApplicationState((state) => state.config.authProviders)
const authProviders = useFrontendConfig().authProviders
const loginLink = useMemo(() => {
const oneClickProviders = authProviders.filter(filterOneClickProviders)

View file

@ -1,12 +1,12 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { doLocalLogin } from '../../../api/auth/local'
import { ErrorToI18nKeyMapper } from '../../../api/common/error-to-i18n-key-mapper'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
import { useFrontendConfig } from '../../common/frontend-config-context/use-frontend-config'
import { ShowIf } from '../../common/show-if/show-if'
import { PasswordField } from './fields/password-field'
import { UsernameField } from './fields/username-field'
@ -25,7 +25,7 @@ export const ViaLocal: React.FC = () => {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string>()
const allowRegister = useApplicationState((state) => state.config.allowRegister)
const allowRegister = useFrontendConfig().allowRegister
const onLoginSubmit = useCallback(
(event: FormEvent) => {

View file

@ -1,11 +1,11 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getProxiedUrl } from '../../../../api/media'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { Logger } from '../../../../utils/logger'
import { useFrontendConfig } from '../../../common/frontend-config-context/use-frontend-config'
import React, { useEffect, useState } from 'react'
const log = new Logger('ProxyImageFrame')
@ -20,7 +20,7 @@ const log = new Logger('ProxyImageFrame')
*/
export const ProxyImageFrame: React.FC<React.ImgHTMLAttributes<HTMLImageElement>> = ({ src, title, alt, ...props }) => {
const [imageUrl, setImageUrl] = useState('')
const imageProxyEnabled = useApplicationState((state) => state.config.useImageProxy)
const imageProxyEnabled = useFrontendConfig().useImageProxy
useEffect(() => {
if (!imageProxyEnabled || !src) {

View file

@ -3,18 +3,18 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MarkdownRendererExtensionOptions } from '../../../../extensions/base/app-extension'
import { AppExtension } from '../../../../extensions/base/app-extension'
import type { CheatsheetExtension } from '../../../editor-page/cheatsheet/cheatsheet-extension'
import { basicCompletion } from '../../../editor-page/editor-pane/autocompletions/basic-completion'
import type { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
import { TableOfContentsMarkdownExtension } from './table-of-contents-markdown-extension'
import type { CompletionSource } from '@codemirror/autocomplete'
import type EventEmitter2 from 'eventemitter2'
import { t } from 'i18next'
export class TableOfContentsAppExtension extends AppExtension {
buildMarkdownRendererExtensions(eventEmitter?: EventEmitter2): MarkdownRendererExtension[] {
return [new TableOfContentsMarkdownExtension(eventEmitter)]
buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): MarkdownRendererExtension[] {
return [new TableOfContentsMarkdownExtension(options.eventEmitter)]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { optionalAppExtensions } from '../../../extensions/extra-integrations/optional-app-extensions'
import { useFrontendConfig } from '../../common/frontend-config-context/use-frontend-config'
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
import { DebuggerMarkdownExtension } from '../extensions/debugger-markdown-extension'
import { ProxyImageMarkdownExtension } from '../extensions/image/proxy-image-markdown-extension'
@ -25,9 +26,15 @@ export const useMarkdownExtensions = (
additionalExtensions: MarkdownRendererExtension[]
): MarkdownRendererExtension[] => {
const extensionEventEmitter = useExtensionEventEmitter()
const frontendConfig = useFrontendConfig()
return useMemo(() => {
return [
...optionalAppExtensions.flatMap((extension) => extension.buildMarkdownRendererExtensions(extensionEventEmitter)),
...optionalAppExtensions.flatMap((extension) =>
extension.buildMarkdownRendererExtensions({
frontendConfig: frontendConfig,
eventEmitter: extensionEventEmitter
})
),
...additionalExtensions,
new UploadIndicatingImageFrameMarkdownExtension(),
new LinkAdjustmentMarkdownExtension(baseUrl),
@ -35,5 +42,5 @@ export const useMarkdownExtensions = (
new DebuggerMarkdownExtension(),
new ProxyImageMarkdownExtension()
]
}, [additionalExtensions, baseUrl, extensionEventEmitter])
}, [additionalExtensions, baseUrl, extensionEventEmitter, frontendConfig])
}

View file

@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../hooks/common/use-application-state'
import { useFrontendConfig } from '../common/frontend-config-context/use-frontend-config'
import { TranslatedExternalLink } from '../common/links/translated-external-link'
import { ShowIf } from '../common/show-if/show-if'
import React from 'react'
@ -14,7 +14,7 @@ import { Trans, useTranslation } from 'react-i18next'
*/
export const RegisterInfos: React.FC = () => {
useTranslation()
const specialUrls = useApplicationState((state) => state.config.specialUrls)
const specialUrls = useFrontendConfig().specialUrls
return (
<ShowIf condition={!!specialUrls.termsOfUse || !!specialUrls.privacy}>

View file

@ -1,8 +1,9 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { FrontendConfig } from '../../api/config/types'
import type { CheatsheetExtension } from '../../components/editor-page/cheatsheet/cheatsheet-extension'
import type { Linter } from '../../components/editor-page/editor-pane/linter/linter'
import type { MarkdownRendererExtension } from '../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
@ -11,9 +12,14 @@ import type { EventEmitter2 } from 'eventemitter2'
import type React from 'react'
import { Fragment } from 'react'
export interface MarkdownRendererExtensionOptions {
frontendConfig: FrontendConfig
eventEmitter?: EventEmitter2
}
export abstract class AppExtension {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public buildMarkdownRendererExtensions(eventEmitter?: EventEmitter2): MarkdownRendererExtension[] {
public buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): MarkdownRendererExtension[] {
return []
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -9,6 +9,7 @@ import {
codeFenceRegex
} from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
import type { MarkdownRendererExtensionOptions } from '../../base/app-extension'
import { AppExtension } from '../../base/app-extension'
import { PlantumlMarkdownExtension } from './plantuml-markdown-extension'
import type { CompletionSource } from '@codemirror/autocomplete'
@ -19,8 +20,8 @@ import type { CompletionSource } from '@codemirror/autocomplete'
* @see https://plantuml.com
*/
export class PlantumlAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new PlantumlMarkdownExtension()]
buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): MarkdownRendererExtension[] {
return [new PlantumlMarkdownExtension(options.frontendConfig.plantumlServer)]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {

View file

@ -5,30 +5,17 @@
*/
import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n'
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
import * as reduxModule from '../../../redux'
import type { ApplicationState } from '../../../redux/application-state'
import { PlantumlMarkdownExtension } from './plantuml-markdown-extension'
import { render } from '@testing-library/react'
import React from 'react'
import { Mock } from 'ts-mockery'
jest.mock('../../../redux')
describe('PlantUML markdown extensions', () => {
beforeAll(() => mockI18n())
it('renders a plantuml codeblock', () => {
jest.spyOn(reduxModule, 'getGlobalState').mockReturnValue(
Mock.of<ApplicationState>({
config: {
plantumlServer: 'https://example.org'
}
})
)
const view = render(
<TestMarkdownRenderer
extensions={[new PlantumlMarkdownExtension()]}
extensions={[new PlantumlMarkdownExtension('https://example.org')]}
content={'```plantuml\nclass Example\n```'}
/>
)
@ -36,17 +23,9 @@ describe('PlantUML markdown extensions', () => {
})
it('renders an error if no server is defined', () => {
jest.spyOn(reduxModule, 'getGlobalState').mockReturnValue(
Mock.of<ApplicationState>({
config: {
plantumlServer: undefined
}
})
)
const view = render(
<TestMarkdownRenderer
extensions={[new PlantumlMarkdownExtension()]}
extensions={[new PlantumlMarkdownExtension(undefined)]}
content={'```plantuml\nclass Example\n```'}
/>
)

View file

@ -1,11 +1,10 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
import { getGlobalState } from '../../../redux'
import { PlantumlNotConfiguredComponentReplacer } from './plantuml-not-configured-component-replacer'
import { Optional } from '@mrdrogdrog/optional'
import type MarkdownIt from 'markdown-it'
@ -20,6 +19,10 @@ import type Token from 'markdown-it/lib/token'
* @see https://plantuml.com
*/
export class PlantumlMarkdownExtension extends MarkdownRendererExtension {
constructor(private plantumlServerUrl: string | undefined) {
super()
}
private plantumlError(markdownIt: MarkdownIt): void {
const defaultRenderer: Renderer.RenderRule = markdownIt.renderer.rules.fence || (() => '')
markdownIt.renderer.rules.fence = (tokens: Token[], idx: number, options: Options, env, slf: Renderer) => {
@ -30,7 +33,7 @@ export class PlantumlMarkdownExtension extends MarkdownRendererExtension {
}
public configureMarkdownIt(markdownIt: MarkdownIt): void {
Optional.ofNullable(getGlobalState().config.plantumlServer)
Optional.ofNullable(this.plantumlServerUrl)
.map((plantumlServer) =>
plantuml(markdownIt, {
openMarker: '```plantuml',

View file

@ -1,14 +1,14 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import type { MarkdownRendererExtensionOptions } from '../../base/app-extension'
import { AppExtension } from '../../base/app-extension'
import { SetCheckboxInCheatsheet } from './set-checkbox-in-cheatsheet'
import { SetCheckboxInEditor } from './set-checkbox-in-editor'
import { TaskListMarkdownExtension } from './task-list-markdown-extension'
import type { EventEmitter2 } from 'eventemitter2'
import type React from 'react'
/**
@ -17,8 +17,8 @@ import type React from 'react'
export class TaskListCheckboxAppExtension extends AppExtension {
public static readonly EVENT_NAME = 'TaskListCheckbox'
buildMarkdownRendererExtensions(eventEmitter: EventEmitter2): TaskListMarkdownExtension[] {
return [new TaskListMarkdownExtension(eventEmitter)]
buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): TaskListMarkdownExtension[] {
return [new TaskListMarkdownExtension(options.eventEmitter)]
}
buildEditorExtensionComponent(): React.FC {

View file

@ -1,9 +1,9 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from './use-application-state'
import { useFrontendConfig } from '../../components/common/frontend-config-context/use-frontend-config'
import { useMemo } from 'react'
/**
@ -12,7 +12,7 @@ import { useMemo } from 'react'
* @return The app title with branding.
*/
export const useAppTitle = (): string => {
const brandingName = useApplicationState((state) => state.config.branding.name)
const brandingName = useFrontendConfig().branding.name
return useMemo(() => {
return 'HedgeDoc' + (brandingName ? ` @ ${brandingName}` : '')

View file

@ -1,8 +1,9 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useFrontendConfig } from '../../components/common/frontend-config-context/use-frontend-config'
import { useApplicationState } from './use-application-state'
import { useMemo } from 'react'
@ -12,7 +13,7 @@ import { useMemo } from 'react'
* @return The array of markdown content lines
*/
export const useTrimmedNoteMarkdownContentWithoutFrontmatter = (): string[] => {
const maxLength = useApplicationState((state) => state.config.maxDocumentLength)
const maxLength = useFrontendConfig().maxDocumentLength
const markdownContent = useApplicationState((state) => ({
lines: state.noteDetails.markdownContent.lines,
content: state.noteDetails.markdownContent.plain

View file

@ -5,9 +5,11 @@
*/
import '../../global-styles/dark.scss'
import '../../global-styles/index.scss'
import type { FrontendConfig } from '../api/config/types'
import { ApplicationLoader } from '../components/application-loader/application-loader'
import { BaseUrlContextProvider } from '../components/common/base-url/base-url-context-provider'
import type { BaseUrls } from '../components/common/base-url/base-url-context-provider'
import { BaseUrlContextProvider } from '../components/common/base-url/base-url-context-provider'
import { FrontendConfigContextProvider } from '../components/common/frontend-config-context/frontend-config-context-provider'
import { ErrorBoundary } from '../components/error-boundary/error-boundary'
import { BaseHead } from '../components/layout/base-head'
import { UiNotificationBoundary } from '../components/notifications/ui-notification-boundary'
@ -16,6 +18,8 @@ import { BaseUrlFromEnvExtractor } from '../utils/base-url-from-env-extractor'
import { configureLuxon } from '../utils/configure-luxon'
import { determineCurrentOrigin } from '../utils/determine-current-origin'
import { ExpectedOriginBoundary } from '../utils/expected-origin-boundary'
import { FrontendConfigFetcher } from '../utils/frontend-config-fetcher'
import { isTestMode } from '../utils/test-modes'
import type { AppContext, AppInitialProps, AppProps } from 'next/app'
import React from 'react'
@ -23,6 +27,7 @@ configureLuxon()
interface AppPageProps {
baseUrls: BaseUrls | undefined
frontendConfig: FrontendConfig | undefined
currentOrigin: string | undefined
}
@ -33,31 +38,36 @@ interface AppPageProps {
function HedgeDocApp({ Component, pageProps }: AppProps<AppPageProps>) {
return (
<BaseUrlContextProvider baseUrls={pageProps.baseUrls}>
<ExpectedOriginBoundary currentOrigin={pageProps.currentOrigin}>
<StoreProvider>
<BaseHead />
<ApplicationLoader>
<ErrorBoundary>
<UiNotificationBoundary>
<Component {...pageProps} />
</UiNotificationBoundary>
</ErrorBoundary>
</ApplicationLoader>
</StoreProvider>
</ExpectedOriginBoundary>
<FrontendConfigContextProvider config={pageProps.frontendConfig}>
<ExpectedOriginBoundary currentOrigin={pageProps.currentOrigin}>
<StoreProvider>
<BaseHead />
<ApplicationLoader>
<ErrorBoundary>
<UiNotificationBoundary>
<Component {...pageProps} />
</UiNotificationBoundary>
</ErrorBoundary>
</ApplicationLoader>
</StoreProvider>
</ExpectedOriginBoundary>
</FrontendConfigContextProvider>
</BaseUrlContextProvider>
)
}
const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
const frontendConfigFetcher = new FrontendConfigFetcher()
HedgeDocApp.getInitialProps = ({ ctx }: AppContext): AppInitialProps<AppPageProps> => {
HedgeDocApp.getInitialProps = async ({ ctx }: AppContext): Promise<AppInitialProps<AppPageProps>> => {
const baseUrls = baseUrlFromEnvExtractor.extractBaseUrls().orElse(undefined)
const frontendConfig = isTestMode ? undefined : await frontendConfigFetcher.fetch(baseUrls) //some tests mock the frontend config. Therefore it needs to be fetched in the browser.
const currentOrigin = determineCurrentOrigin(ctx)
return {
pageProps: {
baseUrls,
frontendConfig,
currentOrigin
}
}

View file

@ -1,15 +1,15 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '../../../api/config/types'
import type { FrontendConfig } from '../../../api/config/types'
import { AuthProviderType } from '../../../api/config/types'
import { HttpMethod, respondToMatchingRequest } from '../../../handler-utils/respond-to-matching-request'
import type { NextApiRequest, NextApiResponse } from 'next'
const handler = (req: NextApiRequest, res: NextApiResponse) => {
respondToMatchingRequest<Config>(HttpMethod.GET, req, res, {
respondToMatchingRequest<FrontendConfig>(HttpMethod.GET, req, res, {
allowAnonymous: true,
allowRegister: true,
authProviders: [

View file

@ -1,10 +1,11 @@
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { AuthProviderWithCustomName } from '../api/config/types'
import { AuthProviderType } from '../api/config/types'
import { useFrontendConfig } from '../components/common/frontend-config-context/use-frontend-config'
import { RedirectBack } from '../components/common/redirect-back'
import { ShowIf } from '../components/common/show-if/show-if'
import { LandingLayout } from '../components/landing-layout/landing-layout'
@ -23,7 +24,7 @@ import { Trans, useTranslation } from 'react-i18next'
*/
export const LoginPage: React.FC = () => {
useTranslation()
const authProviders = useApplicationState((state) => state.config.authProviders)
const authProviders = useFrontendConfig().authProviders
const userLoggedIn = useApplicationState((state) => !!state.user)
const ldapProviders = useMemo(() => {

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -9,6 +9,7 @@ import { DisplayNameField } from '../components/common/fields/display-name-field
import { NewPasswordField } from '../components/common/fields/new-password-field'
import { PasswordAgainField } from '../components/common/fields/password-again-field'
import { UsernameField } from '../components/common/fields/username-field'
import { useFrontendConfig } from '../components/common/frontend-config-context/use-frontend-config'
import { Redirect } from '../components/common/redirect'
import { LandingLayout } from '../components/landing-layout/landing-layout'
import { fetchAndSetUser } from '../components/login-page/auth/utils'
@ -30,7 +31,7 @@ import { Trans, useTranslation } from 'react-i18next'
export const RegisterPage: NextPage = () => {
useTranslation()
const router = useRouter()
const allowRegister = useApplicationState((state) => state.config.allowRegister)
const allowRegister = useFrontendConfig().allowRegister
const userExists = useApplicationState((state) => !!state.user)
const [username, setUsername] = useState('')

View file

@ -3,7 +3,6 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '../api/config/types'
import type { HistoryEntryWithOrigin } from '../api/history/types'
import type { DarkModeConfig } from './dark-mode/types'
import type { EditorConfig } from './editor/types'
@ -14,7 +13,6 @@ import type { OptionalUserState } from './user/types'
export interface ApplicationState {
user: OptionalUserState
config: Config
history: HistoryEntryWithOrigin[]
editorConfig: EditorConfig
darkMode: DarkModeConfig

View file

@ -1,16 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { store } from '..'
import type { Config } from '../../api/config/types'
import type { SetConfigAction } from './types'
import { ConfigActionType } from './types'
export const setConfig = (state: Config): void => {
store.dispatch({
type: ConfigActionType.SET_CONFIG,
state: state
} as SetConfigAction)
}

View file

@ -1,41 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '../../api/config/types'
import type { ConfigActions } from './types'
import { ConfigActionType } from './types'
import type { Reducer } from 'redux'
export const initialState: Config = {
allowAnonymous: true,
allowRegister: true,
authProviders: [],
branding: {
name: '',
logo: ''
},
useImageProxy: false,
specialUrls: {
privacy: undefined,
termsOfUse: undefined,
imprint: undefined
},
version: {
major: 0,
minor: 0,
patch: 0
},
plantumlServer: undefined,
maxDocumentLength: 0
}
export const ConfigReducer: Reducer<Config, ConfigActions> = (state: Config = initialState, action: ConfigActions) => {
switch (action.type) {
case ConfigActionType.SET_CONFIG:
return action.state
default:
return state
}
}

View file

@ -1,18 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '../../api/config/types'
import type { Action } from 'redux'
export enum ConfigActionType {
SET_CONFIG = 'config/set'
}
export type ConfigActions = SetConfigAction
export interface SetConfigAction extends Action<ConfigActionType> {
type: ConfigActionType.SET_CONFIG
state: Config
}

View file

@ -1,10 +1,9 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ApplicationState } from './application-state'
import { ConfigReducer } from './config/reducers'
import { DarkModeConfigReducer } from './dark-mode/reducers'
import { EditorConfigReducer } from './editor/reducers'
import { HistoryReducer } from './history/reducers'
@ -17,7 +16,6 @@ import { combineReducers } from 'redux'
export const allReducers: Reducer<ApplicationState> = combineReducers<ApplicationState>({
user: UserReducer,
config: ConfigReducer,
history: HistoryReducer,
editorConfig: EditorConfigReducer,
darkMode: DarkModeConfigReducer,

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getConfig } from '../api/config'
import type { FrontendConfig } from '../api/config/types'
import type { BaseUrls } from '../components/common/base-url/base-url-context-provider'
import { Logger } from './logger'
/**
* Fetches and caches the {@link FrontendConfig frontend config} from the backend.
*/
export class FrontendConfigFetcher {
private readonly logger = new Logger('Frontend config fetcher')
private frontendConfig: FrontendConfig | undefined = undefined
public async fetch(baseUrls: BaseUrls | undefined): Promise<FrontendConfig | undefined> {
if (!this.frontendConfig) {
if (baseUrls === undefined) {
return undefined
}
const baseUrl = baseUrls.editor.toString()
try {
this.frontendConfig = await getConfig(baseUrl)
} catch (error) {
this.logger.error(`Couldn't fetch frontend configuration from ${baseUrl}`, error)
return undefined
}
this.logger.info(`Fetched frontend config from ${baseUrl}`)
}
return this.frontendConfig
}
}