diff --git a/frontend/build.sh b/frontend/build.sh index 715ee30b2..fe086eaf4 100755 --- a/frontend/build.sh +++ b/frontend/build.sh @@ -19,7 +19,7 @@ else fi echo "🦔 > Building" -next build +BUILD_TIME=true next build echo "🦔 > Bundling" mv .next/standalone dist diff --git a/frontend/cypress/e2e/note-meta-head.spec.ts b/frontend/cypress/e2e/note-meta-head.spec.ts deleted file mode 100644 index e79e1a365..000000000 --- a/frontend/cypress/e2e/note-meta-head.spec.ts +++ /dev/null @@ -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') - }) -}) diff --git a/frontend/cypress/support/config.ts b/frontend/cypress/support/config.ts index b9647c834..32435ae81 100644 --- a/frontend/cypress/support/config.ts +++ b/frontend/cypress/support/config.ts @@ -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) => { - return cy.intercept('/api/private/config', { - statusCode: 200, - body: { - ...config, - ...additionalConfig - } - }) + return cy.request(HttpMethod.POST, '/api/private/config', { ...config, ...additionalConfig }) }) beforeEach(() => { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index b9b83597a..ca8c23bcd 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1,7 +1,6 @@ { "app": { "slogan": "Ideas grow better together", - "title": "Collaborative markdown notes", "icon": "HedgeDoc logo with text" }, "notificationTest": { diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 4f11a03dc..fd36f9494 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js index 7a34ca0ad..129bda01c 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -const { isMockMode, isTestMode, isProfilingMode } = 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?$/, diff --git a/frontend/package.json b/frontend/package.json index 174c2f0f0..0978eacdf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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}\"", diff --git a/frontend/public/icons/browserconfig.xml b/frontend/public/icons/browserconfig.xml deleted file mode 100644 index be3e03846..000000000 --- a/frontend/public/icons/browserconfig.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - #b51f08 - - - diff --git a/frontend/public/icons/favicon-16x16.png b/frontend/public/icons/favicon-16x16.png deleted file mode 100644 index 695b8aaa0..000000000 Binary files a/frontend/public/icons/favicon-16x16.png and /dev/null differ diff --git a/frontend/public/icons/favicon-32x32.png b/frontend/public/icons/favicon-32x32.png deleted file mode 100644 index 80afec65c..000000000 Binary files a/frontend/public/icons/favicon-32x32.png and /dev/null differ diff --git a/frontend/public/icons/mstile-144x144.png b/frontend/public/icons/mstile-144x144.png deleted file mode 100644 index fa2152abd..000000000 Binary files a/frontend/public/icons/mstile-144x144.png and /dev/null differ diff --git a/frontend/public/icons/mstile-150x150.png b/frontend/public/icons/mstile-150x150.png deleted file mode 100644 index 5b277a2cb..000000000 Binary files a/frontend/public/icons/mstile-150x150.png and /dev/null differ diff --git a/frontend/public/icons/mstile-310x150.png b/frontend/public/icons/mstile-310x150.png deleted file mode 100644 index ab952960c..000000000 Binary files a/frontend/public/icons/mstile-310x150.png and /dev/null differ diff --git a/frontend/public/icons/mstile-310x310.png b/frontend/public/icons/mstile-310x310.png deleted file mode 100644 index a43ae7cca..000000000 Binary files a/frontend/public/icons/mstile-310x310.png and /dev/null differ diff --git a/frontend/public/icons/mstile-70x70.png b/frontend/public/icons/mstile-70x70.png deleted file mode 100644 index ce4b9fa96..000000000 Binary files a/frontend/public/icons/mstile-70x70.png and /dev/null differ diff --git a/frontend/public/icons/safari-pinned-tab.svg b/frontend/public/icons/safari-pinned-tab.svg index aedf0d299..d67904f24 100644 --- a/frontend/public/icons/safari-pinned-tab.svg +++ b/frontend/public/icons/safari-pinned-tab.svg @@ -1 +1,7 @@ + + diff --git a/frontend/src/api/config/index.ts b/frontend/src/api/config/index.ts index 32df13eef..6fc0a3e47 100644 --- a/frontend/src/api/config/index.ts +++ b/frontend/src/api/config/index.ts @@ -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 => { +export const getConfig = async (baseUrl?: string): Promise => { + if (isBuildTime) { + return undefined + } const response = await new GetApiRequestBuilder('config', baseUrl).sendRequest() return response.asParsedJsonObject() } diff --git a/frontend/src/api/notes/index.ts b/frontend/src/api/notes/index.ts index 6684005b1..92a44e97f 100644 --- a/frontend/src/api/notes/index.ts +++ b/frontend/src/api/notes/index.ts @@ -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 => { - const response = await new GetApiRequestBuilder('notes/' + noteIdOrAlias).sendRequest() +export const getNote = async (noteIdOrAlias: string, baseUrl?: string): Promise => { + const response = await new GetApiRequestBuilder('notes/' + noteIdOrAlias, baseUrl).sendRequest() return response.asParsedJsonObject() } diff --git a/frontend/src/app/(editor)/[id]/page.tsx b/frontend/src/app/(editor)/[id]/page.tsx new file mode 100644 index 000000000..29422d35e --- /dev/null +++ b/frontend/src/app/(editor)/[id]/page.tsx @@ -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 diff --git a/frontend/src/pages/cheatsheet.tsx b/frontend/src/app/(editor)/cheatsheet/page.tsx similarity index 58% rename from frontend/src/pages/cheatsheet.tsx rename to frontend/src/app/(editor)/cheatsheet/page.tsx index 96ee0a091..3d3713389 100644 --- a/frontend/src/pages/cheatsheet.tsx +++ b/frontend/src/app/(editor)/cheatsheet/page.tsx @@ -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 ( - + ) } diff --git a/frontend/src/app/(editor)/global-error.tsx b/frontend/src/app/(editor)/global-error.tsx new file mode 100644 index 000000000..dd8ee431f --- /dev/null +++ b/frontend/src/app/(editor)/global-error.tsx @@ -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 ( + + + +
+

