1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-26 10:50:29 +02:00

fix(server): add missing extensions and mime types (#3318)

Add extensions and mime types which were accidentally removed in #3197.

Fixes: #3300
This commit is contained in:
Thomas 2023-07-19 15:27:25 +01:00 committed by GitHub
parent 4b8cc7b533
commit f0302670d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 327 additions and 156 deletions

View File

@ -12,7 +12,6 @@ import {
import { when } from 'jest-when'; import { when } from 'jest-when';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { ICryptoRepository } from '../crypto'; import { ICryptoRepository } from '../crypto';
import { mimeTypes } from '../domain.constant';
import { IStorageRepository } from '../storage'; import { IStorageRepository } from '../storage';
import { AssetStats, IAssetRepository } from './asset.repository'; import { AssetStats, IAssetRepository } from './asset.repository';
import { AssetService, UploadFieldName } from './asset.service'; import { AssetService, UploadFieldName } from './asset.service';
@ -66,30 +65,78 @@ const uploadFile = {
}, },
}; };
const validImages = [
'.3fr',
'.ari',
'.arw',
'.avif',
'.cap',
'.cin',
'.cr2',
'.cr3',
'.crw',
'.dcr',
'.dng',
'.erf',
'.fff',
'.gif',
'.heic',
'.heif',
'.iiq',
'.jpeg',
'.jpg',
'.jxl',
'.k25',
'.kdc',
'.mrw',
'.nef',
'.orf',
'.ori',
'.pef',
'.png',
'.raf',
'.raw',
'.rwl',
'.sr2',
'.srf',
'.srw',
'.tiff',
'.webp',
'.x3f',
];
const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv'];
const uploadTests = [ const uploadTests = [
{ {
label: 'asset', label: 'asset images',
fieldName: UploadFieldName.ASSET_DATA, fieldName: UploadFieldName.ASSET_DATA,
filetypes: Object.keys({ ...mimeTypes.image, ...mimeTypes.video }), valid: validImages,
invalid: ['.xml', '.html'], invalid: ['.html', '.xml'],
},
{
label: 'asset videos',
fieldName: UploadFieldName.ASSET_DATA,
valid: validVideos,
invalid: ['.html', '.xml'],
}, },
{ {
label: 'live photo', label: 'live photo',
fieldName: UploadFieldName.LIVE_PHOTO_DATA, fieldName: UploadFieldName.LIVE_PHOTO_DATA,
filetypes: Object.keys(mimeTypes.video), valid: validVideos,
invalid: ['.xml', '.html', '.jpg', '.jpeg'], invalid: ['.html', '.jpeg', '.jpg', '.xml'],
}, },
{ {
label: 'sidecar', label: 'sidecar',
fieldName: UploadFieldName.SIDECAR_DATA, fieldName: UploadFieldName.SIDECAR_DATA,
filetypes: Object.keys(mimeTypes.sidecar), valid: ['.xmp'],
invalid: ['.xml', '.html', '.jpg', '.jpeg', '.mov', '.mp4'], invalid: ['.html', '.jpeg', '.jpg', '.mov', '.mp4', '.xml'],
}, },
{ {
label: 'profile', label: 'profile',
fieldName: UploadFieldName.PROFILE_DATA, fieldName: UploadFieldName.PROFILE_DATA,
filetypes: Object.keys(mimeTypes.profile), valid: ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'],
invalid: ['.xml', '.html', '.cr2', '.arf', '.mov', '.mp4'], invalid: ['.arf', '.cr2', '.html', '.mov', '.mp4', '.xml'],
}, },
]; ];
@ -117,9 +164,9 @@ describe(AssetService.name, () => {
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException); expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
}); });
for (const { fieldName, filetypes, invalid } of uploadTests) { for (const { fieldName, valid, invalid } of uploadTests) {
describe(`${fieldName}`, () => { describe(fieldName, () => {
for (const filetype of filetypes) { for (const filetype of valid) {
it(`should accept ${filetype}`, () => { it(`should accept ${filetype}`, () => {
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true); expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
}); });
@ -132,6 +179,16 @@ describe(AssetService.name, () => {
); );
}); });
} }
it('should be sorted (valid)', () => {
// TODO: use toSorted in NodeJS 20.
expect(valid).toEqual([...valid].sort());
});
it('should be sorted (invalid)', () => {
// TODO: use toSorted in NodeJS 20.
expect(invalid).toEqual([...invalid].sort());
});
}); });
} }
}); });

