diff --git a/toju-app/src/app/domains/attachment/README.md b/toju-app/src/app/domains/attachment/README.md index 21928b8..c69f966 100644 --- a/toju-app/src/app/domains/attachment/README.md +++ b/toju-app/src/app/domains/attachment/README.md @@ -129,10 +129,10 @@ The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachment On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket: ``` -{appDataPath}/{serverId}/{roomName}/{bucket}/{filename} +{appDataPath}/{serverId}/{roomName}/{bucket}/{attachmentId}.{ext?} ``` -Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type. +Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other. `AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On browser builds, files stay in memory only. diff --git a/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.helpers.ts b/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.helpers.ts index e018eb8..ab1cd3c 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.helpers.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.helpers.ts @@ -1,4 +1,5 @@ const ROOM_NAME_SANITIZER = /[^\w.-]+/g; +const STORED_FILENAME_SANITIZER = /[^\w.-]+/g; export function sanitizeAttachmentRoomName(roomName: string): string { const sanitizedRoomName = roomName.trim().replace(ROOM_NAME_SANITIZER, '_'); @@ -6,6 +7,25 @@ export function sanitizeAttachmentRoomName(roomName: string): string { return sanitizedRoomName || 'room'; } +export function resolveAttachmentStoredFilename(attachmentId: string, filename: string): string { + const sanitizedAttachmentId = attachmentId.trim().replace(STORED_FILENAME_SANITIZER, '_') || 'attachment'; + const basename = filename.trim().split(/[\\/]/) + .pop() ?? ''; + const extensionIndex = basename.lastIndexOf('.'); + + if (extensionIndex <= 0 || extensionIndex === basename.length - 1) { + return sanitizedAttachmentId; + } + + const sanitizedExtension = basename.slice(extensionIndex) + .replace(STORED_FILENAME_SANITIZER, '_') + .toLowerCase(); + + return sanitizedExtension === '.' + ? sanitizedAttachmentId + : `${sanitizedAttachmentId}${sanitizedExtension}`; +} + export function resolveAttachmentStorageBucket(mime: string): 'video' | 'audio' | 'image' | 'files' { if (mime.startsWith('video/')) { return 'video'; diff --git a/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.service.ts b/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.service.ts index 27eb5fa..f1c7218 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.service.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.service.ts @@ -1,7 +1,11 @@ import { Injectable, inject } from '@angular/core'; import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import type { Attachment } from '../domain/attachment.models'; -import { resolveAttachmentStorageBucket, sanitizeAttachmentRoomName } from './attachment-storage.helpers'; +import { + resolveAttachmentStorageBucket, + resolveAttachmentStoredFilename, + sanitizeAttachmentRoomName +} from './attachment-storage.helpers'; @Injectable({ providedIn: 'root' }) export class AttachmentStorageService { @@ -38,7 +42,7 @@ export class AttachmentStorageService { } async saveBlob( - attachment: Pick, + attachment: Pick, blob: Blob, roomName: string ): Promise { @@ -55,7 +59,7 @@ export class AttachmentStorageService { await electronApi.ensureDir(directoryPath); const arrayBuffer = await blob.arrayBuffer(); - const diskPath = `${directoryPath}/${attachment.filename}`; + const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`; await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer));