feat: migrate frontend app to nextjs app router

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-05-29 17:32:44 +02:00
parent 5b5dabc84e
commit 8602645bea
108 changed files with 893 additions and 1188 deletions

View file

@ -19,7 +19,7 @@ else
fi
echo "🦔 > Building"
next build
BUILD_TIME=true next build
echo "🦔 > Bundling"
mv .next/standalone dist

View file

@ -1,47 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('Opengraph metadata', () => {
beforeEach(() => {
cy.visitTestNote()
})
it('includes the note title if not overridden', () => {
cy.setCodemirrorContent('---\ntitle: Test title\n---')
cy.get('meta[property="og:title"]').should('have.attr', 'content', 'Test title')
})
it('includes the note title if overridden', () => {
cy.setCodemirrorContent('---\ntitle: Test title\nopengraph:\n title: Overridden title\n---')
cy.get('meta[property="og:title"]').should('have.attr', 'content', 'Overridden title')
})
it('includes custom opengraph tags', () => {
cy.setCodemirrorContent('---\nopengraph:\n image: https://dummyimage.com/48\n---')
cy.get('meta[property="og:image"]').should('have.attr', 'content', 'https://dummyimage.com/48')
})
})
describe('License frontmatter', () => {
beforeEach(() => {
cy.visitTestNote()
})
it('sets the link tag if defined and not blank', () => {
cy.setCodemirrorContent('---\nlicense: https://example.com\n---')
cy.get('link[rel="license"]').should('have.attr', 'href', 'https://example.com')
})
it('does not set the link tag if not defined', () => {
cy.setCodemirrorContent('---\ntitle: No license for this note\n---')
cy.get('link[rel="license"]').should('not.exist')
})
it('does not set the link tag if defined but blank', () => {
cy.setCodemirrorContent('---\nlicense: \n---')
cy.get('link[rel="license"]').should('not.exist')
})
})

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { AuthProviderType } from '../../src/api/config/types'
import { HttpMethod } from '../../src/handler-utils/respond-to-matching-request'
declare namespace Cypress {
interface Chainable {
@ -80,13 +81,7 @@ export const config = {
}
Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) => {
return cy.intercept('/api/private/config', {
statusCode: 200,
body: {
...config,
...additionalConfig
}
})
return cy.request(HttpMethod.POST, '/api/private/config', { ...config, ...additionalConfig })
})
beforeEach(() => {

View file

@ -1,7 +1,6 @@
{
"app": {
"slogan": "Ideas grow better together",
"title": "Collaborative markdown notes",
"icon": "HedgeDoc logo with text"
},
"notificationTest": {

View file

@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
const { isMockMode, isTestMode, isProfilingMode } = require('./src/utils/test-modes')
const { isMockMode, isTestMode, isProfilingMode, isBuildTime } = require('./src/utils/test-modes')
const path = require('path')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const withBundleAnalyzer = require('@next/bundle-analyzer')({
@ -12,15 +12,13 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
console.log('Node environment is', process.env.NODE_ENV)
if (isMockMode) {
console.log('Use mock API')
}
if (isTestMode) {
console.warn(`This build runs in test mode. This means:
- no sandboxed iframe
- No sandboxed iframe
- Additional data-attributes for e2e tests added to DOM
- Editor and renderer are running on the same origin`)
- Editor and renderer are running on the same origin
- No frontend config caching
`)
}
if (isMockMode) {
@ -28,7 +26,14 @@ if (isMockMode) {
- No real data. All API responses are mocked
- No persistent data
- No realtime editing
`)
`)
}
if (isBuildTime) {
console.warn(`This process runs in build mode. During build time this means:
- Editor and Renderer base urls are https://example.org
- No frontend config will be fetched
`)
}
if (isProfilingMode) {
@ -54,7 +59,6 @@ const svgrConfig = {
/** @type {import('next').NextConfig} */
const rawNextConfig = {
webpack: (config) => {
config.module.rules.push({
test: /\.svg$/i,
issuer: /\.[jt]sx?$/,

View file

@ -5,8 +5,8 @@
"license": "AGPL-3.0",
"scripts": {
"build": "cross-env NODE_ENV=production ./build.sh",
"build:mock": "cross-env NEXT_PUBLIC_USE_MOCK_API=true ./build.sh --keep-mock-api",
"build:test": "cross-env NODE_ENV=test NEXT_PUBLIC_TEST_MODE=true ./build.sh --keep-mock-api",
"build:mock": "cross-env BUILD_TIME=true NEXT_PUBLIC_USE_MOCK_API=true ./build.sh --keep-mock-api",
"build:test": "cross-env BUILD_TIME=true NODE_ENV=test NEXT_PUBLIC_TEST_MODE=true ./build.sh --keep-mock-api",
"analyze": "cross-env ANALYZE=true yarn build --profile",
"format": "prettier -c \"src/**/*.{ts,tsx,js}\" \"cypress/**/*.{ts,tsx}\"",
"format:fix": "prettier -w \"src/**/*.{ts,tsx,js}\" \"cypress/**/*.{ts,tsx}\"",

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/icons/mstile-150x150.png"/>
<TileColor>#b51f08</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

@ -5,6 +5,7 @@
*/
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import type { FrontendConfig } from './types'
import { isBuildTime } from '../../utils/test-modes'
/**
* Fetches the frontend config from the backend.
@ -12,7 +13,10 @@ import type { FrontendConfig } from './types'
* @return The frontend config.
* @throws {Error} when the api request wasn't successful.
*/
export const getConfig = async (baseUrl?: string): Promise<FrontendConfig> => {
export const getConfig = async (baseUrl?: string): Promise<FrontendConfig | undefined> => {
if (isBuildTime) {
return undefined
}
const response = await new GetApiRequestBuilder<FrontendConfig>('config', baseUrl).sendRequest()
return response.asParsedJsonObject()
}

View file

@ -16,8 +16,8 @@ import type { Note, NoteDeletionOptions, NoteMetadata } from './types'
* @return Content and metadata of the specified note.
* @throws {Error} when the api request wasn't successful.
*/
export const getNote = async (noteIdOrAlias: string): Promise<Note> => {
const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias).sendRequest()
export const getNote = async (noteIdOrAlias: string, baseUrl?: string): Promise<Note> => {
const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias, baseUrl).sendRequest()
return response.asParsedJsonObject()
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getNote } from '../../../api/notes'
import { redirect } from 'next/navigation'
import { baseUrlFromEnvExtractor } from '../../../utils/base-url-from-env-extractor'
import { notFound } from 'next/navigation'
interface PageProps {
params: { id: string | undefined }
}
/**
* Redirects the user to the editor if the link is a root level direct link to a version 1 note.
*/
const DirectLinkFallback = async ({ params }: PageProps) => {
const baseUrl = baseUrlFromEnvExtractor.extractBaseUrls().editor
if (params.id === undefined) {
notFound()
}
try {
const noteData = await getNote(params.id, baseUrl)
if (noteData.metadata.version !== 1) {
notFound()
}
} catch (error) {
notFound()
}
redirect(`/n/${params.id}`)
}
export default DirectLinkFallback

View file

@ -1,19 +1,18 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { CheatsheetContent } from '../components/cheatsheet/cheatsheet-content'
import { useApplyDarkModeStyle } from '../hooks/dark-mode/use-apply-dark-mode-style'
import { CheatsheetContent } from '../../../components/cheatsheet/cheatsheet-content'
import type { NextPage } from 'next'
import { Container } from 'react-bootstrap'
const CheatsheetPage: NextPage = () => {
useApplyDarkModeStyle()
return (
<Container>
<CheatsheetContent></CheatsheetContent>
<CheatsheetContent />
</Container>
)
}

View file

@ -0,0 +1,42 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { UiIcon } from '../../components/common/icons/ui-icon'
import { ExternalLink } from '../../components/common/links/external-link'
import links from '../../links.json'
import React, { useEffect } from 'react'
import { Button, Container } from 'react-bootstrap'
import { ArrowRepeat as IconArrowRepeat } from 'react-bootstrap-icons'
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
useEffect(() => {
console.error(error)
}, [error])
return (
<html>
<body>
<Container className='d-flex flex-column mvh-100'>
<div className='d-flex flex-column align-items-center justify-content-center my-5'>
<h1>An unknown error occurred</h1>
<p>
Don&apos;t worry, this happens sometimes. If this is the first time you see this page then try reloading
the app.
</p>
If you can reproduce this error, then we would be glad if you{' '}
<ExternalLink text={'open an issue on github'} href={links.issues} className={'text-primary'} /> or{' '}
<ExternalLink text={'contact us on matrix.'} href={links.chat} className={'text-primary'} />
<Button onClick={reset} title={'Reload App'} className={'mt-4'}>
<UiIcon icon={IconArrowRepeat} />
&nbsp;Reload App
</Button>
</div>
</Container>
</body>
</html>
)
}

View file

@ -1,13 +1,15 @@
'use client'
/*
* 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 { HistoryContent } from '../components/history-page/history-content/history-content'
import { HistoryToolbar } from '../components/history-page/history-toolbar/history-toolbar'
import { useSafeRefreshHistoryStateCallback } from '../components/history-page/history-toolbar/hooks/use-safe-refresh-history-state'
import { HistoryToolbarStateContextProvider } from '../components/history-page/history-toolbar/toolbar-context/history-toolbar-state-context-provider'
import { LandingLayout } from '../components/landing-layout/landing-layout'
import { HistoryContent } from '../../../components/history-page/history-content/history-content'
import { HistoryToolbar } from '../../../components/history-page/history-toolbar/history-toolbar'
import { useSafeRefreshHistoryStateCallback } from '../../../components/history-page/history-toolbar/hooks/use-safe-refresh-history-state'
import { HistoryToolbarStateContextProvider } from '../../../components/history-page/history-toolbar/toolbar-context/history-toolbar-state-context-provider'
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
import type { NextPage } from 'next'
import React, { useEffect } from 'react'
import { Row } from 'react-bootstrap'

View file

@ -1,15 +1,17 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { CustomBranding } from '../components/common/custom-branding/custom-branding'
import { HedgeDocLogoVertical } from '../components/common/hedge-doc-logo/hedge-doc-logo-vertical'
import { LogoSize } from '../components/common/hedge-doc-logo/logo-size'
import { EditorToRendererCommunicatorContextProvider } from '../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
import { CoverButtons } from '../components/intro-page/cover-buttons/cover-buttons'
import { IntroCustomContent } from '../components/intro-page/intro-custom-content'
import { LandingLayout } from '../components/landing-layout/landing-layout'
import { CustomBranding } from '../../../components/common/custom-branding/custom-branding'
import { HedgeDocLogoVertical } from '../../../components/common/hedge-doc-logo/hedge-doc-logo-vertical'
import { LogoSize } from '../../../components/common/hedge-doc-logo/logo-size'
import { EditorToRendererCommunicatorContextProvider } from '../../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
import { CoverButtons } from '../../../components/intro-page/cover-buttons/cover-buttons'
import { IntroCustomContent } from '../../../components/intro-page/intro-custom-content'
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
import type { NextPage } from 'next'
import React from 'react'
import { Trans } from 'react-i18next'

View file

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import '../../../global-styles/index.scss'
import { ApplicationLoader } from '../../components/application-loader/application-loader'
import { BaseUrlContextProvider } from '../../components/common/base-url/base-url-context-provider'
import { FrontendConfigContextProvider } from '../../components/common/frontend-config-context/frontend-config-context-provider'
import { MotdModal } from '../../components/global-dialogs/motd-modal/motd-modal'
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 React from 'react'
import { getConfig } from '../../api/config'
configureLuxon()
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const baseUrls = baseUrlFromEnvExtractor.extractBaseUrls()
const frontendConfig = await getConfig(baseUrls.editor)
return (
<html lang='en'>
<head>
<link color='#b51f08' href='/icons/safari-pinned-tab.svg' rel='mask-icon' />
</head>
<body>
<ExpectedOriginBoundary expectedOrigin={baseUrls.editor}>
<BaseUrlContextProvider baseUrls={baseUrls}>
<FrontendConfigContextProvider config={frontendConfig}>
<StoreProvider>
<ApplicationLoader>
<DarkMode />
<MotdModal />
<UiNotificationBoundary>{children}</UiNotificationBoundary>
</ApplicationLoader>
</StoreProvider>
</FrontendConfigContextProvider>
</BaseUrlContextProvider>
</ExpectedOriginBoundary>
</body>
</html>
)
}
export const metadata: Metadata = {
themeColor: '#b51f08',
applicationName: 'HedgeDoc',
appleWebApp: {
title: 'HedgeDoc'
},
description: 'HedgeDoc - Ideas grow better together',
viewport: 'width=device-width, initial-scale=1',
title: 'HedgeDoc',
manifest: '/icons/site.webmanifest'
}

View file

@ -1,19 +1,22 @@
'use client'
/*
* 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'
import { filterOneClickProviders } from '../components/login-page/auth/utils'
import { ViaLdap } from '../components/login-page/auth/via-ldap'
import { ViaLocal } from '../components/login-page/auth/via-local'
import { ViaOneClick } from '../components/login-page/auth/via-one-click'
import { useApplicationState } from '../hooks/common/use-application-state'
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'
import { filterOneClickProviders } from '../../../components/login-page/auth/utils'
import { ViaLdap } from '../../../components/login-page/auth/via-ldap'
import { ViaLocal } from '../../../components/login-page/auth/via-local'
import { ViaOneClick } from '../../../components/login-page/auth/via-one-click'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import type { NextPage } from 'next'
import React, { useMemo } from 'react'
import { Card, Col, Row } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
@ -22,7 +25,7 @@ import { Trans, useTranslation } from 'react-i18next'
* Renders the login page with buttons and fields for the enabled auth providers.
* Redirects the user to the history page if they are already logged in.
*/
export const LoginPage: React.FC = () => {
const LoginPage: NextPage = () => {
useTranslation()
const authProviders = useFrontendConfig().authProviders
const userLoggedIn = useApplicationState((state) => !!state.user)

View file

@ -0,0 +1,54 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NoteIdProps } from '../../../../components/common/note-loading-boundary/note-loading-boundary'
import { NoteLoadingBoundary } from '../../../../components/common/note-loading-boundary/note-loading-boundary'
import { EditorPageContent } from '../../../../components/editor-page/editor-page-content'
import { EditorToRendererCommunicatorContextProvider } from '../../../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
import type { NextPage } from 'next'
import React from 'react'
interface PageParams {
params: NoteIdProps
}
/**
* Renders a page that is used by the user to edit markdown notes. It contains the editor and a renderer.
*/
const EditorPage: NextPage<PageParams> = ({ params }) => {
return (
<NoteLoadingBoundary noteId={params.noteId}>
<EditorToRendererCommunicatorContextProvider>
<EditorPageContent />
</EditorToRendererCommunicatorContextProvider>
</NoteLoadingBoundary>
)
}
/*
TODO: implement these in generateMetadata. We need these only in SSR.
See https://github.com/hedgedoc/hedgedoc/issues/4766
But its problematic because we dont get the opengraph meta data via API.
<NoteAndAppTitleHead />
<OpengraphHead />
<LicenseLinkHead />
export async function generateMetadata({ params }: PageParams): Promise<Metadata> {
if (!params.noteId) {
return {}
}
const note = await getNote(params.noteId, getBaseUrls().editor)
return {
title: `HedgeDoc - ${ note.metadata.title }`
description: note.metadata.description
}
}
*/
export default EditorPage

View file

@ -1,16 +1,18 @@
'use client'
/*
* 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 { createNote } from '../api/notes'
import type { Note } from '../api/notes/types'
import { LoadingScreen } from '../components/application-loader/loading-screen/loading-screen'
import { CustomAsyncLoadingBoundary } from '../components/common/async-loading-boundary/custom-async-loading-boundary'
import { Redirect } from '../components/common/redirect'
import { ShowIf } from '../components/common/show-if/show-if'
import { CommonErrorPage } from '../components/error-pages/common-error-page'
import { useSingleStringUrlParameter } from '../hooks/common/use-single-string-url-parameter'
import { createNote } from '../../../api/notes'
import type { Note } from '../../../api/notes/types'
import { LoadingScreen } from '../../../components/application-loader/loading-screen/loading-screen'
import { CustomAsyncLoadingBoundary } from '../../../components/common/async-loading-boundary/custom-async-loading-boundary'
import { Redirect } from '../../../components/common/redirect'
import { ShowIf } from '../../../components/common/show-if/show-if'
import { CommonErrorPage } from '../../../components/error-pages/common-error-page'
import { useSingleStringUrlParameter } from '../../../hooks/common/use-single-string-url-parameter'
import type { NextPage } from 'next'
import React from 'react'
import { useAsync } from 'react-use'
@ -18,7 +20,7 @@ import { useAsync } from 'react-use'
/**
* Creates a new note, optionally including the passed content and redirects to that note.
*/
export const NewNotePage: NextPage = () => {
const NewNotePage: NextPage = () => {
const newContent = useSingleStringUrlParameter('content', '')
const { loading, error, value } = useAsync(() => {

View file

@ -3,14 +3,14 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { CommonErrorPage } from '../components/error-pages/common-error-page'
import { CommonErrorPage } from '../../components/error-pages/common-error-page'
import type { NextPage } from 'next'
/**
* Renders a hedgedoc themed 404 page.
*/
const Custom404: NextPage = () => {
const NotFound: NextPage = () => {
return <CommonErrorPage titleI18nKey={'errors.notFound.title'} descriptionI18nKey={'errors.notFound.description'} />
}
export default Custom404
export default NotFound

View file

@ -0,0 +1,35 @@
'use client'
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NoteIdProps } from '../../../../components/common/note-loading-boundary/note-loading-boundary'
import { NoteLoadingBoundary } from '../../../../components/common/note-loading-boundary/note-loading-boundary'
import { useNoteAndAppTitle } from '../../../../components/editor-page/head-meta-properties/use-note-and-app-title'
import { EditorToRendererCommunicatorContextProvider } from '../../../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
import { SlideShowPageContent } from '../../../../components/slide-show-page/slide-show-page-content'
import type { NextPage } from 'next'
import React from 'react'
interface PageParams {
params: NoteIdProps
}
/**
* Renders a page that is used by the user to hold a presentation. It contains the renderer for the presentation.
*/
const SlideShowPage: NextPage<PageParams> = ({ params }) => {
useNoteAndAppTitle()
return (
<NoteLoadingBoundary noteId={params.noteId}>
<EditorToRendererCommunicatorContextProvider>
<SlideShowPageContent />
</EditorToRendererCommunicatorContextProvider>
</NoteLoadingBoundary>
)
}
export default SlideShowPage

View file

@ -1,17 +1,20 @@
'use client'
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
*/
import { AuthProviderType } from '../api/config/types'
import { Redirect } from '../components/common/redirect'
import { ShowIf } from '../components/common/show-if/show-if'
import { LandingLayout } from '../components/landing-layout/landing-layout'
import { ProfileAccessTokens } from '../components/profile-page/access-tokens/profile-access-tokens'
import { ProfileAccountManagement } from '../components/profile-page/account-management/profile-account-management'
import { ProfileChangePassword } from '../components/profile-page/settings/profile-change-password'
import { ProfileDisplayName } from '../components/profile-page/settings/profile-display-name'
import { useApplicationState } from '../hooks/common/use-application-state'
import { AuthProviderType } from '../../../api/config/types'
import { Redirect } from '../../../components/common/redirect'
import { ShowIf } from '../../../components/common/show-if/show-if'
import { LandingLayout } from '../../../components/landing-layout/landing-layout'
import { ProfileAccessTokens } from '../../../components/profile-page/access-tokens/profile-access-tokens'
import { ProfileAccountManagement } from '../../../components/profile-page/account-management/profile-account-management'
import { ProfileChangePassword } from '../../../components/profile-page/settings/profile-change-password'
import { ProfileDisplayName } from '../../../components/profile-page/settings/profile-display-name'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import type { NextPage } from 'next'
import React from 'react'
import { Col, Row } from 'react-bootstrap'
@ -19,7 +22,7 @@ import { Col, Row } from 'react-bootstrap'
* Profile page that includes forms for changing display name, password (if internal login is used),
* managing access tokens and deleting the account.
*/
export const ProfilePage: React.FC = () => {
const ProfilePage: NextPage = () => {
const userProvider = useApplicationState((state) => state.user?.authProvider)
if (!userProvider) {

View file

@ -1,26 +1,27 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { doLocalRegister } from '../api/auth/local'
import type { ApiError } from '../api/common/api-error'
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 { UsernameLabelField } from '../components/common/fields/username-label-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'
import { useUiNotifications } from '../components/notifications/ui-notification-boundary'
import { RegisterError } from '../components/register-page/register-error'
import { RegisterInfos } from '../components/register-page/register-infos'
import { useApplicationState } from '../hooks/common/use-application-state'
import { useLowercaseOnInputChange } from '../hooks/common/use-lowercase-on-input-change'
import { useOnInputChange } from '../hooks/common/use-on-input-change'
import { doLocalRegister } from '../../../api/auth/local'
import type { ApiError } from '../../../api/common/api-error'
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 { UsernameLabelField } from '../../../components/common/fields/username-label-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'
import { useUiNotifications } from '../../../components/notifications/ui-notification-boundary'
import { RegisterError } from '../../../components/register-page/register-error'
import { RegisterInfos } from '../../../components/register-page/register-infos'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { useLowercaseOnInputChange } from '../../../hooks/common/use-lowercase-on-input-change'
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
import type { NextPage } from 'next'
import { useRouter } from 'next/router'
import { useRouter } from 'next/navigation'
import type { FormEvent } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import { Button, Card, Col, Form, Row } from 'react-bootstrap'
@ -29,7 +30,7 @@ import { Trans, useTranslation } from 'react-i18next'
/**
* Renders the registration page with fields for username, display name, password, password retype and information about terms and conditions.
*/
export const RegisterPage: NextPage = () => {
const RegisterPage: NextPage = () => {
useTranslation()
const router = useRouter()
const allowRegister = useFrontendConfig().allowRegister

View file

@ -0,0 +1,38 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NoteIdProps } from '../../../../components/common/note-loading-boundary/note-loading-boundary'
import { NoteLoadingBoundary } from '../../../../components/common/note-loading-boundary/note-loading-boundary'
import { DocumentReadOnlyPageContent } from '../../../../components/document-read-only-page/document-read-only-page-content'
import { useNoteAndAppTitle } from '../../../../components/editor-page/head-meta-properties/use-note-and-app-title'
import { EditorToRendererCommunicatorContextProvider } from '../../../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
import { BaseAppBar } from '../../../../components/layout/app-bar/base-app-bar'
import type { NextPage } from 'next'
import React from 'react'
interface PageParams {
params: NoteIdProps
}
/**
* Renders a page that contains only the rendered document without an editor or realtime updates.
*/
const DocumentReadOnlyPage: NextPage<PageParams> = ({ params }) => {
useNoteAndAppTitle()
return (
<EditorToRendererCommunicatorContextProvider>
<NoteLoadingBoundary noteId={params.noteId}>
<div className={'d-flex flex-column mvh-100'}>
<BaseAppBar />
<DocumentReadOnlyPageContent />
</div>
</NoteLoadingBoundary>
</EditorToRendererCommunicatorContextProvider>
)
}
export default DocumentReadOnlyPage

View file

@ -0,0 +1,42 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { UiIcon } from '../../components/common/icons/ui-icon'
import { ExternalLink } from '../../components/common/links/external-link'
import links from '../../links.json'
import React, { useEffect } from 'react'
import { Button, Container } from 'react-bootstrap'
import { ArrowRepeat as IconArrowRepeat } from 'react-bootstrap-icons'
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
useEffect(() => {
console.error(error)
}, [error])
return (
<html>
<body>
<Container className='d-flex flex-column mvh-100'>
<div className='d-flex flex-column align-items-center justify-content-center my-5'>
<h1>An unknown error occurred</h1>
<p>
Don&apos;t worry, this happens sometimes. If this is the first time you see this page then try reloading
the app.
</p>
If you can reproduce this error, then we would be glad if you&#32;
<ExternalLink text={'open an issue on github'} href={links.issues} className={'text-primary'} />
&#32; or <ExternalLink text={'contact us on matrix.'} href={links.chat} className={'text-primary'} />
<Button onClick={reset} title={'Reload App'} className={'mt-4'}>
<UiIcon icon={IconArrowRepeat} />
&nbsp;Reload App
</Button>
</div>
</Container>
</body>
</html>
)
}

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import '../../../global-styles/index.scss'
import { ApplicationLoader } from '../../components/application-loader/application-loader'
import { BaseUrlContextProvider } from '../../components/common/base-url/base-url-context-provider'
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'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const baseUrls = baseUrlFromEnvExtractor.extractBaseUrls()
const frontendConfig = await getConfig(baseUrls.renderer)
return (
<html lang='en'>
<body>
<ExpectedOriginBoundary expectedOrigin={baseUrls.renderer}>
<BaseUrlContextProvider baseUrls={baseUrls}>
<FrontendConfigContextProvider config={frontendConfig}>
<StoreProvider>
<ApplicationLoader>{children}</ApplicationLoader>
</StoreProvider>
</FrontendConfigContextProvider>
</BaseUrlContextProvider>
</ExpectedOriginBoundary>
</body>
</html>
)
}

View file

@ -1,17 +1,18 @@
'use client'
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { RendererToEditorCommunicatorContextProvider } from '../components/editor-page/render-context/renderer-to-editor-communicator-context-provider'
import { RenderPageContent } from '../components/render-page/render-page-content'
import { RendererToEditorCommunicatorContextProvider } from '../../../components/editor-page/render-context/renderer-to-editor-communicator-context-provider'
import { RenderPageContent } from '../../../components/render-page/render-page-content'
import type { NextPage } from 'next'
import React from 'react'
/**
* Renders the actual markdown renderer that receives the content and metadata via iframe communication.
*/
export const RenderPage: NextPage = () => {
const RenderPage: NextPage = () => {
return (
<RendererToEditorCommunicatorContextProvider>
<RenderPageContent />

View file

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: LicenseRef-HedgeDoc-Icon-Usage-Guidelines

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: LicenseRef-HedgeDoc-Icon-Usage-Guidelines

BIN
frontend/src/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: LicenseRef-HedgeDoc-Icon-Usage-Guidelines

View file

@ -1,3 +1,4 @@
'use client'
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*

View file

@ -6,7 +6,6 @@
import { DARK_MODE_LOCAL_STORAGE_KEY } from '../../../hooks/dark-mode/use-save-dark-mode-preference-to-local-storage'
import { setDarkModePreference } from '../../../redux/dark-mode/methods'
import { DarkModePreference } from '../../../redux/dark-mode/types'
import { isClientSideRendering } from '../../../utils/is-client-side-rendering'
import { Logger } from '../../../utils/logger'
const logger = new Logger('Dark mode initializer')
@ -29,9 +28,6 @@ export const loadDarkMode = (): Promise<void> => {
* {@link false} if the user doesn't prefer dark mode or if the value couldn't be read from local storage.
*/
const fetchDarkModeFromLocalStorage = (): DarkModePreference => {
if (!isClientSideRendering()) {
return DarkModePreference.AUTO
}
try {
const colorScheme = window.localStorage.getItem(DARK_MODE_LOCAL_STORAGE_KEY)
if (colorScheme === 'dark') {

View file

@ -36,6 +36,9 @@ export const setUpI18n = async (): Promise<void> => {
}
})
i18n.on('languageChanged', (language) => (Settings.defaultLocale = language))
i18n.on('languageChanged', (language) => {
Settings.defaultLocale = language
document.documentElement.lang = i18n.language
})
Settings.defaultLocale = i18n.language
}

View file

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

View file

@ -3,7 +3,6 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isClientSideRendering } from '../../../../utils/is-client-side-rendering'
import { Logger } from '../../../../utils/logger'
import { UiIcon } from '../../icons/ui-icon'
import { ShowIf } from '../../show-if/show-if'
@ -30,7 +29,7 @@ export const CopyableField: React.FC<CopyableFieldProps> = ({ content, shareOrig
useTranslation()
const sharingSupported = useMemo(
() => shareOriginUrl !== undefined && isClientSideRendering() && typeof navigator.share === 'function',
() => shareOriginUrl !== undefined && typeof navigator.share === 'function',
[shareOriginUrl]
)

View file

@ -3,7 +3,6 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isClientSideRendering } from '../../../../utils/is-client-side-rendering'
import { Logger } from '../../../../utils/logger'
import { ShowIf } from '../../show-if/show-if'
import type { ReactElement, RefObject } from 'react'
@ -45,11 +44,6 @@ export const useCopyOverlay = (
}, [reset, showState])
const copyToClipboard = useCallback(() => {
if (!isClientSideRendering()) {
setShowState(SHOW_STATE.ERROR)
log.error('Clipboard not available in server side rendering')
return
}
if (typeof navigator.clipboard === 'undefined') {
setShowState(SHOW_STATE.ERROR)
return

View file

@ -1,17 +1,13 @@
'use client'
/*
* 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')
import React from 'react'
interface FrontendConfigContextProviderProps extends PropsWithChildren {
config?: FrontendConfig
@ -24,22 +20,9 @@ interface FrontendConfigContextProviderProps extends PropsWithChildren {
* @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 ? (
return config === undefined ? (
<span className={'text-white bg-dark'}>No frontend config received! Please check the server log.</span>
) : (
<frontendConfigContext.Provider value={configState}>{children}</frontendConfigContext.Provider>
<frontendConfigContext.Provider value={config}>{children}</frontendConfigContext.Provider>
)
}

View file

@ -38,6 +38,8 @@ exports[`create non existing note hint renders an button as initial state 1`] =
</div>
`;
exports[`create non existing note hint renders nothing if no note id has been provided 1`] = `<div />`;
exports[`create non existing note hint shows an error message if note couldn't be created 1`] = `
<div>
<div

View file

@ -5,7 +5,6 @@
*/
import * as createNoteWithPrimaryAliasModule from '../../../api/notes'
import type { Note, NoteMetadata } from '../../../api/notes/types'
import * as useSingleStringUrlParameterModule from '../../../hooks/common/use-single-string-url-parameter'
import { mockI18n } from '../../../test-utils/mock-i18n'
import { CreateNonExistingNoteHint } from './create-non-existing-note-hint'
import { waitForOtherPromisesToFinish } from '@hedgedoc/commons'
@ -18,14 +17,6 @@ jest.mock('../../../hooks/common/use-single-string-url-parameter')
describe('create non existing note hint', () => {
const mockedNoteId = 'mockedNoteId'
const mockGetNoteIdQueryParameter = () => {
const expectedQueryParameter = 'noteId'
jest.spyOn(useSingleStringUrlParameterModule, 'useSingleStringUrlParameter').mockImplementation((parameter) => {
expect(parameter).toBe(expectedQueryParameter)
return mockedNoteId
})
}
const mockCreateNoteWithPrimaryAlias = () => {
jest
.spyOn(createNoteWithPrimaryAliasModule, 'createNoteWithPrimaryAlias')
@ -59,14 +50,24 @@ describe('create non existing note hint', () => {
jest.resetModules()
})
beforeEach(() => {
mockGetNoteIdQueryParameter()
it('renders nothing if no note id has been provided', async () => {
const onNoteCreatedCallback = jest.fn()
const view = render(
<CreateNonExistingNoteHint noteId={undefined} onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
)
await waitForOtherPromisesToFinish()
expect(onNoteCreatedCallback).not.toBeCalled()
expect(view.container).toMatchSnapshot()
})
it('renders an button as initial state', async () => {
mockCreateNoteWithPrimaryAlias()
const onNoteCreatedCallback = jest.fn()
const view = render(<CreateNonExistingNoteHint onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>)
const view = render(
<CreateNonExistingNoteHint
noteId={mockedNoteId}
onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
)
await screen.findByTestId('createNoteMessage')
await waitForOtherPromisesToFinish()
expect(onNoteCreatedCallback).not.toBeCalled()
@ -76,7 +77,11 @@ describe('create non existing note hint', () => {
it('renders a waiting message when button is clicked', async () => {
mockCreateNoteWithPrimaryAlias()
const onNoteCreatedCallback = jest.fn()
const view = render(<CreateNonExistingNoteHint onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>)
const view = render(
<CreateNonExistingNoteHint
noteId={mockedNoteId}
onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
)
const button = await screen.findByTestId('createNoteButton')
await act<void>(() => {
button.click()
@ -92,7 +97,11 @@ describe('create non existing note hint', () => {
it('shows success message when the note has been created', async () => {
mockCreateNoteWithPrimaryAlias()
const onNoteCreatedCallback = jest.fn()
const view = render(<CreateNonExistingNoteHint onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>)
const view = render(
<CreateNonExistingNoteHint
noteId={mockedNoteId}
onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
)
const button = await screen.findByTestId('createNoteButton')
await act<void>(() => {
button.click()
@ -108,7 +117,11 @@ describe('create non existing note hint', () => {
it("shows an error message if note couldn't be created", async () => {
mockFailingCreateNoteWithPrimaryAlias()
const onNoteCreatedCallback = jest.fn()
const view = render(<CreateNonExistingNoteHint onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>)
const view = render(
<CreateNonExistingNoteHint
noteId={mockedNoteId}
onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
)
const button = await screen.findByTestId('createNoteButton')
await act<void>(() => {
button.click()

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createNoteWithPrimaryAlias } from '../../../api/notes'
import { useSingleStringUrlParameter } from '../../../hooks/common/use-single-string-url-parameter'
import { testId } from '../../../utils/test-id'
import { UiIcon } from '../icons/ui-icon'
import { ShowIf } from '../show-if/show-if'
@ -20,6 +19,7 @@ import { useAsyncFn } from 'react-use'
export interface CreateNonExistingNoteHintProps {
onNoteCreated: () => void
noteId: string | undefined
}
/**
@ -27,17 +27,16 @@ export interface CreateNonExistingNoteHintProps {
* When the button was clicked it also shows the progress.
*
* @param onNoteCreated A function that will be called after the note was created.
* @param noteId The wanted id for the note to create
*/
export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps> = ({ onNoteCreated }) => {
export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps> = ({ onNoteCreated, noteId }) => {
useTranslation()
const noteIdFromUrl = useSingleStringUrlParameter('noteId', undefined)
const [returnState, createNote] = useAsyncFn(async () => {
if (noteIdFromUrl === undefined) {
throw new Error('Note id not set')
if (noteId !== undefined) {
return await createNoteWithPrimaryAlias('', noteId)
}
return await createNoteWithPrimaryAlias('', noteIdFromUrl)
}, [noteIdFromUrl])
}, [noteId])
const onClickHandler = useCallback(() => {
void createNote()
@ -49,7 +48,7 @@ export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps>
}
}, [onNoteCreated, returnState.value])
if (noteIdFromUrl === undefined) {
if (noteId === undefined) {
return null
} else if (returnState.value) {
return (
@ -76,7 +75,7 @@ export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps>
return (
<Alert variant={'info'} {...testId('createNoteMessage')} className={'mt-5'}>
<span>
<Trans i18nKey={'noteLoadingBoundary.createNote.question'} values={{ aliasName: noteIdFromUrl }} />
<Trans i18nKey={'noteLoadingBoundary.createNote.question'} values={{ aliasName: noteId }} />
</span>
<div className={'mt-3'}>
<Button

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getNote } from '../../../../api/notes'
import { useSingleStringUrlParameter } from '../../../../hooks/common/use-single-string-url-parameter'
import { setNoteDataFromServer } from '../../../../redux/note-details/methods'
import { useAsyncFn } from 'react-use'
import type { AsyncState } from 'react-use/lib/useAsyncFn'
@ -14,15 +13,13 @@ import type { AsyncState } from 'react-use/lib/useAsyncFn'
*
* @return An {@link AsyncState async state} that represents the current state of the loading process.
*/
export const useLoadNoteFromServer = (): [AsyncState<boolean>, () => void] => {
const id = useSingleStringUrlParameter('noteId', undefined)
export const useLoadNoteFromServer = (noteId: string | undefined): [AsyncState<boolean>, () => void] => {
return useAsyncFn(async (): Promise<boolean> => {
if (id === undefined) {
if (noteId === undefined) {
throw new Error('Invalid id')
}
const noteFromServer = await getNote(id)
const noteFromServer = await getNote(noteId)
setNoteDataFromServer(noteFromServer)
return true
}, [id])
}, [noteId])
}

View file

@ -7,7 +7,6 @@ import { ApiError } from '../../../api/common/api-error'
import * as getNoteModule from '../../../api/notes'
import type { Note } from '../../../api/notes/types'
import * as LoadingScreenModule from '../../../components/application-loader/loading-screen/loading-screen'
import * as useSingleStringUrlParameterModule from '../../../hooks/common/use-single-string-url-parameter'
import * as setNoteDataFromServerModule from '../../../redux/note-details/methods'
import { mockI18n } from '../../../test-utils/mock-i18n'
import { testId } from '../../../utils/test-id'
@ -64,17 +63,8 @@ describe('Note loading boundary', () => {
</Fragment>
)
})
mockGetNoteIdQueryParameter()
})
const mockGetNoteIdQueryParameter = () => {
const expectedQueryParameter = 'noteId'
jest.spyOn(useSingleStringUrlParameterModule, 'useSingleStringUrlParameter').mockImplementation((parameter) => {
expect(parameter).toBe(expectedQueryParameter)
return mockedNoteId
})
}
const mockGetNoteApiCall = (returnValue: Note) => {
jest.spyOn(getNoteModule, 'getNote').mockImplementation((id) => {
expect(id).toBe(mockedNoteId)
@ -105,7 +95,7 @@ describe('Note loading boundary', () => {
const setNoteInReduxFunctionMock = mockSetNoteInRedux(mockedNote)
const view = render(
<NoteLoadingBoundary>
<NoteLoadingBoundary noteId={mockedNoteId}>
<span data-testid={'success'}>success!</span>
</NoteLoadingBoundary>
)
@ -121,7 +111,7 @@ describe('Note loading boundary', () => {
const setNoteInReduxFunctionMock = mockSetNoteInRedux(mockedNote)
const view = render(
<NoteLoadingBoundary>
<NoteLoadingBoundary noteId={mockedNoteId}>
<span data-testid={'success'}>success!</span>
</NoteLoadingBoundary>
)

View file

@ -1,3 +1,4 @@
'use client'
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
@ -17,15 +18,20 @@ import React, { useEffect, useMemo } from 'react'
const logger = new Logger('NoteLoadingBoundary')
export interface NoteIdProps {
noteId: string | undefined
}
/**
* Loads the note identified by the note-id in the URL.
* During the loading a {@link LoadingScreen loading screen} will be rendered instead of the child elements.
* The boundary also shows errors that occur during the loading process.
*
* @param children The react elements that will be shown when the loading was successful.
* @param children The react elements that will be shown when the loading was successful
* @param noteId the id of the note to load
*/
export const NoteLoadingBoundary: React.FC<PropsWithChildren> = ({ children }) => {
const [{ error, loading, value }, loadNoteFromServer] = useLoadNoteFromServer()
export const NoteLoadingBoundary: React.FC<PropsWithChildren<NoteIdProps>> = ({ children, noteId }) => {
const [{ error, loading, value }, loadNoteFromServer] = useLoadNoteFromServer(noteId)
useEffect(() => {
loadNoteFromServer()
@ -46,11 +52,11 @@ export const NoteLoadingBoundary: React.FC<PropsWithChildren> = ({ children }) =
titleI18nKey={`${errorI18nKeyPrefix}.title`}
descriptionI18nKey={`${errorI18nKeyPrefix}.description`}>
<ShowIf condition={error instanceof ApiError && error.statusCode === 404}>
<CreateNonExistingNoteHint onNoteCreated={loadNoteFromServer} />
<CreateNonExistingNoteHint onNoteCreated={loadNoteFromServer} noteId={noteId} />
</ShowIf>
</CommonErrorPage>
)
}, [error, loadNoteFromServer])
}, [error, loadNoteFromServer, noteId])
return (
<CustomAsyncLoadingBoundary

View file

@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useRouter } from 'next/router'
import { useRouter } from 'next/navigation'
import React, { useEffect } from 'react'
/**

View file

@ -3,10 +3,9 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Logger } from '../../utils/logger'
import { testId } from '../../utils/test-id'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useRouter } from 'next/navigation'
import React, { useEffect } from 'react'
export interface RedirectProps {
@ -14,8 +13,6 @@ export interface RedirectProps {
replace?: boolean
}
const logger = new Logger('Redirect')
/**
* Redirects the user to another URL. Can be external or internal.
*
@ -26,9 +23,7 @@ export const Redirect: React.FC<RedirectProps> = ({ to, replace }) => {
const router = useRouter()
useEffect(() => {
;(replace ? router.replace(to) : router.push(to)).catch((error: Error) => {
logger.error(`Error while redirecting to ${to}`, error)
})
replace ? router.replace(to) : router.push(to)
}, [replace, router, to])
return (

View file

@ -3,16 +3,13 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplyDarkModeStyle } from '../../hooks/dark-mode/use-apply-dark-mode-style'
import { useSaveDarkModePreferenceToLocalStorage } from '../../hooks/dark-mode/use-save-dark-mode-preference-to-local-storage'
import { MotdModal } from '../global-dialogs/motd-modal/motd-modal'
import { EditorAppBar } from '../layout/app-bar/editor-app-bar'
import { CommunicatorImageLightbox } from '../markdown-renderer/extensions/image/communicator-image-lightbox'
import { ExtensionEventEmitterProvider } from '../markdown-renderer/hooks/use-extension-event-emitter'
import { ChangeEditorContentContextProvider } from './change-content-context/codemirror-reference-context'
import { EditorPane } from './editor-pane/editor-pane'
import { useComponentsFromAppExtensions } from './editor-pane/hooks/use-components-from-app-extensions'
import { HeadMetaProperties } from './head-meta-properties/head-meta-properties'
import { useNoteAndAppTitle } from './head-meta-properties/use-note-and-app-title'
import { useScrollState } from './hooks/use-scroll-state'
import { useSetScrollSource } from './hooks/use-set-scroll-source'
import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-entry'
@ -33,8 +30,6 @@ export enum ScrollSource {
export const EditorPageContent: React.FC = () => {
useTranslation()
useApplyDarkModeStyle()
useSaveDarkModePreferenceToLocalStorage()
useUpdateLocalHistoryEntry()
const scrollSource = useRef<ScrollSource>(ScrollSource.EDITOR)
@ -67,14 +62,13 @@ export const EditorPageContent: React.FC = () => {
)
const editorExtensionComponents = useComponentsFromAppExtensions()
useNoteAndAppTitle()
return (
<ChangeEditorContentContextProvider>
<ExtensionEventEmitterProvider>
{editorExtensionComponents}
<CommunicatorImageLightbox />
<HeadMetaProperties />
<MotdModal />
<div className={'d-flex flex-column vh-100'}>
<EditorAppBar />
<div className={'flex-fill d-flex h-100 w-100 overflow-hidden flex-row'}>

View file

@ -4,16 +4,13 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useNoteTitle } from '../../../../../hooks/common/use-note-title'
import { Logger } from '../../../../../utils/logger'
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
import { useRouter } from 'next/router'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect } from 'react'
const logger = new Logger('UseOnNoteDeleted')
/**
* Hook that redirects the user to the history page and displays a notification when the note is deleted.
*
@ -30,9 +27,7 @@ export const useOnNoteDeleted = (websocketConnection: MessageTransporter): void
noteTitle
}
})
router?.push('/history').catch((error: Error) => {
logger.error(`Error while redirecting to /history`, error)
})
router.push('/history')
}, [router, noteTitle, dispatchUiNotification])
useEffect(() => {

View file

@ -4,7 +4,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { store } from '../../../../../redux'
import { setRealtimeUsers } from '../../../../../redux/realtime/methods'
import { RealtimeStatusActionType } from '../../../../../redux/realtime/types'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
@ -40,4 +42,13 @@ export const useReceiveRealtimeUsers = (messageTransporter: MessageTransporter):
messageTransporter.sendMessage({ type: MessageType.REALTIME_USER_STATE_REQUEST })
}
}, [isConnected, messageTransporter])
useEffect(
() => () => {
store.dispatch({
type: RealtimeStatusActionType.RESET_REALTIME_STATUS
})
},
[]
)
}

View file

@ -1,22 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { LicenseLinkHead } from './license-link-head'
import { NoteAndAppTitleHead } from './note-and-app-title-head'
import { OpengraphHead } from './opengraph-head'
import React, { Fragment } from 'react'
/**
* Renders all HTML head tags that should be present for a note.
*/
export const HeadMetaProperties: React.FC = () => {
return (
<Fragment>
<NoteAndAppTitleHead />
<OpengraphHead />
<LicenseLinkHead />
</Fragment>
)
}

View file

@ -1,24 +0,0 @@
/*
* 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 Head from 'next/head'
import React, { useMemo } from 'react'
/**
* Renders the license link tag if a license is set in the frontmatter.
*/
export const LicenseLinkHead: React.FC = () => {
const license = useApplicationState((state) => state.noteDetails.frontmatter.license)
const optionalLinkElement = useMemo(() => {
if (!license || license.trim() === '') {
return null
}
return <link rel={'license'} href={license} />
}, [license])
return <Head>{optionalLinkElement}</Head>
}

View file

@ -1,28 +0,0 @@
/*
* 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 { useNoteTitle } from '../../../hooks/common/use-note-title'
import Head from 'next/head'
import React, { useMemo } from 'react'
/**
* Returns the meta tags for the opengraph protocol as defined in the note frontmatter.
*/
export const OpengraphHead: React.FC = () => {
const noteTitle = useNoteTitle()
const openGraphData = useApplicationState((state) => state.noteDetails.frontmatter.opengraph)
const openGraphMetaElements = useMemo(() => {
const elements = Object.entries(openGraphData)
.filter(([, value]) => value && String(value).trim() !== '')
.map(([key, value]) => <meta property={`og:${key}`} content={value} key={key} />)
if (!('title' in openGraphData)) {
elements.push(<meta property={'og:title'} content={noteTitle} key={'title'} />)
}
return elements
}, [noteTitle, openGraphData])
return <Head>{openGraphMetaElements}</Head>
}

View file

@ -6,13 +6,12 @@
import { useAppTitle } from '../../../hooks/common/use-app-title'
import { useNoteTitle } from '../../../hooks/common/use-note-title'
import { useHasMarkdownContentBeenChangedInBackground } from './hooks/use-has-markdown-content-been-changed-in-background'
import Head from 'next/head'
import React, { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
/**
* Sets the note and app title for the browser window
*/
export const NoteAndAppTitleHead: React.FC = () => {
export const useNoteAndAppTitle = (): void => {
const noteTitle = useNoteTitle()
const appTitle = useAppTitle()
const showDot = useHasMarkdownContentBeenChangedInBackground()
@ -21,9 +20,7 @@ export const NoteAndAppTitleHead: React.FC = () => {
return (showDot ? '• ' : '') + noteTitle + ' - ' + appTitle
}, [appTitle, noteTitle, showDot])
return (
<Head>
<title>{noteAndAppTitle}</title>
</Head>
)
useEffect(() => {
document.title = noteAndAppTitle
}, [noteAndAppTitle])
}

View file

@ -1,3 +1,5 @@
'use client'
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*

View file

@ -1,3 +1,5 @@
'use client'
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*

View file

@ -1,27 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { resetRealtimeStatus } from '../../redux/realtime/methods'
import { LoadingScreen } from '../application-loader/loading-screen/loading-screen'
import type { PropsWithChildren } from 'react'
import React, { Fragment, useEffect, useState } from 'react'
/**
* Resets the realtime status in the global application state to its initial state before loading the given child elements.
*
* @param children The children to load after the reset
*/
export const ResetRealtimeStateBoundary: React.FC<PropsWithChildren> = ({ children }) => {
const [globalStateInitialized, setGlobalStateInitialized] = useState(false)
useEffect(() => {
resetRealtimeStatus()
setGlobalStateInitialized(true)
}, [])
if (!globalStateInitialized) {
return <LoadingScreen />
} else {
return <Fragment>{children}</Fragment>
}
}

View file

@ -7,19 +7,16 @@ import { deleteNote } from '../../../../../api/notes'
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { useBooleanState } from '../../../../../hooks/common/use-boolean-state'
import { cypressId } from '../../../../../utils/cypress-attribute'
import { Logger } from '../../../../../utils/logger'
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
import { SidebarButton } from '../../sidebar-button/sidebar-button'
import type { SpecificSidebarEntryProps } from '../../types'
import { DeleteNoteModal } from './delete-note-modal'
import { useRouter } from 'next/router'
import { useRouter } from 'next/navigation'
import type { PropsWithChildren } from 'react'
import React, { Fragment, useCallback } from 'react'
import { Trash as IconTrash } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next'
const logger = new Logger('note-deletion')
/**
* Sidebar entry that can be used to delete the current note.
*
@ -35,9 +32,7 @@ export const DeleteNoteSidebarEntry: React.FC<PropsWithChildren<SpecificSidebarE
const deleteNoteAndCloseDialog = useCallback(() => {
deleteNote(noteId)
.then(() => {
router.push('/history').catch((reason) => logger.error('Error while redirecting to /history', reason))
})
.then(() => router.push('/history'))
.catch(showErrorNotification('landing.history.error.deleteNote.text'))
.finally(closeModal)
}, [closeModal, noteId, router, showErrorNotification])

View file

@ -1,69 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import links from '../../links.json'
import { Logger } from '../../utils/logger'
import { UiIcon } from '../common/icons/ui-icon'
import { ExternalLink } from '../common/links/external-link'
import type { ErrorInfo, PropsWithChildren, ReactNode } from 'react'
import React, { Component } from 'react'
import { Button, Container } from 'react-bootstrap'
import { ArrowRepeat as IconArrowRepeat } from 'react-bootstrap-icons'
const log = new Logger('ErrorBoundary')
/**
* An error boundary for the whole application.
* The text in this is not translated, because the error could be part of the translation framework,
* and we still want to display something to the user that's meaningful (and searchable).
*/
export class ErrorBoundary extends Component<PropsWithChildren<unknown>> {
state: {
hasError: boolean
}
constructor(props: Readonly<unknown>) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): { hasError: boolean } {
// Update state so the next render will show the fallback UI.
return { hasError: true }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
log.error('Error catched', error, errorInfo)
}
refreshPage(): void {
window.location.reload()
}
render(): ReactNode | undefined {
if (this.state.hasError) {
return (
<Container className='text-light d-flex flex-column mvh-100'>
<div className='text-light d-flex flex-column align-items-center justify-content-center my-5'>
<h1>An unknown error occurred</h1>
<p>
Don&apos;t worry, this happens sometimes. If this is the first time you see this page then try reloading
the app.
</p>
If you can reproduce this error, then we would be glad if you&#32;
<ExternalLink text={'open an issue on github'} href={links.issues} className={'text-primary'} />
&#32; or <ExternalLink text={'contact us on matrix.'} href={links.chat} className={'text-primary'} />
<Button onClick={() => this.refreshPage()} title={'Reload App'} className={'mt-4'}>
<UiIcon icon={IconArrowRepeat} />
&nbsp;Reload App
</Button>
</div>
</Container>
)
} else {
return this.props.children
}
}
}

View file

@ -1,3 +1,4 @@
'use client'
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*

View file

@ -1,3 +1,4 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*

View file

@ -5,6 +5,7 @@
*/
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
import { useOutlineButtonVariant } from '../../../hooks/dark-mode/use-outline-button-variant'
import { useSaveDarkModePreferenceToLocalStorage } from '../../../hooks/dark-mode/use-save-dark-mode-preference-to-local-storage'
import { cypressId } from '../../../utils/cypress-attribute'
import { IconButton } from '../../common/icon-button/icon-button'
import { SettingsModal } from './settings-modal'
@ -19,6 +20,7 @@ export type SettingsButtonProps = Omit<ButtonProps, 'onClick'>
export const SettingsButton: React.FC<SettingsButtonProps> = (props) => {
const [show, showModal, hideModal] = useBooleanState(false)
const buttonVariant = useOutlineButtonVariant()
useSaveDarkModePreferenceToLocalStorage()
return (
<Fragment>

View file

@ -20,13 +20,13 @@ export const historyToolbarStateContext = createContext<HistoryToolbarStateWithD
* @param children The children that should receive the toolbar state via context.
*/
export const HistoryToolbarStateContextProvider: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
const urlParameterSearch = useSingleStringUrlParameter('search', '')
const urlParameterSelectedTags = useArrayStringUrlParameter('selectedTags')
const search = useSingleStringUrlParameter('search', '')
const selectedTags = useArrayStringUrlParameter('selectedTags')
const stateWithDispatcher = useState<HistoryToolbarState>(() => ({
viewState: ViewStateEnum.CARD,
search: urlParameterSearch,
selectedTags: urlParameterSelectedTags,
search: search,
selectedTags: selectedTags,
titleSortDirection: SortModeEnum.no,
lastVisitedSortDirection: SortModeEnum.down
}))

View file

@ -3,42 +3,46 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useArrayStringUrlParameter } from '../../../../hooks/common/use-array-string-url-parameter'
import { useSingleStringUrlParameter } from '../../../../hooks/common/use-single-string-url-parameter'
import { Logger } from '../../../../utils/logger'
import { useHistoryToolbarState } from './use-history-toolbar-state'
import equal from 'fast-deep-equal'
import { useRouter } from 'next/router'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
const logger = new Logger('useSyncToolbarStateToUrl')
/**
* Pushes the current search and tag selection into the navigation history stack of the browser.
*/
export const useSyncToolbarStateToUrlEffect = (): void => {
const router = useRouter()
const urlParameterSearch = useSingleStringUrlParameter('search', '')
const urlParameterSelectedTags = useArrayStringUrlParameter('selectedTags')
const searchParams = useSearchParams()
const [state] = useHistoryToolbarState()
const pathname = usePathname()
useEffect(() => {
if (!equal(state.search, urlParameterSearch) || !equal(state.selectedTags, urlParameterSelectedTags)) {
router
.push(
{
pathname: router.pathname,
query: {
search: state.search === '' ? [] : state.search,
selectedTags: state.selectedTags
}
},
undefined,
{
shallow: true
}
)
.catch(() => logger.error("Can't update route"))
if (!searchParams || !pathname) {
return
}
}, [state, router, urlParameterSearch, urlParameterSelectedTags])
const urlParameterSearch = searchParams.get('search') ?? ''
const urlParameterSelectedTags = searchParams.getAll('selectedTags')
const params = new URLSearchParams(searchParams.toString())
let shouldUpdate = false
if (!equal(state.search, urlParameterSearch)) {
if (!state.search) {
params.delete('search')
} else {
params.set('search', state.search)
}
shouldUpdate = true
}
if (!equal(state.selectedTags, urlParameterSelectedTags)) {
params.delete('selectedTags')
state.selectedTags.forEach((tag) => params.append('selectedTags', tag))
shouldUpdate = true
}
if (shouldUpdate) {
router.push(`${pathname}?${params.toString()}`)
}
}, [state, router, searchParams, pathname])
}

View file

@ -3,9 +3,6 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplyDarkModeStyle } from '../../hooks/dark-mode/use-apply-dark-mode-style'
import { useSaveDarkModePreferenceToLocalStorage } from '../../hooks/dark-mode/use-save-dark-mode-preference-to-local-storage'
import { MotdModal } from '../global-dialogs/motd-modal/motd-modal'
import { BaseAppBar } from '../layout/app-bar/base-app-bar'
import { HeaderBar } from './navigation/header-bar/header-bar'
import type { PropsWithChildren } from 'react'
@ -18,13 +15,9 @@ import { Container } from 'react-bootstrap'
* @param children The children that should be rendered on the page.
*/
export const LandingLayout: React.FC<PropsWithChildren> = ({ children }) => {
useApplyDarkModeStyle()
useSaveDarkModePreferenceToLocalStorage()
return (
<div>
<BaseAppBar />
<MotdModal />
<Container className='d-flex flex-column'>
<HeaderBar />
<div className={'d-flex flex-column justify-content-between flex-fill text-center'}>

View file

@ -8,7 +8,7 @@ import type { PropsWithDataCypressId } from '../../../../utils/cypress-attribute
import { cypressId } from '../../../../utils/cypress-attribute'
import styles from './header-nav-link.module.scss'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { usePathname } from 'next/navigation'
import type { PropsWithChildren } from 'react'
import React, { useMemo } from 'react'
import { Nav } from 'react-bootstrap'
@ -25,17 +25,17 @@ export interface HeaderNavLinkProps extends PropsWithDataCypressId {
* @param props Other navigation item props
*/
export const HeaderNavLink: React.FC<PropsWithChildren<HeaderNavLinkProps>> = ({ to, children, ...props }) => {
const { route } = useRouter()
const pathname = usePathname()
const className = useMemo(() => {
return concatCssClasses(
{
[styles.active]: route === to
[styles.active]: pathname === to
},
'nav-link',
styles.link
)
}, [route, to])
}, [pathname, to])
return (
<Nav.Item>

View file

@ -8,7 +8,7 @@ import { clearUser } from '../../../redux/user/methods'
import { cypressId } from '../../../utils/cypress-attribute'
import { UiIcon } from '../../common/icons/ui-icon'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
import { useRouter } from 'next/router'
import { useRouter } from 'next/navigation'
import React, { useCallback } from 'react'
import { Dropdown } from 'react-bootstrap'
import { BoxArrowRight as IconBoxArrowRight } from 'react-bootstrap-icons'

View file

@ -3,24 +3,23 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useFrontendConfig } from '../../../../../common/frontend-config-context/use-frontend-config'
import { ShowIf } from '../../../../../common/show-if/show-if'
import { DropdownHeader } from '../dropdown-header'
import { TranslatedDropdownItem } from '../translated-dropdown-item'
import React, { Fragment, useMemo } from 'react'
import type { ReactElement } from 'react'
import React, { Fragment } from 'react'
import { Dropdown } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useFrontendConfig } from '../../../../../common/frontend-config-context/use-frontend-config'
/**
* Renders the legal submenu for the help dropdown.
*/
export const LegalSubmenu: React.FC = () => {
export const LegalSubmenu: React.FC = (): null | ReactElement => {
useTranslation()
const specialUrls = useFrontendConfig().specialUrls
const linksConfigured = useMemo(
() => specialUrls.privacy || specialUrls.termsOfUse || specialUrls.imprint,
[specialUrls]
)
const linksConfigured = specialUrls?.privacy || specialUrls?.termsOfUse || specialUrls?.imprint
if (!linksConfigured) {
return null

View file

@ -1,23 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useAppTitle } from '../../hooks/common/use-app-title'
import { FavIcon } from './fav-icon'
import Head from 'next/head'
import React from 'react'
/**
* Sets basic browser meta tags.
*/
export const BaseHead: React.FC = () => {
const appTitle = useAppTitle()
return (
<Head>
<title>{appTitle}</title>
<FavIcon />
<meta content='width=device-width, initial-scale=1' name='viewport' />
</Head>
)
}

View file

@ -0,0 +1,14 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplyDarkModeStyle } from './use-apply-dark-mode-style'
import type React from 'react'
export const DarkMode: React.FC = () => {
useApplyDarkModeStyle()
return null
}

View file

@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useDarkModeState } from './use-dark-mode-state'
import { useDarkModeState } from '../../../hooks/dark-mode/use-dark-mode-state'
import { useEffect } from 'react'
/**

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { headers } from 'next/headers'
import type { PropsWithChildren } from 'react'
import React from 'react'
export interface ExpectedOriginBoundaryProps extends PropsWithChildren {
expectedOrigin: string
}
export const buildOriginFromHeaders = (): string | undefined => {
const headers1 = headers()
const host = headers1.get('x-forwarded-host') ?? headers1.get('host')
if (host === null) {
return undefined
}
const protocol = headers1.get('x-forwarded-proto')?.split(',')[0] ?? 'http'
return `${protocol}://${host}`
}
export const ExpectedOriginBoundary: React.FC<ExpectedOriginBoundaryProps> = ({ children, expectedOrigin }) => {
const currentOrigin = buildOriginFromHeaders()
if (new URL(expectedOrigin).origin !== currentOrigin) {
return (
<span
className={
'text-white bg-dark'
}>{`You can't open this page using this URL. For this endpoint "${expectedOrigin}" is expected.`}</span>
)
}
return children
}

View file

@ -1,28 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment } from 'react'
/**
* Sets meta tags for the favicon.
*/
export const FavIcon: React.FC = () => {
return (
<Fragment>
<link href='/icons/apple-touch-icon.png' rel='apple-touch-icon' sizes='180x180' />
<link href='/icons/favicon-32x32.png' rel='icon' sizes='32x32' type='image/png' />
<link href='/icons/favicon-16x16.png' rel='icon' sizes='16x16' type='image/png' />
<link href='/icons/site.webmanifest' rel='manifest' />
<link href='/icons/favicon.ico' rel='shortcut icon' />
<link color='#b51f08' href='/icons/safari-pinned-tab.svg' rel='mask-icon' />
<meta name='apple-mobile-web-app-title' content='HedgeDoc' />
<meta name='application-name' content='HedgeDoc' />
<meta name='msapplication-TileColor' content='#b51f08' />
<meta name='theme-color' content='#b51f08' />
<meta content='/icons/browserconfig.xml' name='msapplication-config' />
<meta content='HedgeDoc - Collaborative markdown notes' name='description' />
</Fragment>
)
}

View file

@ -28,7 +28,7 @@ export interface OneClickMetadata {
}
const getBackendAuthUrl = (providerIdentifer: string): string => {
return `auth/${providerIdentifer}`
return `/auth/${providerIdentifer}`
}
const logger = new Logger('GetOneClickProviderMetadata')

View file

@ -1,3 +1,4 @@
'use client'
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*

View file

@ -3,9 +3,9 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplyDarkModeStyle } from '../../../../hooks/dark-mode/use-apply-dark-mode-style'
import { cypressId } from '../../../../utils/cypress-attribute'
import type { ScrollProps } from '../../../editor-page/synced-scroll/scroll-props'
import { useApplyDarkModeStyle } from '../../../layout/dark-mode/use-apply-dark-mode-style'
import type { LineMarkers } from '../../../markdown-renderer/extensions/linemarker/add-line-marker-markdown-it-plugin'
import { LinemarkerMarkdownExtension } from '../../../markdown-renderer/extensions/linemarker/linemarker-markdown-extension'
import { useCalculateLineMarkerPosition } from '../../../markdown-renderer/hooks/use-calculate-line-marker-positions'

View file

@ -3,8 +3,8 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplyDarkModeStyle } from '../../../../hooks/dark-mode/use-apply-dark-mode-style'
import { cypressId } from '../../../../utils/cypress-attribute'
import { useApplyDarkModeStyle } from '../../../layout/dark-mode/use-apply-dark-mode-style'
import { useMarkdownExtensions } from '../../../markdown-renderer/hooks/use-markdown-extensions'
import { MarkdownToReact } from '../../../markdown-renderer/markdown-to-react/markdown-to-react'
import { useOnHeightChange } from '../../hooks/use-on-height-change'

View file

@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isMockMode } from '../utils/test-modes'
import { isMockMode, isTestMode } from '../utils/test-modes'
import type { NextApiRequest, NextApiResponse } from 'next'
export enum HttpMethod {
@ -22,6 +22,7 @@ export enum HttpMethod {
* @param res The response object.
* @param response The response data that will be returned when the HTTP method was the expected one.
* @param statusCode The status code with which the response will be sent.
* @param respondMethodNotAllowedOnMismatch If set and the method can't process the request then a 405 will be returned. Used for chaining multiple calls together.
* @return {@link true} if the HTTP method of the request is the expected one, {@link false} otherwise.
*/
export const respondToMatchingRequest = <T>(
@ -29,17 +30,42 @@ export const respondToMatchingRequest = <T>(
req: NextApiRequest,
res: NextApiResponse,
response: T,
statusCode = 200
statusCode = 200,
respondMethodNotAllowedOnMismatch = true
): boolean => {
if (!isMockMode) {
res.status(404).send('Mock API is disabled')
return false
}
if (method !== req.method) {
res.status(405).send('Method not allowed')
return false
} else {
} else if (method === req.method) {
res.status(statusCode).json(response)
return true
} else if (respondMethodNotAllowedOnMismatch) {
res.status(405).send('Method not allowed')
return true
} else {
return false
}
}
/**
* Intercepts a mock HTTP request that is only allowed in test mode.
* Such requests can only be issued from localhost and only if mock API is activated.
*
* @param req The request object.
* @param res The response object.
* @param response The response data that will be returned when the HTTP method was the expected one.
*/
export const respondToTestRequest = <T>(req: NextApiRequest, res: NextApiResponse, response: () => T): boolean => {
if (!isMockMode) {
res.status(404).send('Mock API is disabled')
} else if (req.method !== HttpMethod.POST) {
res.status(405).send('Method not allowed')
} else if (!isTestMode) {
res.status(404).send('Route only available in test mode')
} else if (req.socket.remoteAddress !== '127.0.0.1' && req.socket.remoteAddress !== '::1') {
res.status(403).send('Request must come from localhost')
} else {
res.status(200).json(response())
}
return true
}

View file

@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useRouter } from 'next/router'
import { useSearchParams } from 'next/navigation'
import { useMemo } from 'react'
/**
@ -13,10 +13,9 @@ import { useMemo } from 'react'
* @return An array of values extracted from the router.
*/
export const useArrayStringUrlParameter = (parameter: string): string[] => {
const router = useRouter()
const router = useSearchParams()
return useMemo(() => {
const value = router.query[parameter]
return (typeof value === 'string' ? [value] : value) ?? []
}, [parameter, router.query])
return router?.getAll(parameter) ?? []
}, [parameter, router])
}

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { baseUrlContext } from '../../components/common/base-url/base-url-context-provider'
import { useRouter } from 'next/router'
import { usePathname } from 'next/navigation'
import { useContext, useMemo } from 'react'
export enum ORIGIN {
@ -22,11 +22,11 @@ export const useBaseUrl = (origin = ORIGIN.CURRENT_PAGE): string => {
throw new Error('No base url context received. Did you forget to use the provider component?')
}
const router = useRouter()
const route = usePathname()
return useMemo(() => {
return (router.route === '/render' && origin === ORIGIN.CURRENT_PAGE) || origin === ORIGIN.RENDERER
return (route === '/render' && origin === ORIGIN.CURRENT_PAGE) || origin === ORIGIN.RENDERER
? baseUrls.renderer
: baseUrls.editor
}, [origin, baseUrls.renderer, baseUrls.editor, router.route])
}, [origin, baseUrls.renderer, baseUrls.editor, route])
}