View File

@ -0,0 +1,191 @@
import { mimeTypes } from '@app/domain';
describe('mimeTypes', () => {
for (const { mimetype, extension } of [
// Please ensure this list is sorted.
{ mimetype: 'image/3fr', extension: '.3fr' },
{ mimetype: 'image/ari', extension: '.ari' },
{ mimetype: 'image/arw', extension: '.arw' },
{ mimetype: 'image/avif', extension: '.avif' },
{ mimetype: 'image/cap', extension: '.cap' },
{ mimetype: 'image/cin', extension: '.cin' },
{ mimetype: 'image/cr2', extension: '.cr2' },
{ mimetype: 'image/cr3', extension: '.cr3' },
{ mimetype: 'image/crw', extension: '.crw' },
{ mimetype: 'image/dcr', extension: '.dcr' },
{ mimetype: 'image/dng', extension: '.dng' },
{ mimetype: 'image/erf', extension: '.erf' },
{ mimetype: 'image/fff', extension: '.fff' },
{ mimetype: 'image/gif', extension: '.gif' },
{ mimetype: 'image/heic', extension: '.heic' },
{ mimetype: 'image/heif', extension: '.heif' },
{ mimetype: 'image/iiq', extension: '.iiq' },
{ mimetype: 'image/jpeg', extension: '.jpeg' },
{ mimetype: 'image/jpeg', extension: '.jpg' },
{ mimetype: 'image/jxl', extension: '.jxl' },
{ mimetype: 'image/k25', extension: '.k25' },
{ mimetype: 'image/kdc', extension: '.kdc' },
{ mimetype: 'image/mrw', extension: '.mrw' },
{ mimetype: 'image/nef', extension: '.nef' },
{ mimetype: 'image/orf', extension: '.orf' },
{ mimetype: 'image/ori', extension: '.ori' },
{ mimetype: 'image/pef', extension: '.pef' },
{ mimetype: 'image/png', extension: '.png' },
{ mimetype: 'image/raf', extension: '.raf' },
{ mimetype: 'image/raw', extension: '.raw' },
{ mimetype: 'image/rwl', extension: '.rwl' },
{ mimetype: 'image/sr2', extension: '.sr2' },
{ mimetype: 'image/srf', extension: '.srf' },
{ mimetype: 'image/srw', extension: '.srw' },
{ mimetype: 'image/tiff', extension: '.tiff' },
{ mimetype: 'image/webp', extension: '.webp' },
{ mimetype: 'image/x-adobe-dng', extension: '.dng' },
{ mimetype: 'image/x-arriflex-ari', extension: '.ari' },
{ mimetype: 'image/x-canon-cr2', extension: '.cr2' },
{ mimetype: 'image/x-canon-cr3', extension: '.cr3' },
{ mimetype: 'image/x-canon-crw', extension: '.crw' },
{ mimetype: 'image/x-epson-erf', extension: '.erf' },
{ mimetype: 'image/x-fuji-raf', extension: '.raf' },
{ mimetype: 'image/x-hasselblad-3fr', extension: '.3fr' },
{ mimetype: 'image/x-hasselblad-fff', extension: '.fff' },
{ mimetype: 'image/x-kodak-dcr', extension: '.dcr' },
{ mimetype: 'image/x-kodak-k25', extension: '.k25' },
{ mimetype: 'image/x-kodak-kdc', extension: '.kdc' },
{ mimetype: 'image/x-leica-rwl', extension: '.rwl' },
{ mimetype: 'image/x-minolta-mrw', extension: '.mrw' },
{ mimetype: 'image/x-nikon-nef', extension: '.nef' },
{ mimetype: 'image/x-olympus-orf', extension: '.orf' },
{ mimetype: 'image/x-olympus-ori', extension: '.ori' },
{ mimetype: 'image/x-panasonic-raw', extension: '.raw' },
{ mimetype: 'image/x-pentax-pef', extension: '.pef' },
{ mimetype: 'image/x-phantom-cin', extension: '.cin' },
{ mimetype: 'image/x-phaseone-cap', extension: '.cap' },
{ mimetype: 'image/x-phaseone-iiq', extension: '.iiq' },
{ mimetype: 'image/x-samsung-srw', extension: '.srw' },
{ mimetype: 'image/x-sigma-x3f', extension: '.x3f' },
{ mimetype: 'image/x-sony-arw', extension: '.arw' },
{ mimetype: 'image/x-sony-sr2', extension: '.sr2' },
{ mimetype: 'image/x-sony-srf', extension: '.srf' },
{ mimetype: 'image/x3f', extension: '.x3f' },
{ mimetype: 'video/3gpp', extension: '.3gp' },
{ mimetype: 'video/avi', extension: '.avi' },
{ mimetype: 'video/mp2t', extension: '.m2ts' },
{ mimetype: 'video/mp2t', extension: '.mts' },
{ mimetype: 'video/mp4', extension: '.mp4' },
{ mimetype: 'video/mpeg', extension: '.mpg' },
{ mimetype: 'video/msvideo', extension: '.avi' },
{ mimetype: 'video/quicktime', extension: '.mov' },
{ mimetype: 'video/vnd.avi', extension: '.avi' },
{ mimetype: 'video/webm', extension: '.webm' },
{ mimetype: 'video/x-flv', extension: '.flv' },
{ mimetype: 'video/x-matroska', extension: '.mkv' },
{ mimetype: 'video/x-ms-wmv', extension: '.wmv' },
{ mimetype: 'video/x-msvideo', extension: '.avi' },
]) {
it(`should map ${extension} to ${mimetype}`, async () => {
expect({ ...mimeTypes.image, ...mimeTypes.video }[extension]).toContain(mimetype);
});
}
describe('profile', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.profile);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.profile).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.profile);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
for (const [ext, v] of Object.entries(mimeTypes.profile)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
});
}
});
describe('image', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.image);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.image).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.image);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
it('should contain only image mime types', () => {
const values = Object.values(mimeTypes.image).flat();
expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('image/')));
});
for (const [ext, v] of Object.entries(mimeTypes.image)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
});
}
});
describe('video', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.video);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.video).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.video);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
it('should contain only video mime types', () => {
const values = Object.values(mimeTypes.video).flat();
expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('video/')));
});
for (const [ext, v] of Object.entries(mimeTypes.video)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
});
}
});
describe('sidecar', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.sidecar);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.sidecar).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.sidecar);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
it('should contain only xml mime types', () => {
expect(Object.values(mimeTypes.sidecar).flat()).toEqual(['application/xml', 'text/xml']);
});
for (const [ext, v] of Object.entries(mimeTypes.sidecar)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
});
}
});
});

