From 175c6d4a3f68e38ce0219b06c83d74c3d8618d48 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Mon, 2 Nov 2026 16:53:59 +0100 Subject: [PATCH] refactor(upload): upload to shared folders through the new upload flow, with the owner's credentials and free names --- src/app/upload/sharedFolderNames.test.ts | 81 +++++++++++++++++ src/app/upload/sharedFolderNames.ts | 68 ++++++++++++++ src/app/upload/useUploadItems.ts | 7 +- src/views/Shared/SharedView.tsx | 111 +++++++++++++++-------- 4 files changed, 228 insertions(+), 39 deletions(-) create mode 100644 src/app/upload/sharedFolderNames.test.ts create mode 100644 src/app/upload/sharedFolderNames.ts diff --git a/src/app/upload/sharedFolderNames.test.ts b/src/app/upload/sharedFolderNames.test.ts new file mode 100644 index 000000000..9e6495d6a --- /dev/null +++ b/src/app/upload/sharedFolderNames.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import shareService from 'app/share/services/share.service'; +import workspacesService from 'services/workspace.service'; +import { getFileToUpload } from 'testUtils/fixtures/upload.fixtures'; +import { renameToFreeNamesInSharedFolder } from './sharedFolderNames'; + +vi.mock('app/share/services/share.service', () => ({ default: { getSharedFolderContent: vi.fn() } })); +vi.mock('services/workspace.service', () => ({ default: { getAllWorkspaceTeamSharedFolderFiles: vi.fn() } })); + +const SHARED_FOLDER = { uuid: 'shared-folder-uuid', resourcesToken: 'resources-token' }; + +const aSharedFile = (plainName: string, type: string) => ({ plainName, type }); + +const sharedFolderContains = (...pages: ReturnType[][]) => + pages.forEach((items) => vi.mocked(shareService.getSharedFolderContent).mockResolvedValueOnce({ items } as never)); + +const namesOf = (files: File[]) => files.map((file) => file.name); + +describe('Naming files uploaded to a shared folder', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('When a name is already taken in the shared folder, then the file gets the next free name', async () => { + sharedFolderContains([aSharedFile('report', 'pdf')]); + + const files = await renameToFreeNamesInSharedFolder([getFileToUpload('report.pdf')], SHARED_FOLDER); + + expect(namesOf(files)).toEqual(['report (1).pdf']); + }); + + test('When the name is free, then the file keeps it', async () => { + sharedFolderContains([aSharedFile('report', 'pdf')]); + + const files = await renameToFreeNamesInSharedFolder([getFileToUpload('invoice.pdf')], SHARED_FOLDER); + + expect(namesOf(files)).toEqual(['invoice.pdf']); + }); + + test('When several uploaded files share a name, then each one gets its own free name', async () => { + sharedFolderContains([]); + + const files = await renameToFreeNamesInSharedFolder( + [getFileToUpload('report.pdf'), getFileToUpload('report.pdf')], + SHARED_FOLDER, + ); + + expect(namesOf(files)).toEqual(['report.pdf', 'report (1).pdf']); + }); + + test('When the shared folder has many files, then every page is checked', async () => { + const fullPage = Array.from({ length: 50 }, (_, index) => aSharedFile(`file-${index}`, 'txt')); + sharedFolderContains(fullPage, [aSharedFile('report', 'pdf')]); + + const files = await renameToFreeNamesInSharedFolder([getFileToUpload('report.pdf')], SHARED_FOLDER); + + expect(shareService.getSharedFolderContent).toHaveBeenCalledTimes(2); + expect(namesOf(files)).toEqual(['report (1).pdf']); + }); + + test('When the shared folder belongs to a workspace, then its files are listed through the workspace', async () => { + vi.mocked(workspacesService.getAllWorkspaceTeamSharedFolderFiles).mockReturnValue([ + Promise.resolve({ items: [aSharedFile('report', 'pdf')] }), + { cancel: vi.fn() }, + ] as never); + + const files = await renameToFreeNamesInSharedFolder([getFileToUpload('report.pdf')], { + ...SHARED_FOLDER, + workspaceId: 'workspace-id', + }); + + expect(workspacesService.getAllWorkspaceTeamSharedFolderFiles).toHaveBeenCalledWith( + 'workspace-id', + 'shared-folder-uuid', + 0, + 50, + 'resources-token', + ); + expect(namesOf(files)).toEqual(['report (1).pdf']); + }); +}); diff --git a/src/app/upload/sharedFolderNames.ts b/src/app/upload/sharedFolderNames.ts new file mode 100644 index 000000000..398aa803a --- /dev/null +++ b/src/app/upload/sharedFolderNames.ts @@ -0,0 +1,68 @@ +import { items as itemUtils } from '@internxt/lib'; +import { renameFile } from 'app/crypto/services/utils'; +import shareService from 'app/share/services/share.service'; +import workspacesService from 'services/workspace.service'; + +const SHARED_FILES_PAGE_SIZE = 50; + +interface ExistingName { + name: string; + type?: string; +} + +export interface SharedFolder { + uuid: string; + resourcesToken: string; + workspaceId?: string; +} + +const listSharedFilesPage = async ({ uuid, resourcesToken, workspaceId }: SharedFolder, page: number) => { + if (workspaceId) { + const [filesPromise] = workspacesService.getAllWorkspaceTeamSharedFolderFiles( + workspaceId, + uuid, + page, + SHARED_FILES_PAGE_SIZE, + resourcesToken, + ); + const workspaceSharedFiles = await filesPromise; + return workspaceSharedFiles.items; + } + + const sharedFiles = await shareService.getSharedFolderContent( + uuid, + 'files', + resourcesToken, + page, + SHARED_FILES_PAGE_SIZE, + ); + return sharedFiles.items; +}; + +const listNamesInSharedFolder = async (sharedFolder: SharedFolder): Promise => { + const names: ExistingName[] = []; + + for (let page = 0; ; page++) { + const sharedFiles = await listSharedFilesPage(sharedFolder, page); + names.push(...sharedFiles.map((sharedFile) => ({ name: sharedFile.plainName, type: sharedFile.type }))); + + const isLastPage = sharedFiles.length < SHARED_FILES_PAGE_SIZE; + if (isLastPage) return names; + } +}; + +/** + * In a shared folder the name collision dialog cannot be used, so files whose name is already taken get the next + * free one instead, such as "report (1).pdf". + */ +export const renameToFreeNamesInSharedFolder = async (files: File[], sharedFolder: SharedFolder): Promise => { + const takenNames = await listNamesInSharedFolder(sharedFolder); + + return files.map((file) => { + const { filename, extension } = itemUtils.getFilenameAndExt(file.name); + const [, , freeFilename] = itemUtils.renameIfNeeded(takenNames, filename, extension); + takenNames.push({ name: freeFilename, type: extension }); + + return renameFile(file, extension ? `${freeFilename}.${extension}` : freeFilename); + }); +}; diff --git a/src/app/upload/useUploadItems.ts b/src/app/upload/useUploadItems.ts index ae4ca9294..9167e5520 100644 --- a/src/app/upload/useUploadItems.ts +++ b/src/app/upload/useUploadItems.ts @@ -39,6 +39,10 @@ const createUploadContext = (dispatch: AppDispatch, state: RootState): UploadCon }, }); +type UploadContextOverrides = Partial< + Pick +>; + export const useUploadItems = () => { const dispatch = useAppDispatch(); const store = useStore(); @@ -48,7 +52,8 @@ export const useUploadItems = () => { return { uploadItems: (payload: UploadItemsPayload) => uploadItems(payload, getUploadContext()), - startUpload: (payload: UploadItemsPayload) => startUpload(payload, getUploadContext()), + startUpload: (payload: UploadItemsPayload, contextOverrides: UploadContextOverrides = {}) => + startUpload(payload, { ...getUploadContext(), ...contextOverrides }), }; }, [dispatch, store]); }; diff --git a/src/views/Shared/SharedView.tsx b/src/views/Shared/SharedView.tsx index 0fd32cba0..4065f662a 100644 --- a/src/views/Shared/SharedView.tsx +++ b/src/views/Shared/SharedView.tsx @@ -1,5 +1,5 @@ import _ from 'utils/lodash'; -import { connect, useSelector } from 'react-redux'; +import { connect, shallowEqual, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; import { ChangeEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'; @@ -31,8 +31,12 @@ import WarningMessageWrapper from 'views/Home/components/WarningMessageWrapper'; import { AdvancedSharedItem, PreviewFileItem, SharedNamePath } from '../../app/share/types'; import { RootState } from '../../app/store'; import { sharedActions, sharedThunks } from '../../app/store/slices/sharedLinks'; -import storageThunks from '../../app/store/slices/storage/storage.thunks'; import workspacesSelectors from '../../app/store/slices/workspaces/workspaces.selectors'; +import { OwnerUserAuthenticationData } from 'app/network/types'; +import { planThunks } from 'app/store/slices/plan'; +import { notifyUploadFailed } from 'app/upload/notifyUploadFailed'; +import { renameToFreeNamesInSharedFolder } from 'app/upload/sharedFolderNames'; +import { useUploadItems } from 'app/upload/useUploadItems'; import { handlePrivateSharedFolderAccess } from '../../app/share/services/redirections.service'; import TopBarButtons from './components/TopBarButtons'; import SharedItemListContainer from './components/SharedItemListContainer'; @@ -136,6 +140,8 @@ function SharedView({ const selectedWorkspace = useSelector(workspacesSelectors.getSelectedWorkspace); const workspaceId = selectedWorkspace?.workspace.id; const defaultTeamId = selectedWorkspace?.workspace.defaultTeamId; + const workspaceAuthenticationData = useSelector(workspacesSelectors.getOwnerUserAuthenticationData, shallowEqual); + const { startUpload } = useUploadItems(); useLayoutEffect(() => { if (page === 0 && !folderUUID) { @@ -378,6 +384,48 @@ function SharedView({ fileInputRef.current?.click(); }; + const getSharedFolderAuthenticationData = async ( + resourcesToken: string, + ): Promise => { + if (workspaceAuthenticationData) return { ...workspaceAuthenticationData, resourcesToken }; + + if (!filesOwnerCredentials) return undefined; + + const ownerCredentials = { + bridgeUser: filesOwnerCredentials.networkUser, + bridgePass: filesOwnerCredentials.networkPass, + token: resourcesToken, + resourcesToken, + }; + + const isOwnerOfFolder = filesOwnerCredentials.networkUser === currentUser?.email; + if (currentUser && isOwnerOfFolder) { + return { ...ownerCredentials, encryptionKey: currentUser.mnemonic, bucketId: currentUser.bucket }; + } + + const ownerMnemonic = ownerEncryptionKey ? await decryptMnemonic(ownerEncryptionKey) : null; + if (!ownerMnemonic || !ownerBucket) return undefined; + + return { ...ownerCredentials, encryptionKey: ownerMnemonic, bucketId: ownerBucket }; + }; + + const openOwnerStorageIsFullDialog = () => + dispatch( + uiActions.setOpenReachedPlanLimitDialog({ + open: true, + info: { + title: translate('error.ownerStorageIsFull'), + description: translate('error.ownerStorageIsFullDescription'), + hidePrimaryAction: true, + }, + }), + ); + + const refreshUsage = () => { + dispatch(planThunks.fetchUsageThunk()); + if (selectedWorkspace?.workspaceUser?.memberId) dispatch(planThunks.fetchBusinessLimitUsageThunk()); + }; + const onUploadFileInputChanged = async ({ files }: { files: FileList | null }) => { const items = files; @@ -399,44 +447,31 @@ function SharedView({ return; // Exit the function if the condition fails } - let ownerUserAuthenticationData; - const isSecondLevelOfFoldersOrMore = sharedNamePath.length >= 2; - const isOwnerOfFolder = filesOwnerCredentials?.networkUser === currentUser?.email; - const token = isSecondLevelOfFoldersOrMore ? currentFolderLevelResourcesToken : nextFolderLevelResourcesToken; - if (filesOwnerCredentials && currentUser && isOwnerOfFolder) { - ownerUserAuthenticationData = { - bridgeUser: filesOwnerCredentials?.networkUser, - bridgePass: filesOwnerCredentials?.networkPass, - encryptionKey: currentUser?.mnemonic, - bucketId: currentUser.bucket, - token, - resourcesToken: token, - }; - } else { - const mnemonicDecrypted = - selectedWorkspace?.workspaceUser.key ?? (ownerEncryptionKey ? await decryptMnemonic(ownerEncryptionKey) : null); - if (filesOwnerCredentials && mnemonicDecrypted && ownerBucket) { - ownerUserAuthenticationData = { - bridgeUser: filesOwnerCredentials?.networkUser, - bridgePass: filesOwnerCredentials?.networkPass, - encryptionKey: mnemonicDecrypted, - bucketId: ownerBucket, - token, - resourcesToken: token, - }; - } - } + const resourcesToken = isSecondLevelOfFoldersOrMore + ? currentFolderLevelResourcesToken + : nextFolderLevelResourcesToken; + const filesWithContent = Array.from(items).filter((file) => file.size > 0); - await dispatch( - storageThunks.uploadSharedItemsThunk({ - files: Array.from(items), - parentFolderId: currentParentFolderId, - currentFolderId, - ownerUserAuthenticationData, - isDeepFolder: isSecondLevelOfFoldersOrMore, - }), - ); + try { + const filesWithFreeNames = await renameToFreeNamesInSharedFolder(filesWithContent, { + uuid: currentFolderId, + resourcesToken: isSecondLevelOfFoldersOrMore ? resourcesToken : '', + workspaceId: defaultTeamId ? workspaceId : undefined, + }); + const ownerUserAuthenticationData = await getSharedFolderAuthenticationData(resourcesToken); + + await startUpload( + { destinationFolderUuid: currentParentFolderId, roots: [], files: filesWithFreeNames }, + { + ownerUserAuthenticationData, + onPlanLimitReached: openOwnerStorageIsFullDialog, + onUploadFinished: refreshUsage, + }, + ); + } catch (error) { + notifyUploadFailed(error); + } actionDispatch(setHasMoreFiles(true)); fetchFiles(true, workspaceId);