diff --git a/toju-app/public/i18n/catalog/attachment.json b/toju-app/public/i18n/catalog/attachment.json index 5183d09..588451a 100644 --- a/toju-app/public/i18n/catalog/attachment.json +++ b/toju-app/public/i18n/catalog/attachment.json @@ -8,7 +8,8 @@ "chunksOutOfOrder": "Received media chunks out of order. Retry the download.", "writeDownloadFailed": "Could not write media download to disk.", "openDownloadFailed": "Could not open completed media download from disk.", - "downloadFailed": "Media download failed. Retry the download." + "downloadFailed": "Media download failed. Retry the download.", + "fileTooLarge": "This file is too large to download in this client. Use the desktop app or ask the sender to share a smaller file." } } } diff --git a/toju-app/public/i18n/en.json b/toju-app/public/i18n/en.json index 597aa5d..cd1acec 100644 --- a/toju-app/public/i18n/en.json +++ b/toju-app/public/i18n/en.json @@ -36,7 +36,8 @@ "chunksOutOfOrder": "Received media chunks out of order. Retry the download.", "writeDownloadFailed": "Could not write media download to disk.", "openDownloadFailed": "Could not open completed media download from disk.", - "downloadFailed": "Media download failed. Retry the download." + "downloadFailed": "Media download failed. Retry the download.", + "fileTooLarge": "This file is too large to download in this client. Use the desktop app or ask the sender to share a smaller file." } }, "auth": { diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts index bf121d1..c180d09 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts @@ -364,6 +364,23 @@ describe('AttachmentTransferService', () => { return attachment; } + function registerIncomingGenericFile(size: number): Attachment { + const attachment: Attachment = { + id: FILE_ID, + messageId: MESSAGE_ID, + filename: 'archive.zip', + size, + mime: 'application/zip', + isImage: false, + uploaderPeerId: PEER_ID, + available: false, + receivedBytes: 0 + }; + + runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]); + return attachment; + } + it('streams playable media to disk when the store supports streaming', async () => { attachmentStorage.canStreamToDisk.mockReturnValue(true); @@ -409,4 +426,57 @@ describe('AttachmentTransferService', () => { expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(true); }); + + it('streams oversized generic files to disk when the store supports streaming', async () => { + attachmentStorage.canStreamToDisk.mockReturnValue(true); + attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 256 * 1024 * 1024); + + const service = createService(); + const attachment = registerIncomingGenericFile(12 * 1024 * 1024); + + service.handleFileChunk(chunkPayload(0, 1, [ + 1, + 2, + 3 + ])); + + await vi.waitFor(() => expect(attachment.available).toBe(true)); + + expect(attachmentStorage.createWritableFile).toHaveBeenCalled(); + expect(attachmentStorage.appendBase64).toHaveBeenCalled(); + expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled(); + expect(persistence.saveFileToDisk).not.toHaveBeenCalled(); + }); + + it('rejects oversized browser downloads before requesting peers', async () => { + attachmentStorage.canStreamToDisk.mockReturnValue(false); + attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 50 * 1024 * 1024); + + const service = createService(); + const attachment = registerIncomingGenericFile(200 * 1024 * 1024); + + await service.requestFromAnyPeer(MESSAGE_ID, attachment); + + expect(attachment.requestError).toBe('attachment.errors.fileTooLarge'); + expect(webrtc.sendToPeer).not.toHaveBeenCalled(); + }); + + it('assembles browser-sized generic files in memory when streaming is unavailable', async () => { + attachmentStorage.canStreamToDisk.mockReturnValue(false); + attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 50 * 1024 * 1024); + + const service = createService(); + const attachment = registerIncomingGenericFile(3); + + service.handleFileChunk(chunkPayload(0, 1, [ + 1, + 2, + 3 + ])); + + await vi.waitFor(() => expect(attachment.available).toBe(true)); + + expect(attachmentStorage.appendBase64).not.toHaveBeenCalled(); + expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1); + }); }); diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts index 670aa87..ac8b559 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts @@ -9,13 +9,20 @@ import { AttachmentStorageService } from '../../infrastructure/services/attachme import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants'; import { isImageAttachment, resolvePublishAttachmentIsImage } from '../../domain/logic/attachment-image.rules'; import { isSharingFromThisDevice } from '../../domain/logic/attachment-sharing.rules'; -import { shouldCopyUploaderMediaToAppData, shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic'; +import { + canReceiveAttachment, + isAttachmentMedia, + shouldCopyUploaderMediaToAppData, + shouldPersistDownloadedAttachment, + shouldStreamAttachmentReceiveToDisk +} from '../../domain/logic/attachment.logic'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model'; import { ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT, ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT, DEFAULT_ATTACHMENT_MIME_TYPE, ATTACHMENT_DOWNLOAD_FAILED_KEY, + ATTACHMENT_FILE_TOO_LARGE_KEY, ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY, ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY, ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY, @@ -188,6 +195,13 @@ export class AttachmentTransferService { return; } + if (!canReceiveAttachment(attachment, this.receiveCapabilities())) { + this.runtimeStore.deletePendingRequest(requestKey); + attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY); + this.runtimeStore.touch(); + return; + } + if (clearedRequestError) this.runtimeStore.touch(); @@ -344,7 +358,9 @@ export class AttachmentTransferService { return; } - if (!this.shouldReceiveToDisk(attachment) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) { + if (!canReceiveAttachment(attachment, this.receiveCapabilities())) { + attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY); + this.runtimeStore.touch(); return; } @@ -784,10 +800,14 @@ export class AttachmentTransferService { } private shouldReceiveToDisk(attachment: Attachment): boolean { - return this.isPlayableMedia(attachment) && - !attachment.filePath && - this.attachmentStorage.canStreamToDisk() && - this.attachmentStorage.canPersistSize(attachment.size); + return shouldStreamAttachmentReceiveToDisk(attachment, this.receiveCapabilities()); + } + + private receiveCapabilities() { + return { + canStreamToDisk: this.attachmentStorage.canStreamToDisk(), + canPersistSize: (bytes: number) => this.attachmentStorage.canPersistSize(bytes) + }; } private enqueueDiskFileChunk( @@ -851,6 +871,14 @@ export class AttachmentTransferService { attachment.savedPath = assembly.path; + if (!isAttachmentMedia(attachment)) { + attachment.available = true; + this.diskReceiveAssemblies.delete(assemblyKey); + this.runtimeStore.touch(); + void this.persistence.persistAttachmentMeta(attachment); + return; + } + const restoredForDisplay = await this.persistence.ensureInlineDisplayObjectUrl(attachment); if (!restoredForDisplay) { diff --git a/toju-app/src/app/domains/attachment/domain/constants/attachment-transfer.constants.ts b/toju-app/src/app/domains/attachment/domain/constants/attachment-transfer.constants.ts index 6f7f28b..b23d6bc 100644 --- a/toju-app/src/app/domains/attachment/domain/constants/attachment-transfer.constants.ts +++ b/toju-app/src/app/domains/attachment/domain/constants/attachment-transfer.constants.ts @@ -22,3 +22,4 @@ export const ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY = 'attachment.errors.chunksOutOf export const ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY = 'attachment.errors.writeDownloadFailed'; export const ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY = 'attachment.errors.openDownloadFailed'; export const ATTACHMENT_DOWNLOAD_FAILED_KEY = 'attachment.errors.downloadFailed'; +export const ATTACHMENT_FILE_TOO_LARGE_KEY = 'attachment.errors.fileTooLarge'; diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts index 579aa8d..a6259ca 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts @@ -1,7 +1,9 @@ import { getWatchedAttachmentRoomIdFromUrl, isDirectMessageAttachmentRoomId, - shouldCopyUploaderMediaToAppData + shouldCopyUploaderMediaToAppData, + shouldStreamAttachmentReceiveToDisk, + canReceiveAttachment } from './attachment.logic'; describe('attachment logic', () => { @@ -44,4 +46,36 @@ describe('attachment logic', () => { mime: 'video/mp4' }, undefined, true)).toBe(false); }); + + it('streams oversized generic files to disk when the store supports it', () => { + const capabilities = { + canStreamToDisk: true, + canPersistSize: (bytes: number) => bytes <= 256 * 1024 * 1024 + }; + + expect(shouldStreamAttachmentReceiveToDisk({ + size: 200 * 1024 * 1024, + mime: 'application/zip', + filePath: undefined + }, capabilities)).toBe(true); + }); + + it('receives browser-sized files in memory when disk streaming is unavailable', () => { + const browserCapabilities = { + canStreamToDisk: false, + canPersistSize: (bytes: number) => bytes <= 50 * 1024 * 1024 + }; + + expect(canReceiveAttachment({ + size: 20 * 1024 * 1024, + mime: 'application/zip', + filePath: undefined + }, browserCapabilities)).toBe(true); + + expect(canReceiveAttachment({ + size: 200 * 1024 * 1024, + mime: 'application/zip', + filePath: undefined + }, browserCapabilities)).toBe(false); + }); }); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts index d80aa21..0d4958f 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts @@ -50,6 +50,49 @@ export function isDirectMessageAttachmentRoomId(roomId: string | null | undefine return !!roomId && roomId.startsWith(DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX); } +export interface AttachmentReceiveCapabilities { + canStreamToDisk: boolean; + canPersistSize: (bytes: number) => boolean; +} + +export function shouldStreamAttachmentReceiveToDisk( + attachment: Pick, + capabilities: AttachmentReceiveCapabilities +): boolean { + if (attachment.filePath?.trim()) { + return false; + } + + if (!capabilities.canStreamToDisk || !capabilities.canPersistSize(attachment.size)) { + return false; + } + + if (attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) { + return true; + } + + return isAttachmentMedia(attachment); +} + +export function canReceiveAttachmentInMemory( + attachment: Pick, + capabilities: AttachmentReceiveCapabilities +): boolean { + if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { + return true; + } + + return !capabilities.canStreamToDisk && capabilities.canPersistSize(attachment.size); +} + +export function canReceiveAttachment( + attachment: Pick, + capabilities: AttachmentReceiveCapabilities +): boolean { + return shouldStreamAttachmentReceiveToDisk(attachment, capabilities) + || canReceiveAttachmentInMemory(attachment, capabilities); +} + function decodeUrlSegment(value: string): string { try { return decodeURIComponent(value);