View File

@ -30,70 +30,73 @@ export function assertMachineLearningEnabled() {
} }
} }
const profile: Record<string, string> = { const image: Record<string, string[]> = {
'.avif': 'image/avif', '.3fr': ['image/3fr', 'image/x-hasselblad-3fr'],
'.dng': 'image/x-adobe-dng', '.ari': ['image/ari', 'image/x-arriflex-ari'],
'.heic': 'image/heic', '.arw': ['image/arw', 'image/x-sony-arw'],
'.heif': 'image/heif', '.avif': ['image/avif'],
'.jpeg': 'image/jpeg', '.cap': ['image/cap', 'image/x-phaseone-cap'],
'.jpg': 'image/jpeg', '.cin': ['image/cin', 'image/x-phantom-cin'],
'.png': 'image/png', '.cr2': ['image/cr2', 'image/x-canon-cr2'],
'.webp': 'image/webp', '.cr3': ['image/cr3', 'image/x-canon-cr3'],
'.crw': ['image/crw', 'image/x-canon-crw'],
'.dcr': ['image/dcr', 'image/x-kodak-dcr'],
'.dng': ['image/dng', 'image/x-adobe-dng'],
'.erf': ['image/erf', 'image/x-epson-erf'],
'.fff': ['image/fff', 'image/x-hasselblad-fff'],
'.gif': ['image/gif'],
'.heic': ['image/heic'],
'.heif': ['image/heif'],
'.iiq': ['image/iiq', 'image/x-phaseone-iiq'],
'.jpeg': ['image/jpeg'],
'.jpg': ['image/jpeg'],
'.jxl': ['image/jxl'],
'.k25': ['image/k25', 'image/x-kodak-k25'],
'.kdc': ['image/kdc', 'image/x-kodak-kdc'],
'.mrw': ['image/mrw', 'image/x-minolta-mrw'],
'.nef': ['image/nef', 'image/x-nikon-nef'],
'.orf': ['image/orf', 'image/x-olympus-orf'],
'.ori': ['image/ori', 'image/x-olympus-ori'],
'.pef': ['image/pef', 'image/x-pentax-pef'],
'.png': ['image/png'],
'.raf': ['image/raf', 'image/x-fuji-raf'],
'.raw': ['image/raw', 'image/x-panasonic-raw'],
'.rwl': ['image/rwl', 'image/x-leica-rwl'],
'.sr2': ['image/sr2', 'image/x-sony-sr2'],
'.srf': ['image/srf', 'image/x-sony-srf'],
'.srw': ['image/srw', 'image/x-samsung-srw'],
'.tiff': ['image/tiff'],
'.webp': ['image/webp'],
'.x3f': ['image/x3f', 'image/x-sigma-x3f'],
}; };
const image: Record<string, string> = { const profileExtensions = ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'];
...profile, const profile: Record<string, string[]> = Object.fromEntries(
'.3fr': 'image/x-hasselblad-3fr', Object.entries(image).filter(([key]) => profileExtensions.includes(key)),
'.ari': 'image/x-arriflex-ari', );
'.arw': 'image/x-sony-arw',
'.cap': 'image/x-phaseone-cap', const video: Record<string, string[]> = {
'.cin': 'image/x-phantom-cin', '.3gp': ['video/3gpp'],
'.cr2': 'image/x-canon-cr2', '.avi': ['video/avi', 'video/msvideo', 'video/vnd.avi', 'video/x-msvideo'],
'.cr3': 'image/x-canon-cr3', '.flv': ['video/x-flv'],
'.crw': 'image/x-canon-crw', '.m2ts': ['video/mp2t'],
'.dcr': 'image/x-kodak-dcr', '.mkv': ['video/x-matroska'],
'.erf': 'image/x-epson-erf', '.mov': ['video/quicktime'],
'.fff': 'image/x-hasselblad-fff', '.mp4': ['video/mp4'],
'.gif': 'image/gif', '.mpg': ['video/mpeg'],
'.iiq': 'image/x-phaseone-iiq', '.mts': ['video/mp2t'],
'.k25': 'image/x-kodak-k25', '.webm': ['video/webm'],
'.kdc': 'image/x-kodak-kdc', '.wmv': ['video/x-ms-wmv'],
'.mrw': 'image/x-minolta-mrw',
'.nef': 'image/x-nikon-nef',
'.orf': 'image/x-olympus-orf',
'.ori': 'image/x-olympus-ori',
'.pef': 'image/x-pentax-pef',
'.raf': 'image/x-fuji-raf',
'.raw': 'image/x-panasonic-raw',
'.rwl': 'image/x-leica-rwl',
'.sr2': 'image/x-sony-sr2',
'.srf': 'image/x-sony-srf',
'.srw': 'image/x-samsung-srw',
'.tiff': 'image/tiff',
'.x3f': 'image/x-sigma-x3f',
}; };
const video: Record<string, string> = { const sidecar: Record<string, string[]> = {
'.3gp': 'video/3gpp', '.xmp': ['application/xml', 'text/xml'],
'.avi': 'video/x-msvideo',
'.flv': 'video/x-flv',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.mp2t': 'video/mp2t',
'.mp4': 'video/mp4',
'.mpeg': 'video/mpeg',
'.webm': 'video/webm',
'.wmv': 'video/x-ms-wmv',
}; };
const sidecar: Record<string, string> = { const isType = (filename: string, r: Record<string, string[]>) => extname(filename).toLowerCase() in r;
'.xmp': 'application/xml',
};
const isType = (filename: string, lookup: Record<string, string>) => !!lookup[extname(filename).toLowerCase()];
const getType = (filename: string, lookup: Record<string, string>) => lookup[extname(filename).toLowerCase()];
const lookup = (filename: string) => const lookup = (filename: string) =>
getType(filename, { ...image, ...video, ...sidecar }) || 'application/octet-stream'; ({ ...image, ...video, ...sidecar }[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream');
export const mimeTypes = { export const mimeTypes = {
image, image,
@ -107,14 +110,12 @@ export const mimeTypes = {
isVideo: (filename: string) => isType(filename, video), isVideo: (filename: string) => isType(filename, video),
lookup, lookup,
assetType: (filename: string) => { assetType: (filename: string) => {
const contentType = lookup(filename).split('/')[0]; const contentType = lookup(filename);
switch (contentType) { if (contentType.startsWith('image/')) {
case 'image':
return AssetType.IMAGE; return AssetType.IMAGE;
case 'video': } else if (contentType.startsWith('video/')) {
return AssetType.VIDEO; return AssetType.VIDEO;
default:
return AssetType.OTHER;
} }
return AssetType.OTHER;
}, },
}; };

View File

@ -1,4 +1,4 @@
import { ICryptoRepository, IJobRepository, IStorageRepository, JobName, mimeTypes } from '@app/domain'; import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { import {
@ -139,84 +139,6 @@ describe('AssetService', () => {
.mockResolvedValue(assetEntityStub.livePhotoMotionAsset); .mockResolvedValue(assetEntityStub.livePhotoMotionAsset);
}); });
describe('mime types linting', () => {
describe('profile', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.profile);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.profile);
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.profile);
expect(keys).toEqual([...keys].sort());
});
});
describe('image', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.image);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.image);
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.image).filter((key) => key in mimeTypes.profile === false);
expect(keys).toEqual([...keys].sort());
});
it('should contain only image mime types', () => {
expect(Object.values(mimeTypes.image)).toEqual(
Object.values(mimeTypes.image).filter((mimeType) => mimeType.startsWith('image/')),
);
});
});
describe('video', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.video);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.video);
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.video);
expect(keys).toEqual([...keys].sort());
});
it('should contain only video mime types', () => {
expect(Object.values(mimeTypes.video)).toEqual(
Object.values(mimeTypes.video).filter((mimeType) => mimeType.startsWith('video/')),
);
});
});
describe('sidecar', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.sidecar);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.sidecar);
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.sidecar);
expect(keys).toEqual([...keys].sort());
});
});
describe('sidecar', () => {
it('should contain only be xml mime type', () => {
expect(Object.values(mimeTypes.sidecar)).toEqual(
Object.values(mimeTypes.sidecar).filter((mimeType) => mimeType === 'application/xml'),
);
});
});
});
describe('uploadFile', () => { describe('uploadFile', () => {
it('should handle a file upload', async () => { it('should handle a file upload', async () => {
const assetEntity = _getAsset_1(); const assetEntity = _getAsset_1();