diff --git a/src/backend/common/rate-limit/transient-error-handler.test.ts b/src/backend/common/rate-limit/transient-error-handler.test.ts index 4199b0e729..f9b4c68028 100644 --- a/src/backend/common/rate-limit/transient-error-handler.test.ts +++ b/src/backend/common/rate-limit/transient-error-handler.test.ts @@ -11,6 +11,12 @@ describe('createTransientErrorHandler', () => { expect(handler(new DriveDesktopError('FILE_ALREADY_EXISTS'))).toBeNull(); }); + it('should not retry FILE_TOO_BIG errors', () => { + const handler = createTransientErrorHandler({ tag: 'SYNC-ENGINE', context: 'TEST', path: '/file.txt' }); + + expect(handler(new DriveDesktopError('FILE_TOO_BIG'))).toBeNull(); + }); + it('should return exponential backoff delay for INTERNAL_SERVER_ERROR', () => { const handler = createTransientErrorHandler({ tag: 'BACKUPS', context: 'TEST', path: '/file.txt' }); const error = new DriveDesktopError('INTERNAL_SERVER_ERROR'); diff --git a/src/backend/features/backup/upload/create-file-to-backend.test.ts b/src/backend/features/backup/upload/create-file-to-backend.test.ts index 5b6ea0814c..65a1c61aad 100644 --- a/src/backend/features/backup/upload/create-file-to-backend.test.ts +++ b/src/backend/features/backup/upload/create-file-to-backend.test.ts @@ -71,6 +71,15 @@ describe('createFileToBackend', () => { expect(result.error?.cause).toBe('UNKNOWN'); }); + it('should return FILE_TOO_BIG on FILE_TOO_BIG', async () => { + createFileMock.mockResolvedValue({ error: new DriveServerError('FILE_TOO_BIG', 402) }); + + const result = await createFileToBackend(baseParams); + + expect(result.error).toBeInstanceOf(DriveDesktopError); + expect(result.error?.cause).toBe('FILE_TOO_BIG'); + }); + it('should return INTERNAL_SERVER_ERROR on SERVER_ERROR', async () => { createFileMock.mockResolvedValue({ error: new DriveServerError('SERVER_ERROR', 500) }); diff --git a/src/backend/features/backup/upload/create-file-to-backend.ts b/src/backend/features/backup/upload/create-file-to-backend.ts index 78758043bf..7bf8b62386 100644 --- a/src/backend/features/backup/upload/create-file-to-backend.ts +++ b/src/backend/features/backup/upload/create-file-to-backend.ts @@ -74,6 +74,7 @@ export async function createFileToBackend({ CONFLICT: 'FILE_ALREADY_EXISTS', SERVER_ERROR: 'INTERNAL_SERVER_ERROR', TOO_MANY_REQUESTS: 'RATE_LIMITED', + FILE_TOO_BIG: 'FILE_TOO_BIG', }; const cause = causeMap[response.error.cause] ?? 'UNKNOWN'; diff --git a/src/backend/features/backup/upload/override-file-to-backend.test.ts b/src/backend/features/backup/upload/override-file-to-backend.test.ts index fcefc93f79..1f1d518871 100644 --- a/src/backend/features/backup/upload/override-file-to-backend.test.ts +++ b/src/backend/features/backup/upload/override-file-to-backend.test.ts @@ -39,6 +39,14 @@ describe('override-file-to-backend', () => { expect(result.error?.cause).toBe('RATE_LIMITED'); }); + it('should return FILE_TOO_BIG when server returns FILE_TOO_BIG', async () => { + overrideFileMock.mockResolvedValue({ error: new DriveServerError('FILE_TOO_BIG', 402) }); + + const result = await overrideFileToBackend(baseParams); + + expect(result.error?.cause).toBe('FILE_TOO_BIG'); + }); + it('should return UNKNOWN for any other server error', async () => { overrideFileMock.mockResolvedValue({ error: new DriveServerError('NOT_FOUND', 404) }); diff --git a/src/backend/features/backup/upload/override-file-to-backend.ts b/src/backend/features/backup/upload/override-file-to-backend.ts index ddfd82aaa0..290265a1ad 100644 --- a/src/backend/features/backup/upload/override-file-to-backend.ts +++ b/src/backend/features/backup/upload/override-file-to-backend.ts @@ -6,6 +6,7 @@ import { SyncError } from '../../../../shared/issues/SyncErrorCause'; const causeMap: Record = { SERVER_ERROR: 'INTERNAL_SERVER_ERROR', TOO_MANY_REQUESTS: 'RATE_LIMITED', + FILE_TOO_BIG: 'FILE_TOO_BIG', }; export async function overrideFileToBackend(params: OverrideFileProps): Promise> { diff --git a/src/backend/features/backup/upload/update-file-to-backup.test.ts b/src/backend/features/backup/upload/update-file-to-backup.test.ts index 47f79decad..9d7da67116 100644 --- a/src/backend/features/backup/upload/update-file-to-backup.test.ts +++ b/src/backend/features/backup/upload/update-file-to-backup.test.ts @@ -7,10 +7,14 @@ import * as overrideFileModule from '../../../../infra/drive-server/services/fil import { BucketEntryIdMother } from '../../../../context/virtual-drive/shared/domain/__test-helpers__/BucketEntryIdMother'; import { UuidMother } from '../../../../context/shared/domain/__test-helpers__/UuidMother'; import { Environment } from '@internxt/inxt-js'; +import configStore from '../../../../apps/main/config'; +import * as maxFileSizeRejectionModule from '../../user/file-size-limit/add-max-file-size-rejection'; describe('update-file-to-backup', () => { const uploadContentMock = partialSpyOn(uploadContentToEnvironmentModule, 'uploadContentToEnvironment'); const overrideFileMock = partialSpyOn(overrideFileModule, 'overrideFile'); + const configGetMock = partialSpyOn(configStore, 'get'); + const addMaxFileSizeRejectionMock = partialSpyOn(maxFileSizeRejectionModule, 'addMaxFileSizeRejection'); let abortController: AbortController; @@ -25,6 +29,8 @@ describe('update-file-to-backup', () => { beforeEach(() => { abortController = new AbortController(); + configGetMock.mockReturnValue(0); + addMaxFileSizeRejectionMock.mockClear(); }); it('should update file successfully', async () => { @@ -37,6 +43,22 @@ describe('update-file-to-backup', () => { expect(result.error).toBeUndefined(); }); + it('should skip file update when local upload size validation fails', async () => { + configGetMock.mockReturnValue(100); + + const result = await updateFileToBackup({ ...baseParams, size: 101, signal: abortController.signal }); + + expect(result).toStrictEqual({ data: undefined }); + expect(uploadContentMock).not.toHaveBeenCalled(); + expect(overrideFileMock).not.toHaveBeenCalled(); + expect(addMaxFileSizeRejectionMock).toHaveBeenCalledWith({ + path: baseParams.path, + fileSize: 101, + validation: { allowed: false, reason: 'PLAN_LIMIT_EXCEEDED', maxFileSize: 100, showUpgradeCta: true }, + blockUploadPath: false, + }); + }); + it('should return ABORTED error when signal is already aborted', async () => { abortController.abort(); @@ -76,6 +98,20 @@ describe('update-file-to-backup', () => { expect(result.error?.cause).toBe('UNKNOWN'); }); + it('should skip file when backend rejects override by upload size limit', async () => { + uploadContentMock.mockResolvedValue({ data: BucketEntryIdMother.primitive() }); + overrideFileMock.mockResolvedValue({ error: new DriveServerError('FILE_TOO_BIG', 402) }); + + const result = await updateFileToBackup({ ...baseParams, signal: abortController.signal }); + + expect(result).toStrictEqual({ data: undefined }); + expect(addMaxFileSizeRejectionMock).toHaveBeenCalledWith({ + path: baseParams.path, + fileSize: baseParams.size, + blockUploadPath: false, + }); + }); + it('should return null data when signal is aborted during content upload', async () => { uploadContentMock.mockImplementation(async () => { abortController.abort(); diff --git a/src/backend/features/backup/upload/update-file-to-backup.ts b/src/backend/features/backup/upload/update-file-to-backup.ts index 1d15e2b6c6..8486e1149a 100644 --- a/src/backend/features/backup/upload/update-file-to-backup.ts +++ b/src/backend/features/backup/upload/update-file-to-backup.ts @@ -5,6 +5,9 @@ import { uploadContentToEnvironment } from './upload-content-to-environment'; import { retryWithBackoff } from '../../../../shared/retry-with-backoff'; import { createTransientErrorHandler } from '../../../../backend/common/rate-limit/transient-error-handler'; import { overrideFileToBackend } from './override-file-to-backend'; +import configStore from '../../../../apps/main/config'; +import { addMaxFileSizeRejection } from '../../user/file-size-limit/add-max-file-size-rejection'; +import { validateUploadFileSize } from '../../user/file-size-limit/validate-upload-file-size'; export type UpdateFileParams = { path: string; @@ -16,6 +19,16 @@ export type UpdateFileParams = { }; async function updateFile(file: UpdateFileParams): Promise> { + const validation = validateUploadFileSize({ + size: file.size, + maxUploadFileSize: configStore.get('maxUploadFileSizeInBytes'), + }); + + if (!validation.allowed) { + addMaxFileSizeRejection({ path: file.path, fileSize: file.size, validation, blockUploadPath: false }); + return { data: undefined }; + } + const { data: contentsId, error } = await retryWithBackoff( () => uploadContentToEnvironment({ @@ -49,6 +62,11 @@ async function updateFile(file: UpdateFileParams): Promise { const uploadContentMock = partialSpyOn(uploadContentModule, 'uploadContentToEnvironment'); const createFileToBackendMock = partialSpyOn(createFileModule, 'createFileToBackend'); const deleteFileMock = partialSpyOn(deleteFileModule, 'deleteFileFromStorageByFileId'); + const configGetMock = partialSpyOn(configStore, 'get'); + const addMaxFileSizeRejectionMock = partialSpyOn(maxFileSizeRejectionModule, 'addMaxFileSizeRejection'); let abortController: AbortController; @@ -26,6 +30,8 @@ describe('upload-file-to-backup', () => { beforeEach(() => { abortController = new AbortController(); + configGetMock.mockReturnValue(0); + addMaxFileSizeRejectionMock.mockClear(); }); it('should upload the file content and create metadata on backend successfully', async () => { @@ -40,6 +46,22 @@ describe('upload-file-to-backup', () => { expect(result.error).toBeUndefined(); }); + it('should skip file upload when local upload size validation fails', async () => { + configGetMock.mockReturnValue(100); + + const result = await uploadFileToBackup({ ...baseParams, size: 101, signal: abortController.signal }); + + expect(result).toStrictEqual({ data: null }); + expect(uploadContentMock).not.toHaveBeenCalled(); + expect(createFileToBackendMock).not.toHaveBeenCalled(); + expect(addMaxFileSizeRejectionMock).toHaveBeenCalledWith({ + path: baseParams.path, + fileSize: 101, + validation: { allowed: false, reason: 'PLAN_LIMIT_EXCEEDED', maxFileSize: 100, showUpgradeCta: true }, + blockUploadPath: false, + }); + }); + it('should return error when content upload fails with a non-retryable error', async () => { const uploadError = new DriveDesktopError('UNKNOWN', 'Upload failed'); uploadContentMock.mockResolvedValue({ error: uploadError }); @@ -73,6 +95,23 @@ describe('upload-file-to-backup', () => { expect(deleteFileMock).toHaveBeenCalledWith({ bucketId: baseParams.bucket, fileId: contentsId }); }); + it('should skip file when backend rejects metadata creation by upload size limit', async () => { + const contentsId = 'contents-id-123'; + uploadContentMock.mockResolvedValue({ data: contentsId }); + createFileToBackendMock.mockResolvedValue({ error: new DriveDesktopError('FILE_TOO_BIG', 'File too big') }); + deleteFileMock.mockResolvedValue({ data: true }); + + const result = await uploadFileToBackup({ ...baseParams, signal: abortController.signal }); + + expect(result).toStrictEqual({ data: null }); + expect(deleteFileMock).toHaveBeenCalledWith({ bucketId: baseParams.bucket, fileId: contentsId }); + expect(addMaxFileSizeRejectionMock).toHaveBeenCalledWith({ + path: baseParams.path, + fileSize: baseParams.size, + blockUploadPath: false, + }); + }); + it('should return null data and skip metadata when signal is aborted during content upload', async () => { const contentsId = 'contents-id-123'; uploadContentMock.mockImplementation(async () => { diff --git a/src/backend/features/backup/upload/upload-file-to-backup.ts b/src/backend/features/backup/upload/upload-file-to-backup.ts index 73eefc9d2c..be8771b5c6 100644 --- a/src/backend/features/backup/upload/upload-file-to-backup.ts +++ b/src/backend/features/backup/upload/upload-file-to-backup.ts @@ -8,6 +8,9 @@ import { Result } from '../../../../context/shared/domain/Result'; import { deleteFileFromStorageByFileId } from '../../../../infra/drive-server/services/files/services/delete-file-content-from-bucket'; import { retryWithBackoff } from '../../../../shared/retry-with-backoff'; import { createTransientErrorHandler } from '../../../../backend/common/rate-limit/transient-error-handler'; +import configStore from '../../../../apps/main/config'; +import { addMaxFileSizeRejection } from '../../user/file-size-limit/add-max-file-size-rejection'; +import { validateUploadFileSize } from '../../user/file-size-limit/validate-upload-file-size'; export type UploadFileParams = { path: string; @@ -20,6 +23,25 @@ export type UploadFileParams = { }; async function uploadFile(file: UploadFileParams): Promise> { + const validation = validateUploadFileSize({ + size: file.size, + maxUploadFileSize: configStore.get('maxUploadFileSizeInBytes'), + }); + + if (!validation.allowed) { + addMaxFileSizeRejection({ path: file.path, fileSize: file.size, validation, blockUploadPath: false }); + logger.warn({ + tag: 'BACKUPS', + msg: 'Skipping backup file because it exceeds upload size limit', + path: file.path, + size: file.size, + maxFileSize: validation.maxFileSize, + reason: validation.reason, + showUpgradeCta: validation.showUpgradeCta, + }); + return { data: null }; + } + const { data: contentsId, error } = await retryWithBackoff( () => uploadContentToEnvironment({ @@ -57,6 +79,18 @@ async function uploadFile(file: UploadFileParams): Promise { + const showModalMock = vi.spyOn(modalModule, 'showMaxFileSizeRejectionModal'); + const path = '/Documents/oversized.pdf'; + + beforeEach(() => { + vi.useFakeTimers(); + showModalMock.mockResolvedValue(undefined); + showModalMock.mockClear(); + clearMaxFileSizeRejectionModal(); + clearUploadSizeLimitBlockedPath(path); + clearUploadSizeLimitBlockedPath('/Documents/first.pdf'); + clearUploadSizeLimitBlockedPath('/Documents/second.pdf'); + }); + + afterEach(() => { + clearMaxFileSizeRejectionModal(); + clearUploadSizeLimitBlockedPath(path); + clearUploadSizeLimitBlockedPath('/Documents/first.pdf'); + clearUploadSizeLimitBlockedPath('/Documents/second.pdf'); + vi.useRealTimers(); + }); + + it('should track paths blocked by upload size limit', () => { + markUploadSizeLimitBlockedPath(path); + + expect(isUploadSizeLimitBlockedPath(path)).toBe(true); + }); + + it('should clear blocked paths', () => { + markUploadSizeLimitBlockedPath(path); + + clearUploadSizeLimitBlockedPath(path); + + expect(isUploadSizeLimitBlockedPath(path)).toBe(false); + }); + + it('should show single-file modal after debounce', () => { + addMaxFileSizeRejection({ + path, + fileSize: 101, + validation: { allowed: false, reason: 'PLAN_LIMIT_EXCEEDED', maxFileSize: 100, showUpgradeCta: true }, + }); + + expect(isUploadSizeLimitBlockedPath(path)).toBe(true); + + vi.advanceTimersByTime(1_999); + expect(showModalMock).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + + expect(showModalMock).toHaveBeenCalledWith({ + variant: 'single', + showUpgradeCta: true, + maxFileSize: 100, + fileSize: 101, + }); + }); + + it('should aggregate multiple rejected files within debounce window', () => { + addMaxFileSizeRejection({ + path: '/Documents/first.pdf', + fileSize: 101, + validation: { allowed: false, reason: 'PLAN_LIMIT_EXCEEDED', maxFileSize: 100, showUpgradeCta: true }, + }); + addMaxFileSizeRejection({ + path: '/Documents/second.pdf', + fileSize: 150, + validation: { allowed: false, reason: 'PLAN_LIMIT_EXCEEDED', maxFileSize: 120, showUpgradeCta: true }, + }); + + expect(isUploadSizeLimitBlockedPath('/Documents/first.pdf')).toBe(true); + expect(isUploadSizeLimitBlockedPath('/Documents/second.pdf')).toBe(true); + + vi.advanceTimersByTime(2_000); + + expect(showModalMock).toHaveBeenCalledWith({ + variant: 'multiple', + showUpgradeCta: true, + maxFileSize: 100, + fileSize: 150, + }); + }); + + it('should keep upgrade CTA disabled for absolute cap rejections', () => { + addMaxFileSizeRejection({ + path, + fileSize: 150, + validation: { allowed: false, reason: 'ABSOLUTE_CAP_EXCEEDED', maxFileSize: 100, showUpgradeCta: false }, + }); + + vi.advanceTimersByTime(2_000); + + expect(showModalMock).toHaveBeenCalledWith({ + variant: 'single', + showUpgradeCta: false, + maxFileSize: 100, + fileSize: 150, + }); + }); + + it('should allow backend rejections to skip blocked path tracking', () => { + addMaxFileSizeRejection({ + path, + fileSize: 150, + blockUploadPath: false, + }); + + expect(isUploadSizeLimitBlockedPath(path)).toBe(false); + + vi.advanceTimersByTime(2_000); + + expect(showModalMock).toHaveBeenCalledWith({ + variant: 'single', + showUpgradeCta: true, + maxFileSize: undefined, + fileSize: 150, + }); + }); +}); diff --git a/src/backend/features/user/file-size-limit/add-max-file-size-rejection.ts b/src/backend/features/user/file-size-limit/add-max-file-size-rejection.ts new file mode 100644 index 0000000000..c3702f3289 --- /dev/null +++ b/src/backend/features/user/file-size-limit/add-max-file-size-rejection.ts @@ -0,0 +1,114 @@ +import { MODAL_DEBOUNCE_MS } from './constants'; +import { showMaxFileSizeRejectionModal } from './show-max-file-size-rejection-modal'; +import { type UploadFileSizeValidation } from './validate-upload-file-size'; + +const uploadSizeLimitBlockedPaths = new Set(); + +type MaxFileSizeRejectionModalState = { + rejectedFilesCount: number; + showUpgradeCta: boolean; + maxFileSize?: number; + fileSize?: number; + timeout: NodeJS.Timeout; +}; + +type MaxFileSizeRejection = { + validation?: Extract; + fileSize: number; + path: string; + blockUploadPath?: boolean; +}; + +type MaxFileSizeRejectionModalDraft = Omit; + +let state: MaxFileSizeRejectionModalState | undefined; + +export function markUploadSizeLimitBlockedPath(path: string): void { + uploadSizeLimitBlockedPaths.add(path); +} + +export function isUploadSizeLimitBlockedPath(path: string): boolean { + return uploadSizeLimitBlockedPaths.has(path); +} + +export function clearUploadSizeLimitBlockedPath(path: string): void { + uploadSizeLimitBlockedPaths.delete(path); +} + +export function addMaxFileSizeRejection(rejection: MaxFileSizeRejection): void { + if (rejection.blockUploadPath ?? true) { + markUploadSizeLimitBlockedPath(rejection.path); + } + if (state) clearTimeout(state.timeout); + + state = { + ...saveMaxFileSizeRejection({ state, rejection }), + timeout: setTimeout(showMaxFileSizeModal, MODAL_DEBOUNCE_MS), + }; +} + +export function clearMaxFileSizeRejectionModal(): void { + if (state) clearTimeout(state.timeout); + state = undefined; +} + +function saveMaxFileSizeRejection({ + state, + rejection, +}: { + state: MaxFileSizeRejectionModalState | undefined; + rejection: MaxFileSizeRejection; +}): MaxFileSizeRejectionModalDraft { + const shouldShowUpgradeCta = rejection.validation?.showUpgradeCta ?? true; + + return { + rejectedFilesCount: (state?.rejectedFilesCount ?? 0) + 1, + showUpgradeCta: Boolean(state?.showUpgradeCta || shouldShowUpgradeCta), + maxFileSize: getMaxFileSizeToDisplay({ + currentMaxFileSizeToDisplay: state?.maxFileSize, + newRejectedFileMaxFileSize: rejection.validation?.maxFileSize, + }), + fileSize: getRejectedFileSizeToShow({ currentFileSize: state?.fileSize, rejection, shouldShowUpgradeCta }), + }; +} + +function showMaxFileSizeModal(): void { + if (!state) { + return; + } + + void showMaxFileSizeRejectionModal({ + variant: state.rejectedFilesCount === 1 ? 'single' : 'multiple', + showUpgradeCta: state.showUpgradeCta, + maxFileSize: state.maxFileSize, + fileSize: state.fileSize, + }); + + state = undefined; +} +function getMaxFileSizeToDisplay({ + currentMaxFileSizeToDisplay, + newRejectedFileMaxFileSize, +}: { + currentMaxFileSizeToDisplay?: number; + newRejectedFileMaxFileSize?: number; +}): number | undefined { + if (!newRejectedFileMaxFileSize) return currentMaxFileSizeToDisplay; + if (!currentMaxFileSizeToDisplay) return newRejectedFileMaxFileSize; + + return Math.min(currentMaxFileSizeToDisplay, newRejectedFileMaxFileSize); +} + +function getRejectedFileSizeToShow({ + currentFileSize, + rejection, + shouldShowUpgradeCta, +}: { + currentFileSize?: number; + rejection: MaxFileSizeRejection; + shouldShowUpgradeCta: boolean; +}): number { + if (shouldShowUpgradeCta) return Math.max(currentFileSize ?? 0, rejection.fileSize); + + return currentFileSize ?? rejection.fileSize; +} diff --git a/src/backend/features/user/file-size-limit/calculate-projected-write-size.test.ts b/src/backend/features/user/file-size-limit/calculate-projected-write-size.test.ts new file mode 100644 index 0000000000..a612e84172 --- /dev/null +++ b/src/backend/features/user/file-size-limit/calculate-projected-write-size.test.ts @@ -0,0 +1,19 @@ +import { calculateProjectedWriteSize } from './calculate-projected-write-size'; + +describe('calculateProjectedWriteSize', () => { + it('should return offset plus incoming bytes when the write extends the file', () => { + expect(calculateProjectedWriteSize({ currentSize: 10, offset: 10, incomingBytes: 5 })).toBe(15); + }); + + it('should keep current size when the write stays inside the existing file', () => { + expect(calculateProjectedWriteSize({ currentSize: 10, offset: 2, incomingBytes: 5 })).toBe(10); + }); + + it('should account for sparse writes at a large offset', () => { + expect(calculateProjectedWriteSize({ currentSize: 0, offset: 1_000, incomingBytes: 5 })).toBe(1_005); + }); + + it('should account for out-of-order writes after a larger write already happened', () => { + expect(calculateProjectedWriteSize({ currentSize: 1_005, offset: 0, incomingBytes: 5 })).toBe(1_005); + }); +}); diff --git a/src/backend/features/user/file-size-limit/calculate-projected-write-size.ts b/src/backend/features/user/file-size-limit/calculate-projected-write-size.ts new file mode 100644 index 0000000000..f621cbb5dc --- /dev/null +++ b/src/backend/features/user/file-size-limit/calculate-projected-write-size.ts @@ -0,0 +1,21 @@ +type Props = { + currentSize: number; + offset: number; + incomingBytes: number; +}; + +/** + * Calculates the logical file size that would exist after accepting a FUSE write. + * + * FUSE write calls are chunk based: the app receives "write these bytes at this + * offset", not "here is the full source file". For copies into the mounted drive + * we also cannot rely on the origin path or origin size being available. + * + * Because writes can be sequential, out of order, or sparse, upload-size-limit + * validation must use the resulting logical size rather than the current chunk + * length. A small chunk written at a large offset can still create an oversized + * file. + */ +export function calculateProjectedWriteSize({ currentSize, offset, incomingBytes }: Props): number { + return Math.max(currentSize, offset + incomingBytes); +} diff --git a/src/backend/features/user/file-size-limit/constants.ts b/src/backend/features/user/file-size-limit/constants.ts new file mode 100644 index 0000000000..fe857a5538 --- /dev/null +++ b/src/backend/features/user/file-size-limit/constants.ts @@ -0,0 +1,2 @@ +export const ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT = 100 * 1024 * 1024 * 1024; +export const MODAL_DEBOUNCE_MS = 2_000; diff --git a/src/backend/features/user/file-size-limit/index.ts b/src/backend/features/user/file-size-limit/index.ts new file mode 100644 index 0000000000..4cf69b6fa7 --- /dev/null +++ b/src/backend/features/user/file-size-limit/index.ts @@ -0,0 +1,15 @@ +export { + addMaxFileSizeRejection, + clearMaxFileSizeRejectionModal, + clearUploadSizeLimitBlockedPath, + isUploadSizeLimitBlockedPath, + markUploadSizeLimitBlockedPath, +} from './add-max-file-size-rejection'; +export { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from './constants'; +export { calculateProjectedWriteSize } from './calculate-projected-write-size'; +export { preserveRejectedFileSizeTooBig } from './rejected-file-size-too-big/preserve-rejected-file-size-too-big'; +export { resolveUserFileSizeLimit } from './resolve-user-file-size-limit'; +export { showMaxFileSizeRejectionModal } from './show-max-file-size-rejection-modal'; +export type { MaxFileSizeRejectionModalPayload } from './show-max-file-size-rejection-modal'; +export type { UploadFileSizeValidation } from './validate-upload-file-size'; +export { validateUploadFileSize } from './validate-upload-file-size'; diff --git a/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.test.ts b/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.test.ts new file mode 100644 index 0000000000..42d9d60ced --- /dev/null +++ b/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.test.ts @@ -0,0 +1,188 @@ +import { constants } from 'node:fs'; +import { copyFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { randomInt } from 'node:crypto'; +import { + copyWithoutOverwriting, + createCopyPath, + createLastResortCopyPath, + createRecoveredPath, + preserveRejectedFileSizeTooBig, +} from './preserve-rejected-file-size-too-big'; + +vi.mock('node:fs/promises', () => ({ + copyFile: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('node:crypto', () => ({ + randomInt: vi.fn(() => 123456), +})); + +const copyFileMock = vi.mocked(copyFile); +const mkdirMock = vi.mocked(mkdir); +const randomIntMock = vi.mocked(randomInt as (max: number) => number); +const recoveryRoot = '/rejected-files-size-too-big'; +const temporalContentPath = '/tmp/internxt-drive-tmp/temporal-content'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + copyFileMock.mockResolvedValue(undefined); + mkdirMock.mockResolvedValue(undefined); + randomIntMock.mockReturnValue(123456); +}); + +describe('preserve-rejected-file-size-too-big', () => { + describe('preserveRejectedFileSizeTooBig', () => { + it('should preserve the rejected file inside its original path structure', async () => { + const result = await preserveRejectedFileSizeTooBig({ + rootFolder: recoveryRoot, + originalPath: '/fotos/a/b/c/photo.jpg', + temporalContentPath, + size: 36_000_000, + }); + + const expectedFolderPath = path.join(recoveryRoot, 'fotos', 'a', 'b', 'c'); + const expectedFilePath = path.join(expectedFolderPath, 'photo.jpg'); + + expect(mkdirMock).toHaveBeenCalledWith(expectedFolderPath, { recursive: true }); + expect(copyFileMock).toHaveBeenCalledWith(temporalContentPath, expectedFilePath, constants.COPYFILE_EXCL); + expect(result).toStrictEqual({ data: { folderPath: expectedFolderPath, filePath: expectedFilePath } }); + }); + + it('should reuse existing recovered folders and only create missing path segments', async () => { + const result = await preserveRejectedFileSizeTooBig({ + rootFolder: recoveryRoot, + originalPath: '/fotos/a/b/c/beach/photo.jpg', + temporalContentPath, + size: 12_000_000, + }); + + const expectedFolderPath = path.join(recoveryRoot, 'fotos', 'a', 'b', 'c', 'beach'); + const expectedFilePath = path.join(expectedFolderPath, 'photo.jpg'); + + expect(mkdirMock).toHaveBeenCalledWith(expectedFolderPath, { recursive: true }); + expect(result).toStrictEqual({ data: { folderPath: expectedFolderPath, filePath: expectedFilePath } }); + }); + + it('should return an error when the recovery folder cannot be created', async () => { + const error = new Error('mkdir failed'); + mkdirMock.mockRejectedValueOnce(error); + + const result = await preserveRejectedFileSizeTooBig({ + rootFolder: recoveryRoot, + originalPath: '/fotos/a/b/c/photo.jpg', + temporalContentPath, + size: 36_000_000, + }); + + expect(result).toStrictEqual({ error }); + expect(copyFileMock).not.toHaveBeenCalled(); + }); + + it('should return an error when the temporal file cannot be copied', async () => { + const error = new Error('copy failed'); + copyFileMock.mockRejectedValueOnce(error); + + const result = await preserveRejectedFileSizeTooBig({ + rootFolder: recoveryRoot, + originalPath: '/fotos/a/b/c/photo.jpg', + temporalContentPath, + size: 36_000_000, + }); + + expect(result).toStrictEqual({ error }); + }); + }); + + describe('createRecoveredPath', () => { + it('should recover a file under the rejected-files root preserving its path segments', () => { + expect(createRecoveredPath({ rootFolder: recoveryRoot, originalPath: '/fotos/a/b/c/photo.jpg' })).toBe( + path.join(recoveryRoot, 'fotos', 'a', 'b', 'c', 'photo.jpg'), + ); + }); + + it('should preserve the file inside the rejected-files root when the original path has no valid segments', () => { + expect(createRecoveredPath({ rootFolder: recoveryRoot, originalPath: '/' })).toBe( + path.join(recoveryRoot, 'rejected-file'), + ); + }); + }); + + describe('copyWithoutOverwriting', () => { + it('should copy to the target path when it does not exist', async () => { + const targetPath = path.join(recoveryRoot, 'photo.jpg'); + + await expect(copyWithoutOverwriting({ sourcePath: temporalContentPath, targetPath })).resolves.toStrictEqual({ + data: targetPath, + }); + expect(copyFileMock).toHaveBeenCalledWith(temporalContentPath, targetPath, constants.COPYFILE_EXCL); + }); + + it('should copy to the first available copy path when the target already exists', async () => { + const targetPath = path.join(recoveryRoot, 'photo.jpg'); + const copyPath = path.join(recoveryRoot, 'photo (copy 1).jpg'); + copyFileMock.mockRejectedValueOnce(createFileAlreadyExistsError()).mockResolvedValueOnce(undefined); + + await expect(copyWithoutOverwriting({ sourcePath: temporalContentPath, targetPath })).resolves.toStrictEqual({ + data: copyPath, + }); + expect(copyFileMock).toHaveBeenNthCalledWith(1, temporalContentPath, targetPath, constants.COPYFILE_EXCL); + expect(copyFileMock).toHaveBeenNthCalledWith(2, temporalContentPath, copyPath, constants.COPYFILE_EXCL); + }); + + it('should return the copy error when copy fails for a reason different from an existing target', async () => { + const error = new Error('copy failed'); + copyFileMock.mockRejectedValueOnce(error); + + await expect( + copyWithoutOverwriting({ sourcePath: temporalContentPath, targetPath: path.join(recoveryRoot, 'photo.jpg') }), + ).resolves.toStrictEqual({ error }); + }); + + it('should use a timestamp and random number on the last copy attempt', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171); + let calls = 0; + copyFileMock.mockImplementation(async () => { + calls += 1; + if (calls < 102) { + throw createFileAlreadyExistsError(); + } + }); + + const targetPath = path.join(recoveryRoot, 'photo.jpg'); + const lastResortPath = path.join(recoveryRoot, 'photo (copy 1717171717171-123456).jpg'); + + await expect(copyWithoutOverwriting({ sourcePath: temporalContentPath, targetPath })).resolves.toStrictEqual({ + data: lastResortPath, + }); + expect(copyFileMock).toHaveBeenCalledTimes(102); + expect(copyFileMock).toHaveBeenLastCalledWith(temporalContentPath, lastResortPath, constants.COPYFILE_EXCL); + }); + }); + + describe('createCopyPath', () => { + it('should append the copy number before the file extension', () => { + expect(createCopyPath({ targetPath: path.join(recoveryRoot, 'fotos', 'photo.jpg'), copyNumber: 3 })).toBe( + path.join(recoveryRoot, 'fotos', 'photo (copy 3).jpg'), + ); + }); + }); + + describe('createLastResortCopyPath', () => { + it('should append a timestamp and random number before the file extension', () => { + vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171); + + expect(createLastResortCopyPath({ targetPath: path.join(recoveryRoot, 'fotos', 'photo.jpg') })).toBe( + path.join(recoveryRoot, 'fotos', 'photo (copy 1717171717171-123456).jpg'), + ); + }); + }); +}); + +function createFileAlreadyExistsError(): NodeJS.ErrnoException { + const error = new Error('File already exists') as NodeJS.ErrnoException; + error.code = 'EEXIST'; + return error; +} diff --git a/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.ts b/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.ts new file mode 100644 index 0000000000..8c362bdb21 --- /dev/null +++ b/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.ts @@ -0,0 +1,124 @@ +import { constants } from 'node:fs'; +import { copyFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { randomInt } from 'node:crypto'; +import { PATHS } from '../../../../../core/electron/paths'; +import { Result } from '../../../../../context/shared/domain/Result'; + +type Props = { + originalPath: string; + temporalContentPath: string; + size: number; + rootFolder?: string; +}; + +type PreservedRejectedFileSizeTooBig = { + folderPath: string; + filePath: string; +}; + +export async function preserveRejectedFileSizeTooBig({ + originalPath, + temporalContentPath, + rootFolder = PATHS.REJECTED_FILES_SIZE_TOO_BIG, +}: Props): Promise> { + const recoveredPath = createRecoveredPath({ rootFolder, originalPath }); + const folderPath = path.dirname(recoveredPath); + try { + await mkdir(folderPath, { recursive: true }); + const { data: filePath, error } = await copyWithoutOverwriting({ + sourcePath: temporalContentPath, + targetPath: recoveredPath, + }); + + if (error) { + return { error }; + } + + return { data: { folderPath: path.dirname(filePath), filePath } }; + } catch (error) { + return { error: error instanceof Error ? error : new Error(`Failed to create directory: ${error}`) }; + } +} + +export function createRecoveredPath({ + rootFolder, + originalPath, +}: { + rootFolder: string; + originalPath: string; +}): string { + const originalPathSegments = originalPath + .split('/') + .filter((segment) => segment && segment !== '.' && segment !== '..'); + + return path.join(rootFolder, ...(originalPathSegments.length ? originalPathSegments : ['rejected-file'])); +} + +export async function copyWithoutOverwriting({ + sourcePath, + targetPath, +}: { + sourcePath: string; + targetPath: string; +}): Promise> { + for (let copyNumber = 0; copyNumber <= 101; copyNumber += 1) { + const candidatePath = createCandidatePath({ targetPath, copyNumber }); + // eslint-disable-next-line no-await-in-loop + const { data: copiedPath, error } = await tryCopyWithoutOverwriting({ sourcePath, targetPath: candidatePath }); + + if (error) { + return { error }; + } + + if (copiedPath) { + return { data: copiedPath }; + } + } + return { + error: new Error(`Unable to preserve rejected file because all copy candidates already exist: ${targetPath}`), + }; +} + +function createCandidatePath({ targetPath, copyNumber }: { targetPath: string; copyNumber: number }): string { + if (copyNumber === 0) { + return targetPath; + } + if (copyNumber <= 100) { + return createCopyPath({ targetPath, copyNumber }); + } + return createLastResortCopyPath({ targetPath }); +} + +async function tryCopyWithoutOverwriting({ + sourcePath, + targetPath, +}: { + sourcePath: string; + targetPath: string; +}): Promise> { + try { + await copyFile(sourcePath, targetPath, constants.COPYFILE_EXCL); + return { data: targetPath }; + } catch (error) { + if (isFileAlreadyExistsError(error)) { + return { data: undefined }; + } + return { error: error instanceof Error ? error : new Error(`Failed to copy file: ${error}`) }; + } +} + +export function createCopyPath({ targetPath, copyNumber }: { targetPath: string; copyNumber: number }): string { + const parsedPath = path.parse(targetPath); + return path.join(parsedPath.dir, `${parsedPath.name} (copy ${copyNumber})${parsedPath.ext}`); +} + +export function createLastResortCopyPath({ targetPath }: { targetPath: string }): string { + const parsedPath = path.parse(targetPath); + const timestamp = Date.now(); + return path.join(parsedPath.dir, `${parsedPath.name} (copy ${timestamp}-${randomInt(1_000_000)})${parsedPath.ext}`); +} + +function isFileAlreadyExistsError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'EEXIST'; +} diff --git a/src/backend/features/user/file-size-limit/show-max-file-size-rejection-modal.ts b/src/backend/features/user/file-size-limit/show-max-file-size-rejection-modal.ts new file mode 100644 index 0000000000..01daa32f38 --- /dev/null +++ b/src/backend/features/user/file-size-limit/show-max-file-size-rejection-modal.ts @@ -0,0 +1,15 @@ +import { logger } from '@internxt/drive-desktop-core/build/backend'; + +export type MaxFileSizeRejectionModalPayload = { + variant: 'single' | 'multiple'; + showUpgradeCta: boolean; + maxFileSize?: number; + fileSize?: number; +}; + +export async function showMaxFileSizeRejectionModal(payload: MaxFileSizeRejectionModalPayload): Promise { + logger.warn({ + msg: 'TODO: Showing max file size rejection modal', + payload, + }); +} diff --git a/src/backend/features/user/file-size-limit/upload-size-limit-error.ts b/src/backend/features/user/file-size-limit/upload-size-limit-error.ts new file mode 100644 index 0000000000..b5ce4d63ae --- /dev/null +++ b/src/backend/features/user/file-size-limit/upload-size-limit-error.ts @@ -0,0 +1,6 @@ +export class UploadSizeLimitError extends Error { + constructor() { + super('UPLOAD_SIZE_LIMIT_EXCEEDED'); + this.name = 'UploadSizeLimitError'; + } +} diff --git a/src/backend/features/user/file-size-limit/validate-upload-file-size.test.ts b/src/backend/features/user/file-size-limit/validate-upload-file-size.test.ts new file mode 100644 index 0000000000..77daba79f3 --- /dev/null +++ b/src/backend/features/user/file-size-limit/validate-upload-file-size.test.ts @@ -0,0 +1,44 @@ +import { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from './constants'; +import { validateUploadFileSize } from './validate-upload-file-size'; + +describe('validateUploadFileSize', () => { + it('should pass when file size is under the stored limit', () => { + expect(validateUploadFileSize({ size: 99, maxUploadFileSize: 100 })).toStrictEqual({ allowed: true }); + }); + + it('should pass when file size is equal to the stored limit', () => { + expect(validateUploadFileSize({ size: 100, maxUploadFileSize: 100 })).toStrictEqual({ allowed: true }); + }); + + it('should return plan limit error when file size is over the stored limit', () => { + expect(validateUploadFileSize({ size: 101, maxUploadFileSize: 100 })).toStrictEqual({ + allowed: false, + reason: 'PLAN_LIMIT_EXCEEDED', + maxFileSize: 100, + showUpgradeCta: true, + }); + }); + + it('should pass when stored limit is 0 and file does not exceed absolute cap', () => { + expect(validateUploadFileSize({ size: 101, maxUploadFileSize: 0 })).toStrictEqual({ allowed: true }); + }); + + it('should pass when stored limit is null and file does not exceed absolute cap', () => { + expect(validateUploadFileSize({ size: 101, maxUploadFileSize: null })).toStrictEqual({ allowed: true }); + }); + + it('should return absolute cap error even when stored limit is unavailable', () => { + expect( + validateUploadFileSize({ size: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT + 1, maxUploadFileSize: null }), + ).toStrictEqual({ + allowed: false, + reason: 'ABSOLUTE_CAP_EXCEEDED', + maxFileSize: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT, + showUpgradeCta: false, + }); + }); + + it('should ignore zero-byte files', () => { + expect(validateUploadFileSize({ size: 0, maxUploadFileSize: 100 })).toStrictEqual({ allowed: true }); + }); +}); diff --git a/src/backend/features/user/file-size-limit/validate-upload-file-size.ts b/src/backend/features/user/file-size-limit/validate-upload-file-size.ts new file mode 100644 index 0000000000..f192500794 --- /dev/null +++ b/src/backend/features/user/file-size-limit/validate-upload-file-size.ts @@ -0,0 +1,37 @@ +import { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from './constants'; + +export type UploadFileSizeValidation = + | { allowed: true } + | { + allowed: false; + reason: 'PLAN_LIMIT_EXCEEDED' | 'ABSOLUTE_CAP_EXCEEDED'; + maxFileSize: number; + showUpgradeCta: boolean; + }; + +type Props = { + size: number; + maxUploadFileSize?: number | null; +}; + +export function validateUploadFileSize({ size, maxUploadFileSize }: Props): UploadFileSizeValidation { + if (size > ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT) { + return { + allowed: false, + reason: 'ABSOLUTE_CAP_EXCEEDED', + maxFileSize: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT, + showUpgradeCta: false, + }; + } + + if (maxUploadFileSize && size > maxUploadFileSize) { + return { + allowed: false, + reason: 'PLAN_LIMIT_EXCEEDED', + maxFileSize: maxUploadFileSize, + showUpgradeCta: true, + }; + } + + return { allowed: true }; +} diff --git a/src/backend/features/virtual-drive/services/operations/release.service.test.ts b/src/backend/features/virtual-drive/services/operations/release.service.test.ts index 5baabfb415..67f713cf8e 100644 --- a/src/backend/features/virtual-drive/services/operations/release.service.test.ts +++ b/src/backend/features/virtual-drive/services/operations/release.service.test.ts @@ -10,7 +10,13 @@ import { File, FileAttributes } from '../../../../../context/virtual-drive/files import { ContentsId } from '../../../../../apps/main/database/entities/DriveFile'; import { FileStatuses } from '../../../../../context/virtual-drive/files/domain/FileStatus'; import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; +import { UploadSizeLimitError } from '../../../user/file-size-limit/upload-size-limit-error'; import { call, calls } from '../../../../../../tests/vitest/utils.helper'; +import { + clearUploadSizeLimitBlockedPath, + isUploadSizeLimitBlockedPath, + markUploadSizeLimitBlockedPath, +} from '../../../user/file-size-limit/add-max-file-size-rejection'; const fileAttrs: FileAttributes = { id: 1, @@ -47,6 +53,7 @@ describe('release', () => { container.get.calledWith(TemporalFileDeleter).mockReturnValue(deleter); container.get.calledWith(FirstsFileSearcher).mockReturnValue(fileSearcher); fileSearcher.run.mockResolvedValue(undefined); + clearUploadSizeLimitBlockedPath('/Documents/report.pdf'); }); describe('when no temporal file is found', () => { @@ -105,6 +112,32 @@ describe('release', () => { ]); }); + it('should delete partial temporal file and skip upload when write already blocked it by upload size limit', async () => { + const temporalFile = createTemporalFile('/Documents/report.pdf'); + finder.run.mockResolvedValue(temporalFile); + markUploadSizeLimitBlockedPath('/Documents/report.pdf'); + + const { data, error } = await release({ path: '/Documents/report.pdf', processName: 'cat', container }); + + expect(error).toBeUndefined(); + expect(data).toBeUndefined(); + calls(uploader.run).toHaveLength(0); + calls(fileSearcher.run).toHaveLength(0); + call(deleter.run).toStrictEqual('/Documents/report.pdf'); + expect(isUploadSizeLimitBlockedPath('/Documents/report.pdf')).toBe(false); + }); + + it('should preserve the temporal file and return success when upload is rejected by upload size limit during upload preflight', async () => { + finder.run.mockResolvedValue(createTemporalFile('/Documents/report.pdf')); + uploader.run.mockRejectedValue(new UploadSizeLimitError()); + + const { data, error } = await release({ path: '/Documents/report.pdf', processName: 'cat', container }); + + expect(error).toBeUndefined(); + expect(data).toBeUndefined(); + calls(deleter.run).toHaveLength(0); + }); + it('should delete the file and return EIO when upload fails', async () => { finder.run.mockResolvedValue(createTemporalFile('/Documents/report.pdf')); uploader.run.mockRejectedValue(new Error('Network error')); diff --git a/src/backend/features/virtual-drive/services/operations/release.service.ts b/src/backend/features/virtual-drive/services/operations/release.service.ts index f207f0f788..c08b9dea00 100644 --- a/src/backend/features/virtual-drive/services/operations/release.service.ts +++ b/src/backend/features/virtual-drive/services/operations/release.service.ts @@ -7,7 +7,12 @@ import { TemporalFileUploader } from '../../../../../context/storage/TemporalFil import { TemporalFileDeleter } from '../../../../../context/storage/TemporalFiles/application/deletion/TemporalFileDeleter'; import { FirstsFileSearcher } from '../../../../../context/virtual-drive/files/application/search/FirstsFileSearcher'; import { FileStatuses } from '../../../../../context/virtual-drive/files/domain/FileStatus'; +import { UploadSizeLimitError } from '../../../user/file-size-limit/upload-size-limit-error'; +import { + clearUploadSizeLimitBlockedPath, + isUploadSizeLimitBlockedPath, +} from '../../../user/file-size-limit/add-max-file-size-rejection'; type Props = { path: string; processName: string; @@ -38,10 +43,21 @@ export async function release({ path, processName, container }: Props): Promise< return { data: undefined }; } + if (isUploadSizeLimitBlockedPath(path)) { + logger.warn({ + msg: '[Release] Upload size limit blocked file detected, deleting partial temporal file without upload', + path, + processName, + }); + await container.get(TemporalFileDeleter).run(path); + return { data: undefined }; + } + if (uploadsInProgress.has(path)) { logger.debug({ msg: '[Release] Upload already in progress, skipping duplicate release', path, processName }); return { data: undefined }; } + uploadsInProgress.add(path); try { @@ -54,6 +70,16 @@ export async function release({ path, processName, container }: Props): Promise< logger.debug({ msg: '[Release] Temporal file uploaded', path, processName }); return { data: undefined }; } catch (uploadError) { + if (uploadError instanceof UploadSizeLimitError) { + logger.warn({ + msg: '[Release] Upload size limit exceeded during upload preflight, preserving temporal file without upload', + error: uploadError, + path, + processName, + }); + return { data: undefined }; + } + logger.error({ msg: '[Release] Upload failed, deleting temporal file', error: uploadError, path, processName }); await container.get(TemporalFileDeleter).run(path); return { error: new FuseIOError('Upload failed due to insufficient storage or network issues.') }; @@ -63,5 +89,7 @@ export async function release({ path, processName, container }: Props): Promise< } catch (err: unknown) { logger.error({ msg: '[Release] Unexpected error', error: err, path, processName }); return { error: new FuseIOError('An unexpected error occurred during file release.') }; + } finally { + clearUploadSizeLimitBlockedPath(path); } } diff --git a/src/backend/features/virtual-drive/services/operations/write.service.test.ts b/src/backend/features/virtual-drive/services/operations/write.service.test.ts index 0a299f2fce..0245708416 100644 --- a/src/backend/features/virtual-drive/services/operations/write.service.test.ts +++ b/src/backend/features/virtual-drive/services/operations/write.service.test.ts @@ -1,25 +1,45 @@ import { mockDeep } from 'vitest-mock-extended'; import { Container } from 'diod'; +import { loggerMock } from '../../../../../../tests/vitest/mocks.helper'; +import { partialSpyOn } from '../../../../../../tests/vitest/utils.helper'; +import configStore from '../../../../../apps/main/config'; +import { clearMaxFileSizeRejectionModal } from '../../../user/file-size-limit/add-max-file-size-rejection'; +import { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from '../../../user/file-size-limit/constants'; import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; import { TemporalFileCreator } from '../../../../../context/storage/TemporalFiles/application/creation/TemporalFileCreator'; import { TemporalFileByPathFinder } from '../../../../../context/storage/TemporalFiles/application/find/TemporalFileByPathFinder'; import { TemporalFileWriter } from '../../../../../context/storage/TemporalFiles/application/write/TemporalFileWriter'; import { TemporalFile } from '../../../../../context/storage/TemporalFiles/domain/TemporalFile'; +import { TemporalFilePath } from '../../../../../context/storage/TemporalFiles/domain/TemporalFilePath'; +import { TemporalFileSize } from '../../../../../context/storage/TemporalFiles/domain/TemporalFileSize'; +import { + clearUploadSizeLimitBlockedPath, + isUploadSizeLimitBlockedPath, + markUploadSizeLimitBlockedPath, +} from '../../../user/file-size-limit/add-max-file-size-rejection'; import { write } from './write.service'; describe('write', () => { + const configGetMock = partialSpyOn(configStore, 'get'); let container: ReturnType>; const temporalFileWriter = mockDeep(); const temporalFileByPathFinder = mockDeep(); const temporalFileCreator = mockDeep(); beforeEach(() => { - vi.restoreAllMocks(); + configGetMock.mockReturnValue(100); container = mockDeep(); container.get.calledWith(TemporalFileWriter).mockReturnValue(temporalFileWriter); container.get.calledWith(TemporalFileByPathFinder).mockReturnValue(temporalFileByPathFinder); container.get.calledWith(TemporalFileCreator).mockReturnValue(temporalFileCreator); temporalFileByPathFinder.run.mockResolvedValue(undefined); + clearUploadSizeLimitBlockedPath('/some/file.txt'); + clearUploadSizeLimitBlockedPath('/.test-test-file.txt.swp'); + clearMaxFileSizeRejectionModal(); + }); + + afterEach(() => { + clearMaxFileSizeRejectionModal(); }); it('should write bytes into temporal file and return written length', async () => { @@ -39,6 +59,104 @@ describe('write', () => { expect(temporalFileWriter.run).toHaveBeenCalledWith('/some/file.txt', content, content.length, 7); }); + it('should reject writes when projected size exceeds the stored upload limit', async () => { + const content = Buffer.alloc(5); + vi.spyOn(TemporalFile, 'isTemporaryPath').mockReturnValue(false); + temporalFileByPathFinder.run.mockResolvedValue( + TemporalFile.create(new TemporalFilePath('/some/file.txt'), new TemporalFileSize(10)), + ); + + const { data, error } = await write({ + path: '/some/file.txt', + content, + offset: 96, + container, + }); + + expect(data).toBeUndefined(); + expect(error?.code).toBe(FuseCodes.EFBIG); + expect(temporalFileWriter.run).not.toHaveBeenCalled(); + expect(isUploadSizeLimitBlockedPath('/some/file.txt')).toBe(true); + expect(loggerMock.warn).toHaveBeenCalledWith( + expect.objectContaining({ + tag: 'SYNC-ENGINE', + msg: 'File size exceeds upload limit', + path: '/some/file.txt', + size: 101, + maxFileSize: 100, + reason: 'PLAN_LIMIT_EXCEEDED', + showUpgradeCta: true, + }), + ); + }); + + it('should reject already blocked paths without writing more bytes', async () => { + const content = Buffer.alloc(5); + vi.spyOn(TemporalFile, 'isTemporaryPath').mockReturnValue(false); + markUploadSizeLimitBlockedPath('/some/file.txt'); + + const { data, error } = await write({ + path: '/some/file.txt', + content, + offset: 0, + container, + }); + + expect(data).toBeUndefined(); + expect(error?.code).toBe(FuseCodes.EFBIG); + expect(temporalFileByPathFinder.run).not.toHaveBeenCalled(); + expect(temporalFileWriter.run).not.toHaveBeenCalled(); + expect(loggerMock.warn).not.toHaveBeenCalled(); + }); + + it('should reject writes over the absolute cap when max upload file size is unavailable', async () => { + configGetMock.mockReturnValue(undefined); + const content = Buffer.alloc(5); + vi.spyOn(TemporalFile, 'isTemporaryPath').mockReturnValue(false); + temporalFileByPathFinder.run.mockResolvedValue( + TemporalFile.create(new TemporalFilePath('/some/file.txt'), new TemporalFileSize(10)), + ); + + const { data, error } = await write({ + path: '/some/file.txt', + content, + offset: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT + 1, + container, + }); + + expect(data).toBeUndefined(); + expect(error?.code).toBe(FuseCodes.EFBIG); + expect(temporalFileWriter.run).not.toHaveBeenCalled(); + expect(loggerMock.warn).toHaveBeenCalledWith( + expect.objectContaining({ + tag: 'SYNC-ENGINE', + msg: 'File size exceeds upload limit', + path: '/some/file.txt', + size: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT + 6, + maxFileSize: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT, + reason: 'ABSOLUTE_CAP_EXCEEDED', + showUpgradeCta: false, + }), + ); + }); + + it('should allow writes when max upload file size is unavailable', async () => { + configGetMock.mockReturnValue(undefined); + const content = Buffer.alloc(5); + vi.spyOn(TemporalFile, 'isTemporaryPath').mockReturnValue(false); + + const { data, error } = await write({ + path: '/some/file.txt', + content, + offset: 1_000, + container, + }); + + expect(error).toBeUndefined(); + expect(data).toBe(content.length); + expect(temporalFileWriter.run).toHaveBeenCalledWith('/some/file.txt', content, content.length, 1_000); + }); + it('should create auxiliary temporal file on first write when missing', async () => { const content = Buffer.from('hello'); vi.spyOn(TemporalFile, 'isTemporaryPath').mockReturnValue(true); diff --git a/src/backend/features/virtual-drive/services/operations/write.service.ts b/src/backend/features/virtual-drive/services/operations/write.service.ts index bead71ddc4..aeb0658d80 100644 --- a/src/backend/features/virtual-drive/services/operations/write.service.ts +++ b/src/backend/features/virtual-drive/services/operations/write.service.ts @@ -1,9 +1,18 @@ import { logger } from '@internxt/drive-desktop-core/build/backend'; import { Container } from 'diod'; +import configStore from '../../../../../apps/main/config'; import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; import { FuseError } from '../../../../../apps/drive/fuse/callbacks/FuseErrors'; import { Result } from '../../../../../context/shared/domain/Result'; +import { TemporalFileByPathFinder } from '../../../../../context/storage/TemporalFiles/application/find/TemporalFileByPathFinder'; import { TemporalFileWriter } from '../../../../../context/storage/TemporalFiles/application/write/TemporalFileWriter'; +import { TemporalFile } from '../../../../../context/storage/TemporalFiles/domain/TemporalFile'; +import { + addMaxFileSizeRejection, + isUploadSizeLimitBlockedPath, +} from '../../../user/file-size-limit/add-max-file-size-rejection'; +import { calculateProjectedWriteSize } from '../../../user/file-size-limit/calculate-projected-write-size'; +import { validateUploadFileSize } from '../../../user/file-size-limit/validate-upload-file-size'; import { ensureTemporalFileExistsForAuxiliaryPath } from './ensure-temporal-file-exists-for-auxiliary-path'; type WritePops = { @@ -16,6 +25,40 @@ type WritePops = { export async function write({ path, content, offset, container }: WritePops): Promise> { try { await ensureTemporalFileExistsForAuxiliaryPath({ path, container }); + + if (!TemporalFile.isTemporaryPath(path)) { + if (isUploadSizeLimitBlockedPath(path)) { + return { error: new FuseError(FuseCodes.EFBIG, `[FUSE - Write] File too large: ${path}`) }; + } + + const temporalFile = await container.get(TemporalFileByPathFinder).run(path); + if (temporalFile) { + const projectedSize = calculateProjectedWriteSize({ + currentSize: temporalFile.size.value, + offset, + incomingBytes: content.length, + }); + const validation = validateUploadFileSize({ + size: projectedSize, + maxUploadFileSize: configStore.get('maxUploadFileSizeInBytes'), + }); + + if (!validation.allowed) { + addMaxFileSizeRejection({ validation, fileSize: projectedSize, path }); + logger.warn({ + tag: 'SYNC-ENGINE', + msg: 'File size exceeds upload limit', + path, + size: projectedSize, + maxFileSize: validation.maxFileSize, + reason: validation.reason, + showUpgradeCta: validation.showUpgradeCta, + }); + return { error: new FuseError(FuseCodes.EFBIG, `[FUSE - Write] File too large: ${path}`) }; + } + } + } + await container.get(TemporalFileWriter).run(path, content, content.length, offset); return { data: content.length }; } catch (error: unknown) { diff --git a/src/context/shared/domain/value-objects/BucketEntry.ts b/src/context/shared/domain/value-objects/BucketEntry.ts index 1b55854b3f..d5c2e76cf9 100644 --- a/src/context/shared/domain/value-objects/BucketEntry.ts +++ b/src/context/shared/domain/value-objects/BucketEntry.ts @@ -1,15 +1,14 @@ +import { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from '../../../../backend/features/user/file-size-limit'; import { ValueObject } from './ValueObject'; export class BucketEntry extends ValueObject { - public static MAX_SIZE = 40 * 1024 * 1024 * 1024; - constructor(value: number) { super(value); this.ensureIsValid(value); } private ensureIsValid(value: number) { - if (value > BucketEntry.MAX_SIZE) { + if (value > ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT) { throw new Error('File size to big'); } diff --git a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts index 3cdb0c9c70..90d96b98cb 100644 --- a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts +++ b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts @@ -1,36 +1,47 @@ -import { Readable } from 'stream'; +import { Readable } from 'node:stream'; import { mockDeep } from 'vitest-mock-extended'; -import { call, calls } from '../../../../../../tests/vitest/utils.helper'; -import { TemporalFileUploader } from './TemporalFileUploader'; -import { TemporalFileRepository } from '../../domain/TemporalFileRepository'; -import { TemporalFileUploaderFactory } from '../../domain/upload/TemporalFileUploaderFactory'; +import { partialSpyOn } from '../../../../../../tests/vitest/utils.helper'; +import configStore from '../../../../../apps/main/config'; +import { + clearMaxFileSizeRejectionModal, + clearUploadSizeLimitBlockedPath, + isUploadSizeLimitBlockedPath, +} from '../../../../../backend/features/user/file-size-limit/add-max-file-size-rejection'; import { EventBus } from '../../../../virtual-drive/shared/domain/EventBus'; import { TemporalFile } from '../../domain/TemporalFile'; +import { TemporalFileRepository } from '../../domain/TemporalFileRepository'; +import { TemporalFileUploaderFactory } from '../../domain/upload/TemporalFileUploaderFactory'; +import { TemporalFileUploader } from './TemporalFileUploader'; +import { call, calls } from '../../../../../../tests/vitest/utils.helper'; describe('TemporalFileUploader', () => { + const configGetMock = partialSpyOn(configStore, 'get'); const repository = mockDeep(); const uploaderFactory = mockDeep(); const eventBus = mockDeep(); const temporalFile = TemporalFile.from({ - path: '/Documents/report.txt', - size: 100, - createdAt: new Date(), - modifiedAt: new Date(), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + modifiedAt: new Date('2026-01-01T00:00:00.000Z'), + path: '/file.txt', + size: 101, }); const stopWatching = vi.fn(); - beforeEach(() => { - vi.resetAllMocks(); - repository.watchFile.mockReturnValue(stopWatching); eventBus.publish.mockResolvedValue(undefined); - uploaderFactory.read.mockReturnValue(uploaderFactory); uploaderFactory.document.mockReturnValue(uploaderFactory); uploaderFactory.replaces.mockReturnValue(uploaderFactory); uploaderFactory.abort.mockReturnValue(uploaderFactory); + uploaderFactory.build.mockReturnValue(async () => 'contents-id'); + repository.stream.mockResolvedValue(Readable.from(['content'])); + }); + + afterEach(() => { + clearMaxFileSizeRejectionModal(); + clearUploadSizeLimitBlockedPath('/file.txt'); }); it('retries content upload on RATE_LIMITED and succeeds', async () => { @@ -69,4 +80,36 @@ describe('TemporalFileUploader', () => { calls(eventBus.publish).toHaveLength(0); call(stopWatching).toStrictEqual([]); }); + + it('should reject oversized temporal files before opening the upload stream', async () => { + configGetMock.mockReturnValue(100); + + const uploader = new TemporalFileUploader(repository, uploaderFactory, eventBus); + + await expect(uploader.run(temporalFile)).rejects.toThrow('UPLOAD_SIZE_LIMIT_EXCEEDED'); + expect(isUploadSizeLimitBlockedPath('/file.txt')).toBe(true); + expect(uploaderFactory.build).not.toHaveBeenCalled(); + expect(eventBus.publish).not.toHaveBeenCalled(); + }); + + it('should continue upload when the stored limit is unavailable', async () => { + configGetMock.mockReturnValue(0); + + const uploader = new TemporalFileUploader(repository, uploaderFactory, eventBus); + + await expect(uploader.run(temporalFile)).resolves.toBe('contents-id'); + expect(repository.stream).toHaveBeenCalledWith(temporalFile.path); + expect(uploaderFactory.build).toHaveBeenCalled(); + }); + + it('should upload temporal files when they fit the stored limit', async () => { + configGetMock.mockReturnValue(101); + + const uploader = new TemporalFileUploader(repository, uploaderFactory, eventBus); + + await expect(uploader.run(temporalFile)).resolves.toBe('contents-id'); + expect(repository.stream).toHaveBeenCalledWith(temporalFile.path); + expect(uploaderFactory.build).toHaveBeenCalled(); + expect(eventBus.publish).toHaveBeenCalled(); + }); }); diff --git a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts index a584b5e77c..d1de1c99f6 100644 --- a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts +++ b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts @@ -16,6 +16,10 @@ import { import { ContentsId } from '../../../../../apps/main/database/entities/DriveFile'; import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; import { Result } from '../../../../shared/domain/Result'; +import configStore from '../../../../../apps/main/config'; +import { addMaxFileSizeRejection } from '../../../../../backend/features/user/file-size-limit/add-max-file-size-rejection'; +import { UploadSizeLimitError } from '../../../../../backend/features/user/file-size-limit/upload-size-limit-error'; +import { validateUploadFileSize } from '../../../../../backend/features/user/file-size-limit/validate-upload-file-size'; @Service() export class TemporalFileUploader { @@ -26,6 +30,16 @@ export class TemporalFileUploader { ) {} async run(temporalFile: TemporalFile, replaces?: Replaces): Promise { + const validation = validateUploadFileSize({ + size: temporalFile.size.value, + maxUploadFileSize: configStore.get('maxUploadFileSizeInBytes'), + }); + + if (!validation.allowed) { + addMaxFileSizeRejection({ path: temporalFile.path.value, fileSize: temporalFile.size.value, validation }); + + throw new UploadSizeLimitError(); + } const controller = new AbortController(); const stopWatching = this.repository.watchFile(temporalFile.path, () => controller.abort()); @@ -101,6 +115,7 @@ export class TemporalFileUploader { path: temporalFile.path.value, replaces: replaces?.contentsId, fileBuffer, + contentFilePath: temporalFile.contentFilePath, }); await this.eventBus.publish([contentsUploadedEvent]); diff --git a/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts b/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts index 9264245779..dbb264f0a4 100644 --- a/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts +++ b/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts @@ -7,6 +7,7 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent { readonly path: string; readonly replaces: string | undefined; readonly fileBuffer: Buffer | undefined; + readonly contentFilePath: string | undefined; constructor({ aggregateId, @@ -14,12 +15,14 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent { path, replaces, fileBuffer, + contentFilePath, }: { aggregateId: string; size: number; path: string; replaces?: string; fileBuffer?: Buffer; + contentFilePath?: string; }) { super({ aggregateId, @@ -30,6 +33,7 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent { this.path = path; this.replaces = replaces; this.fileBuffer = fileBuffer; + this.contentFilePath = contentFilePath; } toPrimitives() { @@ -38,6 +42,7 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent { size: this.size, path: this.path, replaces: this.replaces, + contentFilePath: this.contentFilePath, }; } } diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts b/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts index d4349f546c..482ecfbcdf 100644 --- a/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts +++ b/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts @@ -1,15 +1,38 @@ import { Environment } from '@internxt/inxt-js'; +import { + clearMaxFileSizeRejectionModal, + isUploadSizeLimitBlockedPath, +} from '../../../../../backend/features/user/file-size-limit/add-max-file-size-rejection'; +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; import { CreateFileOnTemporalFileUploaded } from './CreateFileOnTemporalFileUploaded'; import { FileCreatorTestClass } from '../../__test-helpers__/FileCreatorTestClass'; import { FileOverriderTestClass } from '../../__test-helpers__/FileOverriderTestClass'; import { FileMother } from '../../domain/__test-helpers__/FileMother'; import { OfflineContentsUploadedDomainEventMother } from '../../domain/events/__test-helpers__/OfflineContentsUploadedDomainEventMother'; import { call } from 'tests/vitest/utils.helper'; +import { preserveRejectedFileSizeTooBig } from '../../../../../backend/features/user/file-size-limit'; + +vi.mock('../../../../../backend/features/user/file-size-limit', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + preserveRejectedFileSizeTooBig: vi.fn().mockResolvedValue({ + data: { + filePath: '/home/user/.config/internxt/rejected-files-size-too-big/rejected/file.pdf', + }, + }), + }; +}); describe('Create File On Offline File Uploaded', () => { const environment = {} as Environment; const bucket = 'test-bucket'; + afterEach(() => { + clearMaxFileSizeRejectionModal(); + }); + it('creates a new file when event replaces field is undefined', async () => { const creator = new FileCreatorTestClass(); const overrider = new FileOverriderTestClass(); @@ -54,4 +77,24 @@ describe('Create File On Offline File Uploaded', () => { call(overrider.mock).toMatchObject([uploadedEvent.replaces, uploadedEvent.aggregateId, uploadedEvent.size]); }); + + it('preserves and queues max file size rejection when backend rejects metadata creation by file size', async () => { + const creator = new FileCreatorTestClass(); + const overrider = new FileOverriderTestClass(); + creator.mock.mockRejectedValue(new DriveDesktopError('FILE_TOO_BIG')); + + const uploadedEvent = OfflineContentsUploadedDomainEventMother.doesNotReplace(); + Object.assign(uploadedEvent, { contentFilePath: '/tmp/internxt-drive-tmp/staged-file' }); + + const sut = new CreateFileOnTemporalFileUploaded(creator, overrider, environment, bucket); + + await sut.on(uploadedEvent); + + expect(preserveRejectedFileSizeTooBig).toHaveBeenCalledWith({ + originalPath: uploadedEvent.path, + temporalContentPath: '/tmp/internxt-drive-tmp/staged-file', + size: uploadedEvent.size, + }); + expect(isUploadSizeLimitBlockedPath(uploadedEvent.path)).toBe(false); + }); }); diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts index 8e4070eff7..79b737121a 100644 --- a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts +++ b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts @@ -1,13 +1,16 @@ import { Environment } from '@internxt/inxt-js'; import { Service } from 'diod'; import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { addMaxFileSizeRejection } from '../../../../../backend/features/user/file-size-limit/add-max-file-size-rejection'; import { generateThumbnail } from '../../../../../backend/features/thumbnails/generate-thumbnail'; import { uploadAndCreateThumbnail } from '../../../../../backend/features/thumbnails/upload-and-create-thumbnail'; import { TemporalFileUploadedDomainEvent } from '../../../../storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent'; import { DomainEventClass } from '../../../../shared/domain/DomainEvent'; import { DomainEventSubscriber } from '../../../../shared/domain/DomainEventSubscriber'; +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; import { FileCreator } from './FileCreator'; import { FileOverrider } from '../override/FileOverrider'; +import { preserveRejectedFileSizeTooBig } from '../../../../../backend/features/user/file-size-limit'; @Service() export class CreateFileOnTemporalFileUploaded implements DomainEventSubscriber { @@ -52,10 +55,71 @@ export class CreateFileOnTemporalFileUploaded implements DomainEventSubscriber { + if (!event.contentFilePath) { + logger.error({ + msg: '[CreateFileOnOfflineFileUploaded] Backend rejected oversized file but temporal content path is unavailable', + path: event.path, + size: event.size, + }); + return false; + } + + try { + const { data, error } = await preserveRejectedFileSizeTooBig({ + originalPath: event.path, + temporalContentPath: event.contentFilePath, + size: event.size, + }); + + if (error) { + logger.error({ + msg: '[CreateFileOnOfflineFileUploaded] Failed to preserve backend-rejected oversized file', + error, + path: event.path, + size: event.size, + temporalContentPath: event.contentFilePath, + }); + return false; + } + + logger.warn({ + msg: '[CreateFileOnOfflineFileUploaded] Backend rejected file because it exceeds upload size limit, preserved local copy', + path: event.path, + size: event.size, + preservedFilePath: data.filePath, + }); + return true; + } catch (preserveError) { + logger.error({ + msg: '[CreateFileOnOfflineFileUploaded] Failed to preserve backend-rejected oversized file', + error: preserveError, + path: event.path, + size: event.size, + temporalContentPath: event.contentFilePath, + }); + return false; + } + } } diff --git a/src/context/virtual-drive/files/application/override/FileOverrider.test.ts b/src/context/virtual-drive/files/application/override/FileOverrider.test.ts index d312f56d6a..44ad1bbf5e 100644 --- a/src/context/virtual-drive/files/application/override/FileOverrider.test.ts +++ b/src/context/virtual-drive/files/application/override/FileOverrider.test.ts @@ -7,11 +7,17 @@ import { FileSizeMother } from '../../domain/__test-helpers__/FileSizeMother'; import { FileNotFoundError } from '../../domain/errors/FileNotFoundError'; import { FileOverriddenDomainEvent } from '../../domain/events/FileOverriddenDomainEvent'; import * as overrideFileModule from '../../../../../infra/drive-server/services/files/services/override-file'; +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; +import { DriveServerError } from '../../../../../infra/drive-server/drive-server.error'; import { call, partialSpyOn } from '../../../../../../tests/vitest/utils.helper'; describe('File Overrider', () => { const overrideFileMock = partialSpyOn(overrideFileModule, 'overrideFile'); + beforeEach(() => { + overrideFileMock.mockResolvedValue({ data: true }); + }); + it('throws an error if no file is founded with the given fileId', async () => { const repository = new FileRepositoryMock(); const eventBus = new EventBusMock(); @@ -53,6 +59,27 @@ describe('File Overrider', () => { }); }); + it('throws FILE_TOO_BIG when backend rejects the override size', async () => { + const repository = new FileRepositoryMock(); + const eventBus = new EventBusMock(); + + const overrider = new FileOverrider(repository, eventBus); + + const file = FileMother.any(); + const updatedContentsId = BucketEntryIdMother.random(); + const updatedSize = FileSizeMother.random(); + + repository.searchByContentsIdMock.mockReturnValueOnce(file); + overrideFileMock.mockResolvedValueOnce({ error: new DriveServerError('FILE_TOO_BIG', 402) }); + + await expect(overrider.run(file.contentsId, updatedContentsId.value, updatedSize.value)).rejects.toMatchObject({ + cause: 'FILE_TOO_BIG', + } satisfies Partial); + + expect(repository.updateMock).not.toHaveBeenCalled(); + expect(eventBus.publishMock).not.toHaveBeenCalled(); + }); + it('emits the FileOverridden domain event when successfully overridden ', async () => { const repository = new FileRepositoryMock(); const eventBus = new EventBusMock(); diff --git a/src/context/virtual-drive/files/application/override/FileOverrider.ts b/src/context/virtual-drive/files/application/override/FileOverrider.ts index 9e3165286d..0b61c4e3df 100644 --- a/src/context/virtual-drive/files/application/override/FileOverrider.ts +++ b/src/context/virtual-drive/files/application/override/FileOverrider.ts @@ -1,4 +1,6 @@ import { Service } from 'diod'; +import { parseRetryAfterMs } from '../../../../../backend/common/rate-limit/transient-error-handler'; +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; import { EventBus } from '../../../shared/domain/EventBus'; import { File } from '../../domain/File'; import { FileRepository } from '../../domain/FileRepository'; @@ -6,6 +8,7 @@ import { FileSize } from '../../domain/FileSize'; import { FileNotFoundError } from '../../domain/errors/FileNotFoundError'; import { FileContentsId } from '../../domain/FileContentsId'; import { overrideFile } from '../../../../../infra/drive-server/services/files/services/override-file'; +import { DriveServerError } from '../../../../../infra/drive-server/drive-server.error'; @Service() export class FileOverrider { @@ -27,12 +30,16 @@ export class FileOverrider { file.changeContents(new FileContentsId(newContentsId), new FileSize(newSize)); - await overrideFile({ + const result = await overrideFile({ fileUuid: file.uuid, fileContentsId: file.contentsId, fileSize: file.size, }); + if (result.error) { + throw mapOverrideFileError(result.error); + } + await this.repository.update(file); this.eventBus.publish(file.pullDomainEvents()); @@ -40,3 +47,19 @@ export class FileOverrider { return file; } } + +function mapOverrideFileError(error: DriveServerError): DriveDesktopError { + if (error.cause === 'FILE_TOO_BIG') { + return new DriveDesktopError('FILE_TOO_BIG', error.message); + } + + if (error.cause === 'TOO_MANY_REQUESTS') { + return new DriveDesktopError('RATE_LIMITED', String(parseRetryAfterMs(error.message))); + } + + if (error.cause === 'SERVER_ERROR') { + return new DriveDesktopError('INTERNAL_SERVER_ERROR', error.message); + } + + return new DriveDesktopError('UNKNOWN', error.message); +} diff --git a/src/context/virtual-drive/files/domain/FileSize.test.ts b/src/context/virtual-drive/files/domain/FileSize.test.ts index 7d99f67c5c..75777a3eb0 100644 --- a/src/context/virtual-drive/files/domain/FileSize.test.ts +++ b/src/context/virtual-drive/files/domain/FileSize.test.ts @@ -1,37 +1,20 @@ +import { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from '../../../../../src/backend/features/user/file-size-limit'; import { FileSize } from '../../../../../src/context/virtual-drive/files/domain/FileSize'; describe('File Size', () => { - const twentyGB = 20 * 1024 * 1024 * 1024; - - it('can create a file size of 20GB', () => { - try { - new FileSize(twentyGB); - } catch (err) { - expect(err).not.toBeDefined(); - } + it('can create a file size up to the absolute upload limit', () => { + expect(() => new FileSize(ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT)).not.toThrow(); }); it('can create a file size of 0', () => { - try { - new FileSize(0); - } catch (err) { - expect(err).not.toBeDefined(); - } + expect(() => new FileSize(0)).not.toThrow(); }); - it('cannot create a file size of negatives values', () => { - try { - new FileSize(-1); - } catch (err) { - expect(err).toBeDefined(); - } + it('cannot create a file size of negative values', () => { + expect(() => new FileSize(-1)).toThrow(); }); - it('cannot create a file size greater than 20GB', () => { - try { - new FileSize(twentyGB + 1); - } catch (err) { - expect(err).toBeDefined(); - } + it('cannot create a file size greater than the absolute upload limit', () => { + expect(() => new FileSize(ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT + 1)).toThrow(); }); }); diff --git a/src/context/virtual-drive/files/domain/__test-helpers__/FileSizeMother.ts b/src/context/virtual-drive/files/domain/__test-helpers__/FileSizeMother.ts index 3e8d78aee6..c06bcbb022 100644 --- a/src/context/virtual-drive/files/domain/__test-helpers__/FileSizeMother.ts +++ b/src/context/virtual-drive/files/domain/__test-helpers__/FileSizeMother.ts @@ -1,10 +1,11 @@ import Chance from 'chance'; import { FileSize } from '../FileSize'; +import { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from '../../../../../backend/features/user/file-size-limit'; const chance = new Chance(); export class FileSizeMother { static random() { - return new FileSize(chance.integer({ min: 0, max: FileSize.MAX_SIZE })); + return new FileSize(chance.integer({ min: 0, max: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT })); } static primitive(): number { diff --git a/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.test.ts b/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.test.ts index 4e449856f7..e011bf7731 100644 --- a/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.test.ts +++ b/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.test.ts @@ -59,6 +59,16 @@ describe('SDKRemoteFileSystem', () => { expect(result.getLeft().message).toBe('30000'); }); + it('maps FILE_TOO_BIG to FILE_TOO_BIG', async () => { + createFileMock.mockResolvedValue({ error: new DriveServerError('FILE_TOO_BIG', 402, 'too large') }); + + const result = await sut.persist(dataToPersist); + + expect(result.isLeft()).toBe(true); + expect(result.getLeft().cause).toBe('FILE_TOO_BIG'); + expect(result.getLeft().message).toBe('too large'); + }); + it('returns persisted file data on success', async () => { createFileMock.mockResolvedValue({ data: fileResponse }); diff --git a/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.ts b/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.ts index 5d2b714dfe..a7c2f3c7e2 100644 --- a/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.ts +++ b/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.ts @@ -55,6 +55,9 @@ export class SDKRemoteFileSystem implements RemoteFileSystem { if (errorCause === 'TOO_MANY_REQUESTS') { return left(new DriveDesktopError('RATE_LIMITED', String(parseRetryAfterMs(error.message)))); } + if (errorCause === 'FILE_TOO_BIG') { + return left(new DriveDesktopError('FILE_TOO_BIG', error.message)); + } return left(new DriveDesktopError('UNKNOWN', `Creating file ${plainName}: ${error}`)); } } diff --git a/src/core/electron/paths.ts b/src/core/electron/paths.ts index 491834c6b7..0948170eac 100644 --- a/src/core/electron/paths.ts +++ b/src/core/electron/paths.ts @@ -12,6 +12,7 @@ const ROOT_DRIVE_FOLDER = join(HOME_FOLDER_PATH, 'Internxt Drive'); const THUMBNAILS_FOLDER = path.join(os.homedir(), '.cache', 'thumbnails'); const TEMPORAL_FOLDER = app.getPath('temp'); const INTERNXT_DRIVE_TMP = path.join(TEMPORAL_FOLDER, 'internxt-drive-tmp'); +const REJECTED_FILES_SIZE_TOO_BIG = join(INTERNXT, 'rejected-files-size-too-big'); const DOWNLOADED = join(INTERNXT, 'downloaded'); const FUSE_DAEMON_LOG = join(LOGS, 'fuse-daemon.log'); const FUSE_DAEMON_SOCKET = join(process.env.XDG_RUNTIME_DIR ?? '/tmp', 'internxt-fuse.sock'); @@ -30,6 +31,7 @@ export const PATHS = { THUMBNAILS_FOLDER, TEMPORAL_FOLDER, INTERNXT_DRIVE_TMP, + REJECTED_FILES_SIZE_TOO_BIG, ROOT_DRIVE_FOLDER, DOWNLOADED, FUSE_DAEMON_LOG, diff --git a/src/infra/drive-server/drive-server.error.test.ts b/src/infra/drive-server/drive-server.error.test.ts new file mode 100644 index 0000000000..85d7eee621 --- /dev/null +++ b/src/infra/drive-server/drive-server.error.test.ts @@ -0,0 +1,7 @@ +import { mapStatusToErrorCause } from './drive-server.error'; + +describe('mapStatusToErrorCause', () => { + it('maps 402 to FILE_TOO_BIG', () => { + expect(mapStatusToErrorCause(402)).toBe('FILE_TOO_BIG'); + }); +}); diff --git a/src/infra/drive-server/drive-server.error.ts b/src/infra/drive-server/drive-server.error.ts index e36b39337f..ec501af5be 100644 --- a/src/infra/drive-server/drive-server.error.ts +++ b/src/infra/drive-server/drive-server.error.ts @@ -8,6 +8,7 @@ const DriveServerErrorCauses = [ 'NETWORK_ERROR', 'TOO_MANY_REQUESTS', 'CONFLICT', + 'FILE_TOO_BIG', 'UNKNOWN', ] as const; export type DriveServerErrorCause = (typeof DriveServerErrorCauses)[number]; @@ -26,6 +27,7 @@ export function mapStatusToErrorCause(status: number): DriveServerErrorCause { if (status === 403) return 'FORBIDDEN'; if (status === 404) return 'NOT_FOUND'; if (status === 409) return 'CONFLICT'; + if (status === 402) return 'FILE_TOO_BIG'; if (status === 429) return 'TOO_MANY_REQUESTS'; if (status >= 400 && status < 500) return 'BAD_REQUEST'; if (status >= 500) return 'SERVER_ERROR'; diff --git a/src/infra/drive-server/services/files/services/create-file.test.ts b/src/infra/drive-server/services/files/services/create-file.test.ts index cd457116ac..1b6313759f 100644 --- a/src/infra/drive-server/services/files/services/create-file.test.ts +++ b/src/infra/drive-server/services/files/services/create-file.test.ts @@ -32,4 +32,13 @@ describe('createFile', () => { expect(result.error).toBe(error); }); + + it('should return FILE_TOO_BIG when the backend rejects the file size', async () => { + const error = new DriveServerError('FILE_TOO_BIG', 402); + driveServerPostMock.mockResolvedValue({ data: undefined, error } as object); + + const result = await createFile({} as CreateFileDto); + + expect(result.error).toBe(error); + }); }); diff --git a/src/infra/drive-server/services/files/services/override-file.test.ts b/src/infra/drive-server/services/files/services/override-file.test.ts index 2ff0d2d383..b89b8da606 100644 --- a/src/infra/drive-server/services/files/services/override-file.test.ts +++ b/src/infra/drive-server/services/files/services/override-file.test.ts @@ -29,4 +29,13 @@ describe('overrideFile', () => { expect(result.error).toBe(error); }); + + it('should return FILE_TOO_BIG when the backend rejects the file size', async () => { + const error = new DriveServerError('FILE_TOO_BIG', 402); + driveServerPutMock.mockResolvedValue({ data: undefined, error } as object); + + const result = await overrideFile({ fileUuid: 'file-uuid', fileContentsId: 'contents-id', fileSize: 1024 }); + + expect(result.error).toBe(error); + }); });