diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 2e6a5b36ba..5a9a85f179 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -269,6 +269,10 @@ describe(AssetMediaService.name, () => { 'random-uuid.jpg', ); }); + + it('should accept filenames with just an extension', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, '.jpg'))).toEqual('random-uuid.jpg'); + }); }); describe('getUploadFolder', () => { diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 1da5146d91..077bff3db2 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { extname } from 'node:path'; import sanitize from 'sanitize-filename'; import { StorageCore } from 'src/cores/storage.core'; import { AuthSharedLink } from 'src/database'; @@ -92,8 +91,7 @@ export class AssetMediaService extends BaseService { getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string { requireUploadAccess(auth); - const extension = extname(body.filename || file.originalName); - + const extension = getFilenameExtension(body.filename || file.originalName); const lookup = { [UploadFieldName.ASSET_DATA]: extension, [UploadFieldName.SIDECAR_DATA]: '.xmp', diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index ae857870af..e4f2dcacf7 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -20,7 +20,7 @@ import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { JobOf, StorageAsset } from 'src/types'; import { getAssetFile } from 'src/utils/asset.util'; -import { getLivePhotoMotionFilename } from 'src/utils/file'; +import { getFilenameExtension, getLivePhotoMotionFilename } from 'src/utils/file'; const storageTokens = { secondOptions: ['s', 'ss', 'SSS'], @@ -267,10 +267,10 @@ export class StorageTemplateService extends BaseService { const { storageLabel, filename } = metadata; try { - const filenameWithoutExtension = path.basename(filename, path.extname(filename)); + const filenameWithoutExtension = path.basename(filename, getFilenameExtension(filename)); const source = asset.originalPath; - let extension = path.extname(source).split('.').pop() as string; + let extension = getFilenameExtension(source).split('.').pop() as string; const sanitized = sanitize(path.basename(filenameWithoutExtension, `.${extension}`)); extension = extension?.toLowerCase(); const rootPath = StorageCore.getLibraryFolder({ id: asset.ownerId, storageLabel }); diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index c77f7bb7a7..24d555f2fe 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -9,15 +9,19 @@ import { ImmichReadStream } from 'src/repositories/storage.repository'; import { isConnectionAborted } from 'src/utils/misc'; export function getFileNameWithoutExtension(path: string): string { - return basename(path, extname(path)); + return basename(path, getFilenameExtension(path)); } -export function getFilenameExtension(path: string): string { - return extname(path); +export function getFilenameExtension(path: string) { + const extension = extname(path); + if (!extension && path.startsWith('.') && !path.includes('.', 1)) { + return path; + } + return extension; } export function getLivePhotoMotionFilename(stillName: string, motionName: string) { - return getFileNameWithoutExtension(stillName) + extname(motionName); + return getFileNameWithoutExtension(stillName) + getFilenameExtension(motionName); } export class ImmichFileResponse { diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 43e7f0565a..327cf5d73c 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -1,5 +1,5 @@ -import { extname } from 'node:path'; import { AssetType } from 'src/enum'; +import { getFilenameExtension } from 'src/utils/file'; const raw = { '.3fr': ['image/3fr', 'image/x-hasselblad-3fr'], @@ -132,10 +132,12 @@ const sidecar: Record = { const types = { ...image, ...video, ...sidecar }; -const isType = (filename: string, record: Record) => - Object.hasOwn(record, extname(filename).toLowerCase()); +const isType = (filename: string, r: Record) => + Object.hasOwn(r, getFilenameExtension(filename).toLowerCase()); + +const lookup = (filename: string) => + types[getFilenameExtension(filename).toLowerCase()]?.[0] ?? 'application/octet-stream'; -const lookup = (filename: string) => types[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream'; const toExtension = (mimeType: string) => { return ( extensionOverrides[mimeType] || Object.entries(types).find(([, mimeTypes]) => mimeTypes.includes(mimeType))?.[0] @@ -158,7 +160,8 @@ export const mimeTypes = { isProfile: (filename: string) => isType(filename, profile), isSidecar: (filename: string) => isType(filename, sidecar), isVideo: (filename: string) => isType(filename, video), - canBeTransparent: (filename: string) => transparentCapableExtensions.has(extname(filename).toLowerCase()), + canBeTransparent: (filename: string) => + transparentCapableExtensions.has(getFilenameExtension(filename).toLowerCase()), isRaw: (filename: string) => isType(filename, raw), lookup, /** return an extension (including a leading `.`) for a mime-type */