An unknown error occurred

+

+ Don't worry, this happens sometimes. If this is the first time you see this page then try reloading + the app. +

+ If you can reproduce this error, then we would be glad if you{' '} + or{' '} + + +
+
+ + + ) +} diff --git a/frontend/src/pages/history.tsx b/frontend/src/app/(editor)/history/page.tsx similarity index 59% rename from frontend/src/pages/history.tsx rename to frontend/src/app/(editor)/history/page.tsx index 502ba6903..7305fc41c 100644 --- a/frontend/src/pages/history.tsx +++ b/frontend/src/app/(editor)/history/page.tsx @@ -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' diff --git a/frontend/src/pages/intro.tsx b/frontend/src/app/(editor)/intro/page.tsx similarity index 62% rename from frontend/src/pages/intro.tsx rename to frontend/src/app/(editor)/intro/page.tsx index a8c0957e5..d4834e825 100644 --- a/frontend/src/pages/intro.tsx +++ b/frontend/src/app/(editor)/intro/page.tsx @@ -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' diff --git a/frontend/src/app/(editor)/layout.tsx b/frontend/src/app/(editor)/layout.tsx new file mode 100644 index 000000000..e9822556c --- /dev/null +++ b/frontend/src/app/(editor)/layout.tsx @@ -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 ( + + + + + + + + + + + + + {children} + + + + + + + + ) +} + +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' +} diff --git a/frontend/src/pages/login.tsx b/frontend/src/app/(editor)/login/page.tsx similarity index 72% rename from frontend/src/pages/login.tsx rename to frontend/src/app/(editor)/login/page.tsx index 3fa24b062..670f1b4d9 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/app/(editor)/login/page.tsx @@ -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) diff --git a/frontend/src/app/(editor)/n/[noteId]/page.tsx b/frontend/src/app/(editor)/n/[noteId]/page.tsx new file mode 100644 index 000000000..ef6ee99c1 --- /dev/null +++ b/frontend/src/app/(editor)/n/[noteId]/page.tsx @@ -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 = ({ params }) => { + return ( + + + + + + ) +} + +/* + 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. + + + + + + export async function generateMetadata({ params }: PageParams): Promise { + 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 diff --git a/frontend/src/pages/new.tsx b/frontend/src/app/(editor)/new/page.tsx similarity index 56% rename from frontend/src/pages/new.tsx rename to frontend/src/app/(editor)/new/page.tsx index ece3f6554..474e4db1d 100644 --- a/frontend/src/pages/new.tsx +++ b/frontend/src/app/(editor)/new/page.tsx @@ -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(() => { diff --git a/frontend/src/pages/404.tsx b/frontend/src/app/(editor)/not-found.tsx similarity index 70% rename from frontend/src/pages/404.tsx rename to frontend/src/app/(editor)/not-found.tsx index a2a0745a5..db02b35e8 100644 --- a/frontend/src/pages/404.tsx +++ b/frontend/src/app/(editor)/not-found.tsx @@ -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 } -export default Custom404 +export default NotFound diff --git a/frontend/src/app/(editor)/p/[noteId]/page.tsx b/frontend/src/app/(editor)/p/[noteId]/page.tsx new file mode 100644 index 000000000..a65729dd3 --- /dev/null +++ b/frontend/src/app/(editor)/p/[noteId]/page.tsx @@ -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 = ({ params }) => { + useNoteAndAppTitle() + + return ( + + + + + + ) +} + +export default SlideShowPage diff --git a/frontend/src/pages/profile.tsx b/frontend/src/app/(editor)/profile/page.tsx similarity index 53% rename from frontend/src/pages/profile.tsx rename to frontend/src/app/(editor)/profile/page.tsx index 6d32836fb..428ebb6eb 100644 --- a/frontend/src/pages/profile.tsx +++ b/frontend/src/app/(editor)/profile/page.tsx @@ -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) { diff --git a/frontend/src/pages/register.tsx b/frontend/src/app/(editor)/register/page.tsx similarity index 72% rename from frontend/src/pages/register.tsx rename to frontend/src/app/(editor)/register/page.tsx index ce6f988e2..41cd3f92e 100644 --- a/frontend/src/pages/register.tsx +++ b/frontend/src/app/(editor)/register/page.tsx @@ -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 diff --git a/frontend/src/app/(editor)/s/[noteId]/page.tsx b/frontend/src/app/(editor)/s/[noteId]/page.tsx new file mode 100644 index 000000000..bcd6702d7 --- /dev/null +++ b/frontend/src/app/(editor)/s/[noteId]/page.tsx @@ -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 = ({ params }) => { + useNoteAndAppTitle() + + return ( + + +
+ + +
+
+
+ ) +} + +export default DocumentReadOnlyPage diff --git a/frontend/src/app/(render)/global-error.tsx b/frontend/src/app/(render)/global-error.tsx new file mode 100644 index 000000000..4a1dfec11 --- /dev/null +++ b/frontend/src/app/(render)/global-error.tsx @@ -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 ( + + + +
+

An unknown error occurred

+

+ Don't worry, this happens sometimes. If this is the first time you see this page then try reloading + the app. +

+ If you can reproduce this error, then we would be glad if you + + or + +
+
+ + + ) +} diff --git a/frontend/src/app/(render)/layout.tsx b/frontend/src/app/(render)/layout.tsx new file mode 100644 index 000000000..8afb48915 --- /dev/null +++ b/frontend/src/app/(render)/layout.tsx @@ -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 ( + + + + + + + {children} + + + + + + + ) +} diff --git a/frontend/src/pages/render.tsx b/frontend/src/app/(render)/render/page.tsx similarity index 69% rename from frontend/src/pages/render.tsx rename to frontend/src/app/(render)/render/page.tsx index 5fc8d313a..2b777b579 100644 --- a/frontend/src/pages/render.tsx +++ b/frontend/src/app/(render)/render/page.tsx @@ -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 ( diff --git a/frontend/public/icons/apple-touch-icon.png b/frontend/src/app/apple-icon.png similarity index 100% rename from frontend/public/icons/apple-touch-icon.png rename to frontend/src/app/apple-icon.png diff --git a/frontend/src/app/apple-icon.png.license b/frontend/src/app/apple-icon.png.license new file mode 100644 index 000000000..d685f690e --- /dev/null +++ b/frontend/src/app/apple-icon.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + +SPDX-License-Identifier: LicenseRef-HedgeDoc-Icon-Usage-Guidelines diff --git a/frontend/public/icons/favicon.ico b/frontend/src/app/favicon.ico similarity index 100% rename from frontend/public/icons/favicon.ico rename to frontend/src/app/favicon.ico diff --git a/frontend/src/app/favicon.ico.license b/frontend/src/app/favicon.ico.license new file mode 100644 index 000000000..d685f690e --- /dev/null +++ b/frontend/src/app/favicon.ico.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + +SPDX-License-Identifier: LicenseRef-HedgeDoc-Icon-Usage-Guidelines diff --git a/frontend/src/app/icon.png b/frontend/src/app/icon.png new file mode 100644 index 000000000..1eedb77af Binary files /dev/null and b/frontend/src/app/icon.png differ diff --git a/frontend/src/app/icon.png.license b/frontend/src/app/icon.png.license new file mode 100644 index 000000000..d685f690e --- /dev/null +++ b/frontend/src/app/icon.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + +SPDX-License-Identifier: LicenseRef-HedgeDoc-Icon-Usage-Guidelines diff --git a/frontend/src/components/application-loader/application-loader.tsx b/frontend/src/components/application-loader/application-loader.tsx index cdc0a993f..3c0062e0c 100644 --- a/frontend/src/components/application-loader/application-loader.tsx +++ b/frontend/src/components/application-loader/application-loader.tsx @@ -1,3 +1,4 @@ +'use client' /* * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * diff --git a/frontend/src/components/application-loader/initializers/load-dark-mode.ts b/frontend/src/components/application-loader/initializers/load-dark-mode.ts index 20f2d9404..e055031b4 100644 --- a/frontend/src/components/application-loader/initializers/load-dark-mode.ts +++ b/frontend/src/components/application-loader/initializers/load-dark-mode.ts @@ -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 => { * {@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') { diff --git a/frontend/src/components/application-loader/initializers/setupI18n.ts b/frontend/src/components/application-loader/initializers/setupI18n.ts index 0d8892315..beb2e78e2 100644 --- a/frontend/src/components/application-loader/initializers/setupI18n.ts +++ b/frontend/src/components/application-loader/initializers/setupI18n.ts @@ -36,6 +36,9 @@ export const setUpI18n = async (): Promise => { } }) - i18n.on('languageChanged', (language) => (Settings.defaultLocale = language)) + i18n.on('languageChanged', (language) => { + Settings.defaultLocale = language + document.documentElement.lang = i18n.language + }) Settings.defaultLocale = i18n.language } diff --git a/frontend/src/components/common/base-url/base-url-context-provider.tsx b/frontend/src/components/common/base-url/base-url-context-provider.tsx index 609e78eb7..aa4d2c926 100644 --- a/frontend/src/components/common/base-url/base-url-context-provider.tsx +++ b/frontend/src/components/common/base-url/base-url-context-provider.tsx @@ -1,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 { - const [baseUrlState] = useState(() => baseUrls) - return baseUrlState === undefined ? ( + return baseUrls === undefined ? ( HedgeDoc is not configured correctly! Please check the server log. ) : ( - {children} + {children} ) } diff --git a/frontend/src/components/common/copyable/copyable-field/copyable-field.tsx b/frontend/src/components/common/copyable/copyable-field/copyable-field.tsx index b6852dd7b..f458b29e0 100644 --- a/frontend/src/components/common/copyable/copyable-field/copyable-field.tsx +++ b/frontend/src/components/common/copyable/copyable-field/copyable-field.tsx @@ -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 = ({ content, shareOrig useTranslation() const sharingSupported = useMemo( - () => shareOriginUrl !== undefined && isClientSideRendering() && typeof navigator.share === 'function', + () => shareOriginUrl !== undefined && typeof navigator.share === 'function', [shareOriginUrl] ) diff --git a/frontend/src/components/common/copyable/hooks/use-copy-overlay.tsx b/frontend/src/components/common/copyable/hooks/use-copy-overlay.tsx index c75b85ee7..9a22fd812 100644 --- a/frontend/src/components/common/copyable/hooks/use-copy-overlay.tsx +++ b/frontend/src/components/common/copyable/hooks/use-copy-overlay.tsx @@ -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 diff --git a/frontend/src/components/common/frontend-config-context/frontend-config-context-provider.tsx b/frontend/src/components/common/frontend-config-context/frontend-config-context-provider.tsx index 09b7de918..6452a3715 100644 --- a/frontend/src/components/common/frontend-config-context/frontend-config-context-provider.tsx +++ b/frontend/src/components/common/frontend-config-context/frontend-config-context-provider.tsx @@ -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 = ({ config, children }) => { - const [configState, setConfigState] = useState(() => 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 ? ( No frontend config received! Please check the server log. ) : ( - {children} + {children} ) } diff --git a/frontend/src/components/common/note-loading-boundary/__snapshots__/create-non-existing-note-hint.spec.tsx.snap b/frontend/src/components/common/note-loading-boundary/__snapshots__/create-non-existing-note-hint.spec.tsx.snap index 6fd3da83b..8ff28ee55 100644 --- a/frontend/src/components/common/note-loading-boundary/__snapshots__/create-non-existing-note-hint.spec.tsx.snap +++ b/frontend/src/components/common/note-loading-boundary/__snapshots__/create-non-existing-note-hint.spec.tsx.snap @@ -38,6 +38,8 @@ exports[`create non existing note hint renders an button as initial state 1`] = `; +exports[`create non existing note hint renders nothing if no note id has been provided 1`] = `
`; + exports[`create non existing note hint shows an error message if note couldn't be created 1`] = `
{ 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( + + ) + 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() + const view = render( + + ) 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() + const view = render( + + ) const button = await screen.findByTestId('createNoteButton') await act(() => { 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() + const view = render( + + ) const button = await screen.findByTestId('createNoteButton') await act(() => { 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() + const view = render( + + ) const button = await screen.findByTestId('createNoteButton') await act(() => { button.click() diff --git a/frontend/src/components/common/note-loading-boundary/create-non-existing-note-hint.tsx b/frontend/src/components/common/note-loading-boundary/create-non-existing-note-hint.tsx index 939a45b9a..991efc2e5 100644 --- a/frontend/src/components/common/note-loading-boundary/create-non-existing-note-hint.tsx +++ b/frontend/src/components/common/note-loading-boundary/create-non-existing-note-hint.tsx @@ -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 = ({ onNoteCreated }) => { +export const CreateNonExistingNoteHint: React.FC = ({ 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 } }, [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 return ( - +
-
- - ) - } else { - return this.props.children - } - } -} diff --git a/frontend/src/components/error-pages/common-error-page.tsx b/frontend/src/components/error-pages/common-error-page.tsx index d345d5a49..d39e5bc70 100644 --- a/frontend/src/components/error-pages/common-error-page.tsx +++ b/frontend/src/components/error-pages/common-error-page.tsx @@ -1,3 +1,4 @@ +'use client' /* * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * diff --git a/frontend/src/components/global-dialogs/motd-modal/motd-modal.tsx b/frontend/src/components/global-dialogs/motd-modal/motd-modal.tsx index 622faabf0..872b9bfb5 100644 --- a/frontend/src/components/global-dialogs/motd-modal/motd-modal.tsx +++ b/frontend/src/components/global-dialogs/motd-modal/motd-modal.tsx @@ -1,3 +1,4 @@ +'use client' /* * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * diff --git a/frontend/src/components/global-dialogs/settings-dialog/settings-button.tsx b/frontend/src/components/global-dialogs/settings-dialog/settings-button.tsx index 53f8540f7..13bd5d09f 100644 --- a/frontend/src/components/global-dialogs/settings-dialog/settings-button.tsx +++ b/frontend/src/components/global-dialogs/settings-dialog/settings-button.tsx @@ -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 export const SettingsButton: React.FC = (props) => { const [show, showModal, hideModal] = useBooleanState(false) const buttonVariant = useOutlineButtonVariant() + useSaveDarkModePreferenceToLocalStorage() return ( diff --git a/frontend/src/components/history-page/history-toolbar/toolbar-context/history-toolbar-state-context-provider.tsx b/frontend/src/components/history-page/history-toolbar/toolbar-context/history-toolbar-state-context-provider.tsx index ad3d54073..df30f0cbb 100644 --- a/frontend/src/components/history-page/history-toolbar/toolbar-context/history-toolbar-state-context-provider.tsx +++ b/frontend/src/components/history-page/history-toolbar/toolbar-context/history-toolbar-state-context-provider.tsx @@ -20,13 +20,13 @@ export const historyToolbarStateContext = createContext> = ({ children }) => { - const urlParameterSearch = useSingleStringUrlParameter('search', '') - const urlParameterSelectedTags = useArrayStringUrlParameter('selectedTags') + const search = useSingleStringUrlParameter('search', '') + const selectedTags = useArrayStringUrlParameter('selectedTags') const stateWithDispatcher = useState(() => ({ viewState: ViewStateEnum.CARD, - search: urlParameterSearch, - selectedTags: urlParameterSelectedTags, + search: search, + selectedTags: selectedTags, titleSortDirection: SortModeEnum.no, lastVisitedSortDirection: SortModeEnum.down })) diff --git a/frontend/src/components/history-page/history-toolbar/toolbar-context/use-sync-toolbar-state-to-url-effect.ts b/frontend/src/components/history-page/history-toolbar/toolbar-context/use-sync-toolbar-state-to-url-effect.ts index c65f66bc5..ba20f6410 100644 --- a/frontend/src/components/history-page/history-toolbar/toolbar-context/use-sync-toolbar-state-to-url-effect.ts +++ b/frontend/src/components/history-page/history-toolbar/toolbar-context/use-sync-toolbar-state-to-url-effect.ts @@ -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]) } diff --git a/frontend/src/components/landing-layout/landing-layout.tsx b/frontend/src/components/landing-layout/landing-layout.tsx index 921f8fb55..d500f9472 100644 --- a/frontend/src/components/landing-layout/landing-layout.tsx +++ b/frontend/src/components/landing-layout/landing-layout.tsx @@ -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 = ({ children }) => { - useApplyDarkModeStyle() - useSaveDarkModePreferenceToLocalStorage() - return (
-
diff --git a/frontend/src/components/landing-layout/navigation/header-bar/header-nav-link.tsx b/frontend/src/components/landing-layout/navigation/header-bar/header-nav-link.tsx index 3c405864e..7281be3a8 100644 --- a/frontend/src/components/landing-layout/navigation/header-bar/header-nav-link.tsx +++ b/frontend/src/components/landing-layout/navigation/header-bar/header-nav-link.tsx @@ -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> = ({ 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 ( diff --git a/frontend/src/components/landing-layout/navigation/sign-out-dropdown-button.tsx b/frontend/src/components/landing-layout/navigation/sign-out-dropdown-button.tsx index e24933709..a85c13523 100644 --- a/frontend/src/components/landing-layout/navigation/sign-out-dropdown-button.tsx +++ b/frontend/src/components/landing-layout/navigation/sign-out-dropdown-button.tsx @@ -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' diff --git a/frontend/src/components/layout/app-bar/app-bar-elements/help-dropdown/submenues/legal-submenu.tsx b/frontend/src/components/layout/app-bar/app-bar-elements/help-dropdown/submenues/legal-submenu.tsx index 3d5b2de41..b9ee33b8b 100644 --- a/frontend/src/components/layout/app-bar/app-bar-elements/help-dropdown/submenues/legal-submenu.tsx +++ b/frontend/src/components/layout/app-bar/app-bar-elements/help-dropdown/submenues/legal-submenu.tsx @@ -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 diff --git a/frontend/src/components/layout/base-head.tsx b/frontend/src/components/layout/base-head.tsx deleted file mode 100644 index 78fe90946..000000000 --- a/frontend/src/components/layout/base-head.tsx +++ /dev/null @@ -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 ( - - {appTitle} - - - - ) -} diff --git a/frontend/src/components/layout/dark-mode/dark-mode.tsx b/frontend/src/components/layout/dark-mode/dark-mode.tsx new file mode 100644 index 000000000..b612d81ba --- /dev/null +++ b/frontend/src/components/layout/dark-mode/dark-mode.tsx @@ -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 +} diff --git a/frontend/src/hooks/dark-mode/use-apply-dark-mode-style.ts b/frontend/src/components/layout/dark-mode/use-apply-dark-mode-style.ts similarity index 88% rename from frontend/src/hooks/dark-mode/use-apply-dark-mode-style.ts rename to frontend/src/components/layout/dark-mode/use-apply-dark-mode-style.ts index ec5861e24..6baba3356 100644 --- a/frontend/src/hooks/dark-mode/use-apply-dark-mode-style.ts +++ b/frontend/src/components/layout/dark-mode/use-apply-dark-mode-style.ts @@ -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' /** diff --git a/frontend/src/components/layout/expected-origin-boundary.tsx b/frontend/src/components/layout/expected-origin-boundary.tsx new file mode 100644 index 000000000..e440ab47e --- /dev/null +++ b/frontend/src/components/layout/expected-origin-boundary.tsx @@ -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 = ({ children, expectedOrigin }) => { + const currentOrigin = buildOriginFromHeaders() + + if (new URL(expectedOrigin).origin !== currentOrigin) { + return ( + {`You can't open this page using this URL. For this endpoint "${expectedOrigin}" is expected.`} + ) + } + return children +} diff --git a/frontend/src/components/layout/fav-icon.tsx b/frontend/src/components/layout/fav-icon.tsx deleted file mode 100644 index e8e7fc2e4..000000000 --- a/frontend/src/components/layout/fav-icon.tsx +++ /dev/null @@ -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 ( - - - - - - - - - - - - - - - ) -} diff --git a/frontend/src/components/login-page/auth/utils/get-one-click-provider-metadata.ts b/frontend/src/components/login-page/auth/utils/get-one-click-provider-metadata.ts index 14bc8d3a3..08335f3c8 100644 --- a/frontend/src/components/login-page/auth/utils/get-one-click-provider-metadata.ts +++ b/frontend/src/components/login-page/auth/utils/get-one-click-provider-metadata.ts @@ -28,7 +28,7 @@ export interface OneClickMetadata { } const getBackendAuthUrl = (providerIdentifer: string): string => { - return `auth/${providerIdentifer}` + return `/auth/${providerIdentifer}` } const logger = new Logger('GetOneClickProviderMetadata') diff --git a/frontend/src/components/notifications/ui-notification-boundary.tsx b/frontend/src/components/notifications/ui-notification-boundary.tsx index 7b63bd786..90896abff 100644 --- a/frontend/src/components/notifications/ui-notification-boundary.tsx +++ b/frontend/src/components/notifications/ui-notification-boundary.tsx @@ -1,3 +1,4 @@ +'use client' /* * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * diff --git a/frontend/src/components/render-page/renderers/document/document-markdown-renderer.tsx b/frontend/src/components/render-page/renderers/document/document-markdown-renderer.tsx index efacb57fa..e785eb40d 100644 --- a/frontend/src/components/render-page/renderers/document/document-markdown-renderer.tsx +++ b/frontend/src/components/render-page/renderers/document/document-markdown-renderer.tsx @@ -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' diff --git a/frontend/src/components/render-page/renderers/simple/simple-markdown-renderer.tsx b/frontend/src/components/render-page/renderers/simple/simple-markdown-renderer.tsx index cc16fd3dc..fa39c3493 100644 --- a/frontend/src/components/render-page/renderers/simple/simple-markdown-renderer.tsx +++ b/frontend/src/components/render-page/renderers/simple/simple-markdown-renderer.tsx @@ -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' diff --git a/frontend/src/handler-utils/respond-to-matching-request.ts b/frontend/src/handler-utils/respond-to-matching-request.ts index 916fea563..22f89ceae 100644 --- a/frontend/src/handler-utils/respond-to-matching-request.ts +++ b/frontend/src/handler-utils/respond-to-matching-request.ts @@ -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 = ( @@ -29,17 +30,42 @@ export const respondToMatchingRequest = ( 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 = (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 +} diff --git a/frontend/src/hooks/common/use-array-string-url-parameter.ts b/frontend/src/hooks/common/use-array-string-url-parameter.ts index 508da50b6..2fe48b66a 100644 --- a/frontend/src/hooks/common/use-array-string-url-parameter.ts +++ b/frontend/src/hooks/common/use-array-string-url-parameter.ts @@ -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]) } diff --git a/frontend/src/hooks/common/use-base-url.tsx b/frontend/src/hooks/common/use-base-url.tsx index ea8359a36..641772427 100644 --- a/frontend/src/hooks/common/use-base-url.tsx +++ b/frontend/src/hooks/common/use-base-url.tsx @@ -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]) } diff --git a/frontend/src/hooks/common/use-single-string-url-parameter.ts b/frontend/src/hooks/common/use-single-string-url-parameter.ts index e8035b9ed..c0aeb6960 100644 --- a/frontend/src/hooks/common/use-single-string-url-parameter.ts +++ b/frontend/src/hooks/common/use-single-string-url-parameter.ts @@ -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 = (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]) } diff --git a/frontend/src/pages/[id].tsx b/frontend/src/pages/[id].tsx deleted file mode 100644 index a9027776f..000000000 --- a/frontend/src/pages/[id].tsx +++ /dev/null @@ -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 - } else if (value !== undefined) { - return - } else { - return Loading - } -} - -export default DirectLinkFallback diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx deleted file mode 100644 index 97b5b32f7..000000000 --- a/frontend/src/pages/_app.tsx +++ /dev/null @@ -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) { - return ( - - - - - - - - - - - - - - - - - ) -} - -const baseUrlFromEnvExtractor = new BaseUrlFromEnvExtractor() -const frontendConfigFetcher = new FrontendConfigFetcher() - -HedgeDocApp.getInitialProps = async ({ ctx }: AppContext): Promise> => { - 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 diff --git a/frontend/src/pages/api/private/config.ts b/frontend/src/pages/api/private/config.ts index 3692d1204..9f186ba91 100644 --- a/frontend/src/pages/api/private/config.ts +++ b/frontend/src/pages/api/private/config.ts @@ -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(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(HttpMethod.GET, req, res, currentConfig, 200, false) || + respondToTestRequest(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 diff --git a/frontend/src/pages/n/[noteId].tsx b/frontend/src/pages/n/[noteId].tsx deleted file mode 100644 index 680cb1e38..000000000 --- a/frontend/src/pages/n/[noteId].tsx +++ /dev/null @@ -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 ( - - - - - - - - ) -} - -export default EditorPage diff --git a/frontend/src/pages/p/[noteId].tsx b/frontend/src/pages/p/[noteId].tsx deleted file mode 100644 index e57fafdfc..000000000 --- a/frontend/src/pages/p/[noteId].tsx +++ /dev/null @@ -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 ( - - - - - - - ) -} - -export default SlideShowPage diff --git a/frontend/src/pages/s/[noteId].tsx b/frontend/src/pages/s/[noteId].tsx deleted file mode 100644 index 819505e8c..000000000 --- a/frontend/src/pages/s/[noteId].tsx +++ /dev/null @@ -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 ( - - - - -
- - -
-
-
- ) -} - -export default DocumentReadOnlyPage diff --git a/frontend/src/redux/store-provider.tsx b/frontend/src/redux/store-provider.tsx index 06163ec0a..f2e4a7a4d 100644 --- a/frontend/src/redux/store-provider.tsx +++ b/frontend/src/redux/store-provider.tsx @@ -1,3 +1,4 @@ +'use client' /* * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * diff --git a/frontend/src/utils/base-url-from-env-extractor.spec.ts b/frontend/src/utils/base-url-from-env-extractor.spec.ts index cba2e0884..cbf66b3af 100644 --- a/frontend/src/utils/base-url-from-env-extractor.spec.ts +++ b/frontend/src/utils/base-url-from-env-extractor.spec.ts @@ -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/' }) diff --git a/frontend/src/utils/base-url-from-env-extractor.ts b/frontend/src/utils/base-url-from-env-extractor.ts index 90c511202..72a74b7f4 100644 --- a/frontend/src/utils/base-url-from-env-extractor.ts +++ b/frontend/src/utils/base-url-from-env-extractor.ts @@ -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 | undefined + private baseUrls: BaseUrls | undefined private readonly logger = new Logger('Base URL Configuration') private extractUrlFromEnvVar(envVarName: string, envVarValue: string | undefined): Optional { @@ -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 { - 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() diff --git a/frontend/src/utils/determine-current-origin.spec.ts b/frontend/src/utils/determine-current-origin.spec.ts deleted file mode 100644 index 6fdbb4d2f..000000000 --- a/frontend/src/utils/determine-current-origin.spec.ts +++ /dev/null @@ -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({}))).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({}))).toBeUndefined() - }) - - it("won't return an origin if no headers are present", () => { - expect(determineCurrentOrigin(Mock.of({ req: { headers: undefined } }))).toBeUndefined() - }) - - it("won't return an origin if no host is present", () => { - expect( - determineCurrentOrigin( - Mock.of({ - req: { - headers: {} - } - }) - ) - ).toBeUndefined() - }) - - it('will return an origin for a forwarded host', () => { - expect( - determineCurrentOrigin( - Mock.of({ - 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({ - req: { - headers: { - host: 'mockHost', - 'x-forwarded-proto': 'mockProtocol' - } - } - }) - ) - ).toBe('mockProtocol://mockHost') - }) - - it('will prefer x-forwarded-host over host', () => { - expect( - determineCurrentOrigin( - Mock.of({ - 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({ - 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({ - 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({ - 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({ - req: { - headers: { - 'x-forwarded-proto': 'mockProtocol1,mockProtocol2', - 'x-forwarded-host': 'forwardedMockHost' - } - } - }) - ) - ).toBe('mockProtocol1://forwardedMockHost') - }) - }) -}) diff --git a/frontend/src/utils/determine-current-origin.ts b/frontend/src/utils/determine-current-origin.ts deleted file mode 100644 index fcadf16b5..000000000 --- a/frontend/src/utils/determine-current-origin.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { isClientSideRendering } from './is-client-side-rendering' -import { Optional } from '@mrdrogdrog/optional' -import type { IncomingHttpHeaders } from 'http' -import type { NextPageContext } from 'next' - -/** - * Determines the location origin of the current request. - * Client side rendering will use the browsers window location. - * Server side rendering will use the http request. - * - * @param context The next page context that contains the http headers - * @return the determined request origin. Will be undefined if no origin could be determined. - */ -export const determineCurrentOrigin = (context: NextPageContext): string | undefined => { - if (isClientSideRendering()) { - return window.location.origin - } - return Optional.ofNullable(context.req?.headers) - .flatMap((headers) => buildOriginFromHeaders(headers)) - .orElse(undefined) -} - -const buildOriginFromHeaders = (headers: IncomingHttpHeaders) => { - const rawHost = headers['x-forwarded-host'] ?? headers['host'] - return extractFirstValue(rawHost).map((host) => { - const protocol = extractFirstValue(headers['x-forwarded-proto']).orElse('http') - return `${protocol}://${host}` - }) -} - -const extractFirstValue = (rawValue: string | string[] | undefined): Optional => { - return Optional.ofNullable(rawValue) - .map((value) => (typeof value === 'string' ? value : value[0])) - .map((value) => value.split(',')[0]) -} diff --git a/frontend/src/utils/expected-origin-boundary.tsx b/frontend/src/utils/expected-origin-boundary.tsx deleted file mode 100644 index f3893ef01..000000000 --- a/frontend/src/utils/expected-origin-boundary.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { useBaseUrl } from '../hooks/common/use-base-url' -import type { PropsWithChildren } from 'react' -import React, { Fragment, useMemo } from 'react' - -export interface ExpectedOriginBoundaryProps { - currentOrigin?: string -} - -/** - * Checks if the url of the current browser window matches the expected origin. - * This is necessary to ensure that the render endpoint is only opened from the rendering origin. - * - * @param children The children react element that should be rendered if the origin is correct - * @param currentOrigin the current origin from client or server side rendering context - */ -export const ExpectedOriginBoundary: React.FC> = ({ - children, - currentOrigin -}) => { - const baseUrl = useBaseUrl() - const expectedOrigin = useMemo(() => new URL(baseUrl).origin, [baseUrl]) - - if (currentOrigin !== expectedOrigin) { - return ( - {`You can't open this page using this URL. For this endpoint "${expectedOrigin}" is expected.`} - ) - } else { - return {children} - } -} diff --git a/frontend/src/utils/frontend-config-fetcher.ts b/frontend/src/utils/frontend-config-fetcher.ts deleted file mode 100644 index 2dccf6497..000000000 --- a/frontend/src/utils/frontend-config-fetcher.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { getConfig } from '../api/config' -import type { FrontendConfig } from '../api/config/types' -import type { BaseUrls } from '../components/common/base-url/base-url-context-provider' -import { Logger } from './logger' - -/** - * Fetches and caches the {@link FrontendConfig frontend config} from the backend. - */ -export class FrontendConfigFetcher { - private readonly logger = new Logger('Frontend config fetcher') - - private frontendConfig: FrontendConfig | undefined = undefined - - public async fetch(baseUrls: BaseUrls | undefined): Promise { - if (!this.frontendConfig) { - if (baseUrls === undefined) { - return undefined - } - const baseUrl = baseUrls.editor.toString() - try { - this.frontendConfig = await getConfig(baseUrl) - } catch (error) { - this.logger.error(`Couldn't fetch frontend configuration from ${baseUrl}`, error) - return undefined - } - this.logger.info(`Fetched frontend config from ${baseUrl}`) - } - return this.frontendConfig - } -} diff --git a/frontend/src/utils/is-apple-device.ts b/frontend/src/utils/is-apple-device.ts index 8dcb212ea..48bd53639 100644 --- a/frontend/src/utils/is-apple-device.ts +++ b/frontend/src/utils/is-apple-device.ts @@ -8,7 +8,7 @@ * Determines if the client is running on an Apple device like a Mac or an iPhone. * This is necessary to e.g. determine different keyboard shortcuts. */ -export const isAppleDevice: () => boolean = () => { +export const isAppleDevice = (): boolean => { const platform = navigator?.userAgentData?.platform || navigator?.platform || 'unknown' return platform.startsWith('Mac') || platform === 'iPhone' } diff --git a/frontend/src/utils/is-client-side-rendering.ts b/frontend/src/utils/is-client-side-rendering.ts deleted file mode 100644 index fbab8d7c8..000000000 --- a/frontend/src/utils/is-client-side-rendering.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -/** - * Detects if the application is running on client side. - */ -export const isClientSideRendering = (): boolean => { - return typeof window !== 'undefined' && typeof window.navigator !== 'undefined' -} diff --git a/frontend/src/utils/test-modes.js b/frontend/src/utils/test-modes.js index 13b52e7b9..56ad561a6 100644 --- a/frontend/src/utils/test-modes.js +++ b/frontend/src/utils/test-modes.js @@ -43,9 +43,17 @@ const isDevMode = process.env.NODE_ENV === 'development' */ const isProfilingMode = !!process.env.ANALYZE && isPositiveAnswer(process.env.ANALYZE) +/** + * Defines if the currently running process is building or executing. + * + * @type boolean + */ +const isBuildTime = !!process.env.BUILD_TIME && isPositiveAnswer(process.env.BUILD_TIME) + module.exports = { isTestMode, isMockMode, isDevMode, - isProfilingMode + isProfilingMode, + isBuildTime } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index aef3daa9a..e6da63df2 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,36 +1,42 @@ { - "compilerOptions" : { - "target" : "esnext", - "lib" : [ + "compilerOptions": { + "target": "esnext", + "lib": [ "dom", "dom.iterable", "esnext" ], - "allowJs" : true, - "skipLibCheck" : true, - "strict" : true, - "forceConsistentCasingInFileNames" : true, - "noEmit" : true, - "esModuleInterop" : true, - "module" : "esnext", - "moduleResolution" : "node", - "resolveJsonModule" : true, - "isolatedModules" : true, - "jsx" : "preserve", - "incremental" : true, - "types" : [ + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "types": [ "node", "@testing-library/jest-dom", "@types/jest" + ], + "plugins": [ + { + "name": "next" + } ] }, - "include" : [ + "include": [ "src/external-types/images/index.d.ts", "next-env.d.ts", "**/*.ts", - "**/*.tsx" + "**/*.tsx", + ".next/types/**/*.ts" ], - "exclude" : [ + "exclude": [ "node_modules", "cypress", "cypress.config.ts", diff --git a/yarn.lock b/yarn.lock index 25066e038..3f5c3ab3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17959,9 +17959,9 @@ __metadata: languageName: node linkType: hard -"vega-canvas@patch:vega-canvas@npm%3A1.2.7#./.yarn/patches/vega-canvas-npm-1.2.7-df0c331091.patch::locator=hedgedoc%40workspace%3A.": +"vega-canvas@patch:vega-canvas@npm%3A1.2.7#./.yarn/patches/remove-vega-canvas-node.patch::locator=hedgedoc%40workspace%3A.": version: 1.2.7 - resolution: "vega-canvas@patch:vega-canvas@npm%3A1.2.7#./.yarn/patches/vega-canvas-npm-1.2.7-df0c331091.patch::version=1.2.7&hash=713695&locator=hedgedoc%40workspace%3A." + resolution: "vega-canvas@patch:vega-canvas@npm%3A1.2.7#./.yarn/patches/remove-vega-canvas-node.patch::version=1.2.7&hash=713695&locator=hedgedoc%40workspace%3A." checksum: c933998d0402278195becacf3880958810d77fb0eaa0225d2c4d687845cd9db9b442c8e0ded6219638abb057864dde1dd903884c7e5c1c762f91d9bdbe89f83c languageName: node linkType: hard