View file

@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useRouter } from 'next/router'
import { useSearchParams } from 'next/navigation'
import { useMemo } from 'react'
/**
@ -14,10 +14,9 @@ import { useMemo } from 'react'
* @return A value extracted from the router.
*/
export const useSingleStringUrlParameter = <T>(parameter: string, fallback: T): string | T => {
const router = useRouter()
const router = useSearchParams()
return useMemo(() => {
const value = router.query[parameter]
return (typeof value === 'string' ? value : value?.[0]) ?? fallback
}, [fallback, parameter, router.query])
return router?.get(parameter) ?? fallback
}, [fallback, parameter, router])
}

View file

@ -1,40 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getNote } from '../api/notes'
import { Redirect } from '../components/common/redirect'
import { useSingleStringUrlParameter } from '../hooks/common/use-single-string-url-parameter'
import Custom404 from './404'
import type { NextPage } from 'next'
import React from 'react'
import { useAsync } from 'react-use'
/**
* Redirects the user to the editor if the link is a root level direct link to a version 1 note.
*/
export const DirectLinkFallback: NextPage = () => {
const id = useSingleStringUrlParameter('id', undefined)
const { error, value } = useAsync(async () => {
if (id === undefined) {
throw new Error('No note id found in path')
}
const noteData = await getNote(id)
if (noteData.metadata.version !== 1) {
throw new Error('Note is not a version 1 note')
}
return id
})
if (error !== undefined) {
return <Custom404 />
} else if (value !== undefined) {
return <Redirect to={`/n/${value}`} />
} else {
return <span>Loading</span>
}
}
export default DirectLinkFallback

