diff --git a/src/declarations.d.ts b/src/declarations.d.ts index d9e98b7492..36e56c638d 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -91,6 +91,14 @@ declare module 'cozy-client/dist/models/file' { ) => boolean } +declare module 'cozy-client/dist/models/note' { + export const fetchURL: ( + client: import('cozy-client/types/CozyClient').CozyClient, + file: { id: string }, + options: { pathname: string } + ) => Promise +} + declare module '*.svg' { import { FC, SVGProps } from 'react' const content: FC> diff --git a/src/locales/en.json b/src/locales/en.json index b7b0d0d6ab..1cef520d6b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -848,5 +848,11 @@ }, "LastUpdate": { "titleFormat": "MMMM DD, YYYY, HH:MM" + }, + "PublicNoteRedirect": { + "error": { + "title": "Unable to access document", + "subtitle": "The share link appears to be missing or invalid. Please ask the document owner to check access" + } } } diff --git a/src/locales/fr.json b/src/locales/fr.json index d5ca370349..d00cf144ab 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -849,5 +849,11 @@ }, "LastUpdate": { "titleFormat": "DD MMMM YYYY, HH:MM" + }, + "PublicNoteRedirect": { + "error": { + "title": "Impossible d'accéder au document", + "subtitle": "Le lien de partage semble manquant ou invalide. Merci de demander au propriétaire du document de vérifier les accès" + } } } diff --git a/src/modules/layout/DummyLayout.tsx b/src/modules/layout/DummyLayout.tsx new file mode 100644 index 0000000000..fda69d8d17 --- /dev/null +++ b/src/modules/layout/DummyLayout.tsx @@ -0,0 +1,15 @@ +import React from 'react' + +import Sprite from 'cozy-ui/transpiled/react/Icon/Sprite' +import { Layout } from 'cozy-ui/transpiled/react/Layout' + +const DummyLayout: React.FC = ({ children }) => { + return ( + + {children} + + + ) +} + +export { DummyLayout } diff --git a/src/modules/navigation/AppRoute.jsx b/src/modules/navigation/AppRoute.jsx index 36e868da98..12cf67989c 100644 --- a/src/modules/navigation/AppRoute.jsx +++ b/src/modules/navigation/AppRoute.jsx @@ -23,6 +23,7 @@ import { ROOT_DIR_ID, TRASH_DIR_ID } from 'constants/config' import { SentryRoutes } from 'lib/sentry' import { UploaderComponent } from 'modules//views/Upload/UploaderComponent' import Layout from 'modules/layout/Layout' +import { PublicNoteRedirect } from 'modules/navigation/PublicNoteRedirect' import FileOpenerExternal from 'modules/viewer/FileOpenerExternal' import HarvestRoutes from 'modules/views/Drive/HarvestRoutes' import { SharedDrivesFolderView } from 'modules/views/Drive/SharedDrivesFolderView' @@ -50,6 +51,8 @@ const FilesRedirect = () => { const AppRoute = () => ( } /> + } /> + }> } /> } /> diff --git a/src/modules/navigation/ExternalRedirect.jsx b/src/modules/navigation/ExternalRedirect.jsx index d21aad1fdf..30a0d7971c 100644 --- a/src/modules/navigation/ExternalRedirect.jsx +++ b/src/modules/navigation/ExternalRedirect.jsx @@ -3,10 +3,10 @@ import { useParams } from 'react-router-dom' import { useClient, useFetchShortcut } from 'cozy-client' import Empty from 'cozy-ui/transpiled/react/Empty' -import Sprite from 'cozy-ui/transpiled/react/Icon/Sprite' import { translate } from 'cozy-ui/transpiled/react/providers/I18n' import EmptyIcon from 'assets/icons/icon-folder-broken.svg' +import { DummyLayout } from 'modules/layout/DummyLayout' const ExternalRedirect = ({ t }) => { const { fileId } = useParams() @@ -17,8 +17,7 @@ const ExternalRedirect = ({ t }) => { } return ( - <> - + {fetchStatus === 'failed' && ( { text={t('External.redirection.text')} /> )} - + ) } diff --git a/src/modules/navigation/PublicNoteRedirect.tsx b/src/modules/navigation/PublicNoteRedirect.tsx new file mode 100644 index 0000000000..fb2e658903 --- /dev/null +++ b/src/modules/navigation/PublicNoteRedirect.tsx @@ -0,0 +1,68 @@ +import React, { FC, useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' + +import { useClient } from 'cozy-client' +import { fetchURL } from 'cozy-client/dist/models/note' +import Empty from 'cozy-ui/transpiled/react/Empty' +import Icon from 'cozy-ui/transpiled/react/Icon' +import SadCozyIcon from 'cozy-ui/transpiled/react/Icons/SadCozy' +import Spinner from 'cozy-ui/transpiled/react/Spinner' +import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' + +import { joinPath } from 'lib/path' +import { DummyLayout } from 'modules/layout/DummyLayout' + +const PublicNoteRedirect: FC = () => { + const { t } = useI18n() + const { fileId } = useParams() + const client = useClient() + + const [noteUrl, setNoteUrl] = useState(null) + const [fetchStatus, setFetchStatus] = useState< + 'failed' | 'loading' | 'pending' | 'loaded' + >('pending') + + useEffect(() => { + const fetchNoteUrl = async (fileId: string): Promise => { + setFetchStatus('loading') + try { + const url = await fetchURL( + client, + { + id: fileId + }, + { + pathname: joinPath(location.pathname, '') + } + ) + setNoteUrl(url) + setFetchStatus('loaded') + } catch (error) { + setFetchStatus('failed') + } + } + + if (fileId) { + void fetchNoteUrl(fileId) + } + }, [fileId, client]) + + if (noteUrl) { + window.location.href = noteUrl + } + + return ( + + {fetchStatus === 'failed' && ( + } + title={t('PublicNoteRedirect.error.title')} + text={t('PublicNoteRedirect.error.subtitle')} + /> + )} + {fetchStatus !== 'failed' && } + + ) +} + +export { PublicNoteRedirect } diff --git a/src/modules/navigation/hooks/helpers.spec.js b/src/modules/navigation/hooks/helpers.spec.js index 193593807b..b082d07548 100644 --- a/src/modules/navigation/hooks/helpers.spec.js +++ b/src/modules/navigation/hooks/helpers.spec.js @@ -32,7 +32,7 @@ describe('computeFileType', () => { expect(computeFileType(file)).toBe('nextcloud-file') }) - it('should return "note" for notes', () => { + it('should return "public-note-same-instance" for public notes on the same instance', () => { const file = { _type: 'io.cozy.files', name: 'My journal.cozy-note', @@ -40,9 +40,68 @@ describe('computeFileType', () => { metadata: { title: '', version: '0' + }, + cozyMetadata: { + createdOn: 'https://example.com/' + } + } + expect( + computeFileType(file, { isPublic: true, cozyUrl: 'https://example.com' }) + ).toBe('public-note-same-instance') + }) + + it('should return "note" for notes on the same instance', () => { + const file = { + _type: 'io.cozy.files', + name: 'My journal.cozy-note', + type: 'file', + metadata: { + title: '', + version: '0' + }, + cozyMetadata: { + createdOn: 'https://example.com/' } } - expect(computeFileType(file)).toBe('note') + expect(computeFileType(file, { cozyUrl: 'https://example.com/' })).toBe( + 'note' + ) + }) + + it('should return "public-note" for notes on an another instance', () => { + const file = { + _type: 'io.cozy.files', + name: 'My journal.cozy-note', + type: 'file', + metadata: { + title: '', + version: '0' + }, + cozyMetadata: { + createdOn: 'https://example.com/' + } + } + expect(computeFileType(file, { cozyUrl: 'https://another.com/' })).toBe( + 'public-note' + ) + }) + + it('should return "public-note" for public notes', () => { + const file = { + _type: 'io.cozy.files', + name: 'My journal.cozy-note', + type: 'file', + metadata: { + title: '', + version: '0' + }, + cozyMetadata: { + createdOn: 'https://example.com/' + } + } + expect( + computeFileType(file, { isPublic: true, cozyUrl: 'https://another.com' }) + ).toBe('public-note') }) it('should return "onlyoffice" for files opened by OnlyOffice when Office is enabled', () => { @@ -158,6 +217,23 @@ describe('computePath', () => { ) }) + it('should return correct path for public-note', () => { + const file = { _id: 'note123' } + expect( + computePath(file, { type: 'public-note', pathname: '/public' }) + ).toBe('/note/note123') + }) + + it('should return correct path for public-note-same-instance', () => { + const file = { _id: 'note123' } + expect( + computePath(file, { + type: 'public-note-same-instance', + pathname: '/public' + }) + ).toBe('/?id=note123') + }) + it('should return correct path for shortcut', () => { const file = { _id: 'shortcut123' } expect(computePath(file, { type: 'shortcut', pathname: '/any' })).toBe( diff --git a/src/modules/navigation/hooks/helpers.ts b/src/modules/navigation/hooks/helpers.ts index deca0eaedd..c3f25560a7 100644 --- a/src/modules/navigation/hooks/helpers.ts +++ b/src/modules/navigation/hooks/helpers.ts @@ -7,11 +7,14 @@ import { import type { File } from 'components/FolderPicker/types' import { TRASH_DIR_ID } from 'constants/config' +import { joinPath } from 'lib/path' import { isNextcloudShortcut } from 'modules/nextcloud/helpers' import { makeOnlyOfficeFileRoute } from 'modules/views/OnlyOffice/helpers' interface ComputeFileTypeOptions { isOfficeEnabled?: boolean + isPublic?: boolean + cozyUrl?: string } interface ComputePathOptions { @@ -22,7 +25,11 @@ interface ComputePathOptions { export const computeFileType = ( file: File, - { isOfficeEnabled = false }: ComputeFileTypeOptions = {} + { + isOfficeEnabled = false, + isPublic = false, + cozyUrl = '' + }: ComputeFileTypeOptions = {} ): string => { if (file._id === TRASH_DIR_ID) { return 'trash' @@ -31,7 +38,16 @@ export const computeFileType = ( } else if (file._type === 'io.cozy.remote.nextcloud.files') { return isDirectory(file) ? 'nextcloud-directory' : 'nextcloud-file' } else if (isNote(file)) { - return 'note' + // createdOn url ends with a trailing slash whereas cozyUrl does not joinPath fixes this + const isSameInstance = + joinPath(cozyUrl, '') === file.cozyMetadata?.createdOn + if (isPublic && isSameInstance) { + return 'public-note-same-instance' + } else if (isSameInstance) { + return 'note' + } else { + return 'public-note' + } } else if (shouldBeOpenedByOnlyOffice(file) && isOfficeEnabled) { return 'onlyoffice' } else if (isNextcloudShortcut(file)) { @@ -46,13 +62,15 @@ export const computeFileType = ( } export const computeApp = (type: string): string => { - if (type === 'nextcloud-file') { - return 'nextcloud' - } - if (type === 'note') { - return 'notes' + switch (type) { + case 'nextcloud-file': + return 'nextcloud' + case 'note': + case 'public-note-same-instance': + return 'notes' + default: + return 'drive' } - return 'drive' } export const computePath = ( @@ -75,6 +93,10 @@ export const computePath = ( return file.links?.self ?? '' case 'note': return `/n/${file._id}` + case 'public-note-same-instance': + return `/?id=${file._id}` + case 'public-note': + return `/note/${file._id}` case 'shortcut': return `/external/${file._id}` case 'directory': diff --git a/src/modules/navigation/hooks/useFileLink.tsx b/src/modules/navigation/hooks/useFileLink.tsx index 03e73451f0..1c09b9421d 100644 --- a/src/modules/navigation/hooks/useFileLink.tsx +++ b/src/modules/navigation/hooks/useFileLink.tsx @@ -6,6 +6,7 @@ import { useClient, generateWebLink } from 'cozy-client' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import type { File } from 'components/FolderPicker/types' +import { joinPath } from 'lib/path' import { computeFileType, computeApp, @@ -48,8 +49,13 @@ const useFileLink = (file: File): UseFileLinkResult => { const isOfficeEnabled = computeOfficeEnabled(isDesktop) const { isPublic } = usePublicContext() + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment + const cozyUrl = client?.getStackClient().uri as string + const type = computeFileType(file, { - isOfficeEnabled + isOfficeEnabled, + isPublic, + cozyUrl }) const app = computeApp(type) const path = computePath(file, { @@ -81,11 +87,14 @@ const useFileLink = (file: File): UseFileLinkResult => { ? path : generateWebLink({ slug: app, - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment - cozyUrl: client?.getStackClient().uri, + cozyUrl, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment subDomainType: client?.getInstanceOptions().subdomain, - pathname: currentURL.pathname, + // Inside notes, we need to add / at the end of /public/ or /preview/ to avoid 409 error + pathname: + type === 'public-note-same-instance' + ? joinPath(currentURL.pathname, '') + : currentURL.pathname, searchParams: searchParams as unknown as unknown[], hash: to.pathname }) diff --git a/src/modules/views/Public/PublicNoteRedirectView.tsx b/src/modules/views/Public/PublicNoteRedirectView.tsx new file mode 100644 index 0000000000..fac84e2662 --- /dev/null +++ b/src/modules/views/Public/PublicNoteRedirectView.tsx @@ -0,0 +1,73 @@ +import React, { FC, useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' + +import { useClient } from 'cozy-client' +import { fetchURL } from 'cozy-client/dist/models/note' +import Empty from 'cozy-ui/transpiled/react/Empty' +import Sprite from 'cozy-ui/transpiled/react/Icon/Sprite' +import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' + +import EmptyIcon from 'assets/icons/icon-folder-broken.svg' +import { joinPath } from 'lib/path' + +const PublicNoteRedirectView: FC = () => { + const { t } = useI18n() + const { fileId } = useParams() + const client = useClient() + + const [noteUrl, setNoteUrl] = useState(null) + const [fetchStatus, setFetchStatus] = useState< + 'failed' | 'loading' | 'pending' | 'loaded' + >('pending') + + useEffect(() => { + const fetchNoteUrl = async (fileId: string): Promise => { + setFetchStatus('loading') + try { + const url = await fetchURL( + client, + { + id: fileId + }, + { + pathname: joinPath(location.pathname, '') + } + ) + setNoteUrl(url) + setFetchStatus('loaded') + } catch (error) { + setFetchStatus('failed') + } + } + + if (fileId) { + void fetchNoteUrl(fileId) + } + }, [fileId, client]) + + if (noteUrl) { + window.location.href = noteUrl + } + + return ( + <> + + {fetchStatus === 'failed' && ( + + )} + {fetchStatus !== 'failed' && ( + + )} + + ) +} + +export { PublicNoteRedirectView } diff --git a/src/targets/public/components/AppRouter.jsx b/src/targets/public/components/AppRouter.jsx index 531cd0ec1d..41f1fae5e9 100644 --- a/src/targets/public/components/AppRouter.jsx +++ b/src/targets/public/components/AppRouter.jsx @@ -7,6 +7,7 @@ import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import FileHistory from 'components/FileHistory' import { SentryRoutes } from 'lib/sentry' import ExternalRedirect from 'modules/navigation/ExternalRedirect' +import { PublicNoteRedirect } from 'modules/navigation/PublicNoteRedirect' import LightFileViewer from 'modules/public/LightFileViewer' import PublicLayout from 'modules/public/PublicLayout' import OnlyOfficeView from 'modules/views/OnlyOffice' @@ -91,7 +92,7 @@ const AppRouter = ({ element={} /> - + } /> } />