View file

@ -1,76 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import '../../global-styles/index.scss'
import type { FrontendConfig } from '../api/config/types'
import { ApplicationLoader } from '../components/application-loader/application-loader'
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'
import { StoreProvider } from '../redux/store-provider'
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'
configureLuxon()
interface AppPageProps {
baseUrls: BaseUrls | undefined
frontendConfig: FrontendConfig | undefined
currentOrigin: string | undefined
}
/**
* The actual hedgedoc next js app.
* Provides necessary wrapper components to every page.
*/
function HedgeDocApp({ Component, pageProps }: AppProps<AppPageProps>) {
return (
<BaseUrlContextProvider baseUrls={pageProps.baseUrls}>
<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 = 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
}
}
}
// noinspection JSUnusedGlobalSymbols
export default HedgeDocApp

View file

@ -5,72 +5,89 @@
*/
import type { FrontendConfig } from '../../../api/config/types'
import { AuthProviderType } from '../../../api/config/types'
import { HttpMethod, respondToMatchingRequest } from '../../../handler-utils/respond-to-matching-request'
import {
HttpMethod,
respondToMatchingRequest,
respondToTestRequest
} from '../../../handler-utils/respond-to-matching-request'
import { isTestMode } from '../../../utils/test-modes'
import type { NextApiRequest, NextApiResponse } from 'next'
const initialConfig: FrontendConfig = {
allowAnonymous: true,
allowRegister: true,
branding: {
name: 'DEMO Corp',
logo: '/public/img/demo.png'
},
useImageProxy: false,
specialUrls: {
privacy: 'https://example.com/privacy',
termsOfUse: 'https://example.com/termsOfUse',
imprint: 'https://example.com/imprint'
},
version: {
major: isTestMode ? 0 : 2,
minor: 0,
patch: 0,
preRelease: isTestMode ? undefined : '',
commit: 'mock'
},
plantumlServer: isTestMode ? 'http://mock-plantuml.local' : 'https://www.plantuml.com/plantuml',
maxDocumentLength: isTestMode ? 200 : 1000000,
authProviders: [
{
type: AuthProviderType.LOCAL
},
{
type: AuthProviderType.FACEBOOK
},
{
type: AuthProviderType.GITHUB
},
{
type: AuthProviderType.TWITTER
},
{
type: AuthProviderType.DROPBOX
},
{
type: AuthProviderType.GOOGLE
},
{
type: AuthProviderType.LDAP,
identifier: 'test-ldap',
providerName: 'Test LDAP'
},
{
type: AuthProviderType.GITLAB,
identifier: 'test-gitlab',
providerName: 'Test GitLab'
},
{
type: AuthProviderType.OAUTH2,
identifier: 'test-oauth2',
providerName: 'Test OAuth2'
},
{
type: AuthProviderType.SAML,
identifier: 'test-saml',
providerName: 'Test SAML'
}
]
}
let currentConfig: FrontendConfig = initialConfig
const handler = (req: NextApiRequest, res: NextApiResponse) => {
respondToMatchingRequest<FrontendConfig>(HttpMethod.GET, req, res, {
allowAnonymous: true,
allowRegister: true,
authProviders: [
{
type: AuthProviderType.LOCAL
},
{
type: AuthProviderType.LDAP,
identifier: 'test-ldap',
providerName: 'Test LDAP'
},
{
type: AuthProviderType.DROPBOX
},
{
type: AuthProviderType.FACEBOOK
},
{
type: AuthProviderType.GITHUB
},
{
type: AuthProviderType.GITLAB,
identifier: 'test-gitlab',
providerName: 'Test GitLab'
},
{
type: AuthProviderType.GOOGLE
},
{
type: AuthProviderType.OAUTH2,
identifier: 'test-oauth2',
providerName: 'Test OAuth2'
},
{
type: AuthProviderType.SAML,
identifier: 'test-saml',
providerName: 'Test SAML'
},
{
type: AuthProviderType.TWITTER
respondToMatchingRequest<FrontendConfig>(HttpMethod.GET, req, res, currentConfig, 200, false) ||
respondToTestRequest<FrontendConfig>(req, res, () => {
currentConfig = {
...initialConfig,
...(req.body as FrontendConfig)
}
],
branding: {
name: 'DEMO Corp',
logo: '/public/img/demo.png'
},
useImageProxy: false,
specialUrls: {
privacy: 'https://example.com/privacy',
termsOfUse: 'https://example.com/termsOfUse',
imprint: 'https://example.com/imprint'
},
version: {
major: 2,
minor: 0,
patch: 0,
commit: 'mock'
},
plantumlServer: 'https://www.plantuml.com/plantuml',
maxDocumentLength: 1000000
})
return currentConfig
})
}
export default handler

View file

@ -1,31 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { NoteLoadingBoundary } from '../../components/common/note-loading-boundary/note-loading-boundary'
import { EditorPageContent } from '../../components/editor-page/editor-page-content'
import { EditorToRendererCommunicatorContextProvider } from '../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
import { ResetRealtimeStateBoundary } from '../../components/editor-page/reset-realtime-state-boundary'
import { useApplyDarkModeStyle } from '../../hooks/dark-mode/use-apply-dark-mode-style'
import type { NextPage } from 'next'
import React from 'react'
/**
* Renders a page that is used by the user to edit markdown notes. It contains the editor and a renderer.
*/
export const EditorPage: NextPage = () => {
useApplyDarkModeStyle()
return (
<ResetRealtimeStateBoundary>
<NoteLoadingBoundary>
<EditorToRendererCommunicatorContextProvider>
<EditorPageContent />
</EditorToRendererCommunicatorContextProvider>
</NoteLoadingBoundary>
</ResetRealtimeStateBoundary>
)
}
export default EditorPage

View file

@ -1,26 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { NoteLoadingBoundary } from '../../components/common/note-loading-boundary/note-loading-boundary'
import { HeadMetaProperties } from '../../components/editor-page/head-meta-properties/head-meta-properties'
import { EditorToRendererCommunicatorContextProvider } from '../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
import { SlideShowPageContent } from '../../components/slide-show-page/slide-show-page-content'
import React from 'react'
/**
* Renders a page that is used by the user to hold a presentation. It contains the renderer for the presentation.
*/
export const SlideShowPage: React.FC = () => {
return (
<NoteLoadingBoundary>
<HeadMetaProperties />
<EditorToRendererCommunicatorContextProvider>
<SlideShowPageContent />
</EditorToRendererCommunicatorContextProvider>
</NoteLoadingBoundary>
)
}
export default SlideShowPage

View file

@ -1,37 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { NoteLoadingBoundary } from '../../components/common/note-loading-boundary/note-loading-boundary'
import { DocumentReadOnlyPageContent } from '../../components/document-read-only-page/document-read-only-page-content'
import { HeadMetaProperties } from '../../components/editor-page/head-meta-properties/head-meta-properties'
import { EditorToRendererCommunicatorContextProvider } from '../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
import { MotdModal } from '../../components/global-dialogs/motd-modal/motd-modal'
import { BaseAppBar } from '../../components/layout/app-bar/base-app-bar'
import { useApplyDarkModeStyle } from '../../hooks/dark-mode/use-apply-dark-mode-style'
import { useSaveDarkModePreferenceToLocalStorage } from '../../hooks/dark-mode/use-save-dark-mode-preference-to-local-storage'
import React from 'react'
/**
* Renders a page that contains only the rendered document without an editor or realtime updates.
*/
export const DocumentReadOnlyPage: React.FC = () => {
useApplyDarkModeStyle()
useSaveDarkModePreferenceToLocalStorage()
return (
<EditorToRendererCommunicatorContextProvider>
<NoteLoadingBoundary>
<HeadMetaProperties />
<MotdModal />
<div className={'d-flex flex-column mvh-100'}>
<BaseAppBar />
<DocumentReadOnlyPageContent />
</div>
</NoteLoadingBoundary>
</EditorToRendererCommunicatorContextProvider>
)
}
export default DocumentReadOnlyPage

View file

@ -1,3 +1,4 @@
'use client'
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*

View file

@ -9,10 +9,9 @@ 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 baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
const result = baseUrlFromEnvExtractor.extractBaseUrls()
expect(result.isPresent()).toBeTruthy()
expect(result.get()).toStrictEqual({
const sut = new BaseUrlFromEnvExtractor()
expect(sut.extractBaseUrls()).toStrictEqual({
renderer: 'https://renderer.example.org/',
editor: 'https://editor.example.org/'
})
@ -21,31 +20,33 @@ describe('BaseUrlFromEnvExtractor', () => {
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 baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
expect(baseUrlFromEnvExtractor.extractBaseUrls().isEmpty()).toBeTruthy()
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 baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
expect(baseUrlFromEnvExtractor.extractBaseUrls().isEmpty()).toBeTruthy()
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 baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
expect(baseUrlFromEnvExtractor.extractBaseUrls().isEmpty()).toBeTruthy()
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 baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
const result = baseUrlFromEnvExtractor.extractBaseUrls()
expect(result.isPresent()).toBeTruthy()
expect(result.get()).toStrictEqual({
const sut = new BaseUrlFromEnvExtractor()
expect(sut.extractBaseUrls()).toStrictEqual({
renderer: 'https://renderer.example.org/',
editor: 'https://editor.example.org/'
})
@ -54,10 +55,9 @@ describe('BaseUrlFromEnvExtractor', () => {
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 baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
const result = baseUrlFromEnvExtractor.extractBaseUrls()
expect(result.isPresent()).toBeTruthy()
expect(result.get()).toStrictEqual({
const sut = new BaseUrlFromEnvExtractor()
expect(sut.extractBaseUrls()).toStrictEqual({
renderer: 'https://renderer.example.org/',
editor: 'https://editor.example.org/'
})
@ -66,10 +66,9 @@ describe('BaseUrlFromEnvExtractor', () => {
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 baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor()
const result = baseUrlFromEnvExtractor.extractBaseUrls()
expect(result.isPresent()).toBeTruthy()
expect(result.get()).toStrictEqual({
const sut = new BaseUrlFromEnvExtractor()
expect(sut.extractBaseUrls()).toStrictEqual({
renderer: 'https://editor.example.org/',
editor: 'https://editor.example.org/'
})

View file

@ -5,15 +5,15 @@
*/
import type { BaseUrls } from '../components/common/base-url/base-url-context-provider'
import { Logger } from './logger'
import { isTestMode } from './test-modes'
import { isTestMode, isBuildTime } from './test-modes'
import { NoSubdirectoryAllowedError, parseUrl } from '@hedgedoc/commons'
import { Optional } from '@mrdrogdrog/optional'
/**
* Extracts the editor and renderer base urls from the environment variables.
* Extracts and caches the editor and renderer base urls from the environment variables.
*/
export class BaseUrlFromEnvExtractor {
private baseUrls: Optional<BaseUrls> | undefined
private baseUrls: BaseUrls | undefined
private readonly logger = new Logger('Base URL Configuration')
private extractUrlFromEnvVar(envVarName: string, envVarValue: string | undefined): Optional<URL> {
@ -51,23 +51,17 @@ export class BaseUrlFromEnvExtractor {
return this.extractUrlFromEnvVar('HD_RENDERER_BASE_URL', process.env.HD_RENDERER_BASE_URL)
}
private renewBaseUrls(): void {
this.baseUrls = this.extractEditorBaseUrlFromEnv().flatMap((editorBaseUrl) =>
this.extractRendererBaseUrlFromEnv(editorBaseUrl).map((rendererBaseUrl) => {
return {
editor: editorBaseUrl.toString(),
renderer: rendererBaseUrl.toString()
}
})
)
this.baseUrls.ifPresent((urls) => {
this.logger.info('Editor base URL', urls.editor.toString())
this.logger.info('Renderer base URL', urls.renderer.toString())
})
}
private isEnvironmentExtractDone(): boolean {
return this.baseUrls !== undefined
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'))
}
/**
@ -75,10 +69,28 @@ export class BaseUrlFromEnvExtractor {
*
* @return An {@link Optional} with the base urls.
*/
public extractBaseUrls(): Optional<BaseUrls> {
if (!this.isEnvironmentExtractDone()) {
this.renewBaseUrls()
public extractBaseUrls(): BaseUrls {
if (isBuildTime) {
return {
editor: 'https://example.org/',
renderer: 'https://example.org/'
}
}
return Optional.ofNullable(this.baseUrls).flatMap((value) => value)
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()

View file

@ -1,151 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { determineCurrentOrigin } from './determine-current-origin'
import * as IsClientSideRenderingModule from './is-client-side-rendering'
import type { NextPageContext } from 'next'
import { Mock } from 'ts-mockery'
jest.mock('./is-client-side-rendering')
describe('determineCurrentOrigin', () => {
describe('client side', () => {
it('parses a client side origin correctly', () => {
jest.spyOn(IsClientSideRenderingModule, 'isClientSideRendering').mockImplementation(() => true)
const expectedOrigin = 'expectedOrigin'
Object.defineProperty(window, 'location', { value: { origin: expectedOrigin } })
expect(determineCurrentOrigin(Mock.of<NextPageContext>({}))).toBe(expectedOrigin)
})
})
describe('server side', () => {
beforeEach(() => {
jest.spyOn(IsClientSideRenderingModule, 'isClientSideRendering').mockImplementation(() => false)
})
it("won't return an origin if no request is present", () => {
expect(determineCurrentOrigin(Mock.of<NextPageContext>({}))).toBeUndefined()
})
it("won't return an origin if no headers are present", () => {
expect(determineCurrentOrigin(Mock.of<NextPageContext>({ req: { headers: undefined } }))).toBeUndefined()
})
it("won't return an origin if no host is present", () => {
expect(
determineCurrentOrigin(
Mock.of<NextPageContext>({
req: {
headers: {}
}
})
)
).toBeUndefined()
})
it('will return an origin for a forwarded host', () => {
expect(
determineCurrentOrigin(
Mock.of<NextPageContext>({
req: {
headers: {
'x-forwarded-host': 'forwardedMockHost',
'x-forwarded-proto': 'mockProtocol'
}
}
})
)
).toBe('mockProtocol://forwardedMockHost')
})
it("will fallback to host header if x-forwarded-host isn't present", () => {
expect(
determineCurrentOrigin(
Mock.of<NextPageContext>({
req: {
headers: {
host: 'mockHost',
'x-forwarded-proto': 'mockProtocol'
}
}
})
)
).toBe('mockProtocol://mockHost')
})
it('will prefer x-forwarded-host over host', () => {
expect(
determineCurrentOrigin(
Mock.of<NextPageContext>({
req: {
headers: {
'x-forwarded-host': 'forwardedMockHost',
host: 'mockHost',
'x-forwarded-proto': 'mockProtocol'
}
}
})
)
).toBe('mockProtocol://forwardedMockHost')
})
it('will fallback to http if x-forwarded-proto is missing', () => {
expect(
determineCurrentOrigin(
Mock.of<NextPageContext>({
req: {
headers: {
'x-forwarded-host': 'forwardedMockHost'
}
}
})
)
).toBe('http://forwardedMockHost')
})
it('will use the first header if x-forwarded-proto is defined multiple times', () => {
expect(
determineCurrentOrigin(
Mock.of<NextPageContext>({
req: {
headers: {
'x-forwarded-proto': ['mockProtocol1', 'mockProtocol2'],
'x-forwarded-host': 'forwardedMockHost'
}
}
})
)
).toBe('mockProtocol1://forwardedMockHost')
})
it('will use the first header if x-forwarded-host is defined multiple times', () => {
expect(
determineCurrentOrigin(
Mock.of<NextPageContext>({
req: {
headers: {
'x-forwarded-host': ['forwardedMockHost1', 'forwardedMockHost2']
}
}
})
)
).toBe('http://forwardedMockHost1')
})
it('will use the first value if x-forwarded-proto is a comma separated list', () => {
expect(
determineCurrentOrigin(
Mock.of<NextPageContext>({
req: {
headers: {
'x-forwarded-proto': 'mockProtocol1,mockProtocol2',
'x-forwarded-host': 'forwardedMockHost'
}
}
})
)
).toBe('mockProtocol1://forwardedMockHost')
})
})
})

Some files were not shown because too many files have changed in this diff Show more