mirror of
https://github.com/immich-app/immich.git
synced 2024-11-24 08:52:28 +02:00
refactor(server): mime types (#3197)
* refactor(server): mime type check * chore: open api * chore: remove duplicate test
This commit is contained in:
parent
785f61ba70
commit
6180828ed2
@ -679,12 +679,6 @@ export interface AssetResponseDto {
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'isArchived': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'mimeType': string | null;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
|
BIN
mobile/openapi/doc/AssetResponseDto.md
generated
BIN
mobile/openapi/doc/AssetResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/asset_response_dto.dart
generated
BIN
mobile/openapi/lib/model/asset_response_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/asset_response_dto_test.dart
generated
BIN
mobile/openapi/test/asset_response_dto_test.dart
generated
Binary file not shown.
@ -4866,10 +4866,6 @@
|
||||
"isArchived": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mimeType": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"duration": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -4915,7 +4911,6 @@
|
||||
"updatedAt",
|
||||
"isFavorite",
|
||||
"isArchived",
|
||||
"mimeType",
|
||||
"duration",
|
||||
"checksum"
|
||||
]
|
||||
|
26
server/package-lock.json
generated
26
server/package-lock.json
generated
@ -21,7 +21,6 @@
|
||||
"@nestjs/typeorm": "^9.0.1",
|
||||
"@nestjs/websockets": "^9.2.1",
|
||||
"@socket.io/redis-adapter": "^8.0.1",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^0.26.0",
|
||||
"bcrypt": "^5.0.1",
|
||||
@ -40,7 +39,6 @@
|
||||
"local-reverse-geocoder": "0.12.5",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.0.3",
|
||||
"mime-types": "^2.1.35",
|
||||
"mv": "^2.1.1",
|
||||
"nest-commander": "^3.3.0",
|
||||
"openid-client": "^5.2.1",
|
||||
@ -55,8 +53,8 @@
|
||||
"ua-parser-js": "^1.0.35"
|
||||
},
|
||||
"bin": {
|
||||
"immich": "./bin/cli.sh",
|
||||
"immich-admin": "./bin/admin-cli.sh"
|
||||
"immich": "bin/cli.sh",
|
||||
"immich-admin": "bin/admin-cli.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.1.8",
|
||||
@ -3022,11 +3020,6 @@
|
||||
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/mime-types": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
|
||||
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw=="
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
|
||||
@ -9079,7 +9072,6 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
@ -9327,7 +9319,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
@ -12213,7 +12205,6 @@
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@ -14458,11 +14449,6 @@
|
||||
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/mime-types": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
|
||||
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw=="
|
||||
},
|
||||
"@types/multer": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
|
||||
@ -19088,7 +19074,6 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
}
|
||||
@ -19271,7 +19256,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"pirates": {
|
||||
"version": "4.0.5",
|
||||
@ -21284,8 +21269,7 @@
|
||||
"yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
|
||||
},
|
||||
"zip-stream": {
|
||||
"version": "4.1.0",
|
||||
|
@ -51,7 +51,6 @@
|
||||
"@nestjs/typeorm": "^9.0.1",
|
||||
"@nestjs/websockets": "^9.2.1",
|
||||
"@socket.io/redis-adapter": "^8.0.1",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^0.26.0",
|
||||
"bcrypt": "^5.0.1",
|
||||
@ -64,12 +63,12 @@
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"handlebars": "^4.7.7",
|
||||
"i18n-iso-countries": "^7.5.0",
|
||||
"immich": "^0.39.0",
|
||||
"ioredis": "^5.3.1",
|
||||
"joi": "^17.5.0",
|
||||
"local-reverse-geocoder": "0.12.5",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.0.3",
|
||||
"mime-types": "^2.1.35",
|
||||
"mv": "^2.1.1",
|
||||
"nest-commander": "^3.3.0",
|
||||
"openid-client": "^5.2.1",
|
||||
@ -81,8 +80,7 @@
|
||||
"thumbhash": "^0.1.1",
|
||||
"typeorm": "^0.3.11",
|
||||
"typesense": "^1.5.3",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"immich": "^0.39.0"
|
||||
"ua-parser-js": "^1.0.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.1.8",
|
||||
|
@ -156,10 +156,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual({ stream });
|
||||
|
||||
expect(storageMock.createReadStream).toHaveBeenCalledWith(
|
||||
assetEntityStub.image.originalPath,
|
||||
assetEntityStub.image.mimeType,
|
||||
);
|
||||
expect(storageMock.createReadStream).toHaveBeenCalledWith(assetEntityStub.image.originalPath, 'image/jpeg');
|
||||
});
|
||||
|
||||
it('should download an archive', async () => {
|
||||
|
@ -4,6 +4,7 @@ import { DateTime } from 'luxon';
|
||||
import { extname } from 'path';
|
||||
import { AccessCore, IAccessRepository, Permission } from '../access';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { mimeTypes } from '../domain.constant';
|
||||
import { HumanReadableSize, usePagination } from '../domain.util';
|
||||
import { ImmichReadStream, IStorageRepository } from '../storage';
|
||||
import { IAssetRepository } from './asset.repository';
|
||||
@ -20,7 +21,6 @@ export enum UploadFieldName {
|
||||
}
|
||||
|
||||
export interface UploadFile {
|
||||
mimeType: string;
|
||||
checksum: Buffer;
|
||||
originalPath: string;
|
||||
originalName: string;
|
||||
@ -68,7 +68,7 @@ export class AssetService {
|
||||
throw new BadRequestException('Asset not found');
|
||||
}
|
||||
|
||||
return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType);
|
||||
return this.storageRepository.createReadStream(asset.originalPath, mimeTypes.lookup(asset.originalPath));
|
||||
}
|
||||
|
||||
async getDownloadInfo(authUser: AuthUserDto, dto: DownloadDto): Promise<DownloadResponseDto> {
|
||||
|
@ -23,7 +23,6 @@ export class AssetResponseDto {
|
||||
updatedAt!: Date;
|
||||
isFavorite!: boolean;
|
||||
isArchived!: boolean;
|
||||
mimeType!: string | null;
|
||||
duration!: string;
|
||||
exifInfo?: ExifResponseDto;
|
||||
smartInfo?: SmartInfoResponseDto;
|
||||
@ -50,7 +49,6 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||
updatedAt: entity.updatedAt,
|
||||
isFavorite: entity.isFavorite,
|
||||
isArchived: entity.isArchived,
|
||||
mimeType: entity.mimeType,
|
||||
duration: entity.duration ?? '0:00:00.00000',
|
||||
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||
@ -77,7 +75,6 @@ export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
|
||||
updatedAt: entity.updatedAt,
|
||||
isFavorite: entity.isFavorite,
|
||||
isArchived: entity.isArchived,
|
||||
mimeType: entity.mimeType,
|
||||
duration: entity.duration ?? '0:00:00.00000',
|
||||
exifInfo: undefined,
|
||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { extname } from 'node:path';
|
||||
import pkg from 'src/../../package.json';
|
||||
|
||||
const [major, minor, patch] = pkg.version.split('.');
|
||||
@ -28,92 +29,78 @@ export function assertMachineLearningEnabled() {
|
||||
}
|
||||
}
|
||||
|
||||
export const ASSET_MIME_TYPES = [
|
||||
'image/3fr',
|
||||
'image/ari',
|
||||
'image/arw',
|
||||
'image/avif',
|
||||
'image/cap',
|
||||
'image/cin',
|
||||
'image/cr2',
|
||||
'image/cr3',
|
||||
'image/crw',
|
||||
'image/dcr',
|
||||
'image/dng',
|
||||
'image/erf',
|
||||
'image/fff',
|
||||
'image/gif',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'image/iiq',
|
||||
'image/jpeg',
|
||||
'image/jxl',
|
||||
'image/k25',
|
||||
'image/kdc',
|
||||
'image/mrw',
|
||||
'image/nef',
|
||||
'image/orf',
|
||||
'image/ori',
|
||||
'image/pef',
|
||||
'image/png',
|
||||
'image/raf',
|
||||
'image/raw',
|
||||
'image/rwl',
|
||||
'image/sr2',
|
||||
'image/srf',
|
||||
'image/srw',
|
||||
'image/tiff',
|
||||
'image/webp',
|
||||
'image/x-adobe-dng',
|
||||
'image/x-arriflex-ari',
|
||||
'image/x-canon-cr2',
|
||||
'image/x-canon-cr3',
|
||||
'image/x-canon-crw',
|
||||
'image/x-epson-erf',
|
||||
'image/x-fuji-raf',
|
||||
'image/x-hasselblad-3fr',
|
||||
'image/x-hasselblad-fff',
|
||||
'image/x-kodak-dcr',
|
||||
'image/x-kodak-k25',
|
||||
'image/x-kodak-kdc',
|
||||
'image/x-leica-rwl',
|
||||
'image/x-minolta-mrw',
|
||||
'image/x-nikon-nef',
|
||||
'image/x-olympus-orf',
|
||||
'image/x-olympus-ori',
|
||||
'image/x-panasonic-raw',
|
||||
'image/x-pentax-pef',
|
||||
'image/x-phantom-cin',
|
||||
'image/x-phaseone-cap',
|
||||
'image/x-phaseone-iiq',
|
||||
'image/x-samsung-srw',
|
||||
'image/x-sigma-x3f',
|
||||
'image/x-sony-arw',
|
||||
'image/x-sony-sr2',
|
||||
'image/x-sony-srf',
|
||||
'image/x3f',
|
||||
'video/3gpp',
|
||||
'video/avi',
|
||||
'video/mp2t',
|
||||
'video/mp4',
|
||||
'video/mpeg',
|
||||
'video/msvideo',
|
||||
'video/quicktime',
|
||||
'video/vnd.avi',
|
||||
'video/webm',
|
||||
'video/x-flv',
|
||||
'video/x-matroska',
|
||||
'video/x-ms-wmv',
|
||||
'video/x-msvideo',
|
||||
];
|
||||
export const LIVE_PHOTO_MIME_TYPES = ASSET_MIME_TYPES;
|
||||
export const SIDECAR_MIME_TYPES = ['application/xml', 'text/xml'];
|
||||
export const PROFILE_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'image/dng',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
];
|
||||
const profile: Record<string, string> = {
|
||||
'.avif': 'image/avif',
|
||||
'.dng': 'image/x-adobe-dng',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
|
||||
const image: Record<string, string> = {
|
||||
...profile,
|
||||
'.3fr': 'image/x-hasselblad-3fr',
|
||||
'.ari': 'image/x-arriflex-ari',
|
||||
'.arw': 'image/x-sony-arw',
|
||||
'.cap': 'image/x-phaseone-cap',
|
||||
'.cin': 'image/x-phantom-cin',
|
||||
'.cr2': 'image/x-canon-cr2',
|
||||
'.cr3': 'image/x-canon-cr3',
|
||||
'.crw': 'image/x-canon-crw',
|
||||
'.dcr': 'image/x-kodak-dcr',
|
||||
'.erf': 'image/x-epson-erf',
|
||||
'.fff': 'image/x-hasselblad-fff',
|
||||
'.gif': 'image/gif',
|
||||
'.iiq': 'image/x-phaseone-iiq',
|
||||
'.k25': 'image/x-kodak-k25',
|
||||
'.kdc': 'image/x-kodak-kdc',
|
||||
'.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> = {
|
||||
'.3gp': 'video/3gpp',
|
||||
'.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> = {
|
||||
'.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()];
|
||||
|
||||
export const mimeTypes = {
|
||||
image,
|
||||
profile,
|
||||
sidecar,
|
||||
video,
|
||||
|
||||
isAsset: (filename: string) => isType(filename, image) || isType(filename, video),
|
||||
isProfile: (filename: string) => isType(filename, profile),
|
||||
isSidecar: (filename: string) => isType(filename, sidecar),
|
||||
isVideo: (filename: string) => isType(filename, video),
|
||||
lookup: (filename: string) => getType(filename, { ...image, ...video, ...sidecar }) || 'application/octet-stream',
|
||||
};
|
||||
|
@ -268,7 +268,7 @@ describe(FacialRecognitionService.name, () => {
|
||||
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']);
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
|
||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
|
||||
left: 95,
|
||||
top: 95,
|
||||
width: 110,
|
||||
@ -289,7 +289,7 @@ describe(FacialRecognitionService.name, () => {
|
||||
|
||||
await sut.handleGenerateFaceThumbnail(face.start);
|
||||
|
||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
|
||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 510,
|
||||
@ -306,7 +306,7 @@ describe(FacialRecognitionService.name, () => {
|
||||
|
||||
await sut.handleGenerateFaceThumbnail(face.end);
|
||||
|
||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
|
||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
|
||||
left: 297,
|
||||
top: 297,
|
||||
width: 202,
|
||||
|
@ -116,7 +116,7 @@ describe(MediaService.name, () => {
|
||||
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/asset-id.jpeg', {
|
||||
size: 1440,
|
||||
format: 'jpeg',
|
||||
});
|
||||
@ -167,11 +167,11 @@ describe(MediaService.name, () => {
|
||||
await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.image.id });
|
||||
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
||||
'/uploads/user-id/thumbs/path.ext',
|
||||
'/uploads/user-id/thumbs/path.ext',
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
'/uploads/user-id/thumbs/path.webp',
|
||||
{ format: 'webp', size: 250 },
|
||||
);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.ext' });
|
||||
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.webp' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -195,7 +195,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.image.id });
|
||||
|
||||
expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext');
|
||||
expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg');
|
||||
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
|
||||
});
|
||||
});
|
||||
|
@ -89,10 +89,10 @@ export class MediaService {
|
||||
return false;
|
||||
}
|
||||
|
||||
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
||||
const webpPath = asset.resizePath.replace('jpeg', 'webp').replace('jpg', 'webp');
|
||||
|
||||
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' });
|
||||
await this.assetRepository.save({ id: asset.id, webpPath: webpPath });
|
||||
await this.assetRepository.save({ id: asset.id, webpPath });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -82,10 +82,10 @@ describe(MetadataService.name, () => {
|
||||
assetMock.save.mockResolvedValue(assetEntityStub.image);
|
||||
storageMock.checkFileExists.mockResolvedValue(true);
|
||||
await sut.handleSidecarDiscovery({ id: assetEntityStub.image.id });
|
||||
expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK);
|
||||
expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: assetEntityStub.image.id,
|
||||
sidecarPath: '/original/path.ext.xmp',
|
||||
sidecarPath: '/original/path.jpg.xmp',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -17,7 +17,7 @@ import { PersonService } from './person.service';
|
||||
const responseDto: PersonResponseDto = {
|
||||
id: 'person-1',
|
||||
name: 'Person 1',
|
||||
thumbnailPath: '/path/to/thumbnail',
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
};
|
||||
|
||||
describe(PersonService.name, () => {
|
||||
@ -74,7 +74,7 @@ describe(PersonService.name, () => {
|
||||
it('should serve the thumbnail', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
await sut.getThumbnail(authStub.admin, 'person-1');
|
||||
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail', 'image/jpeg');
|
||||
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg');
|
||||
});
|
||||
});
|
||||
|
||||
@ -150,7 +150,7 @@ describe(PersonService.name, () => {
|
||||
expect(personMock.delete).toHaveBeenCalledWith(personStub.noName);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILES,
|
||||
data: { files: ['/path/to/thumbnail'] },
|
||||
data: { files: ['/path/to/thumbnail.jpg'] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -2,6 +2,7 @@ import { PersonEntity } from '@app/infra/entities';
|
||||
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { AssetResponseDto, mapAsset } from '../asset';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { mimeTypes } from '../domain.constant';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { ImmichReadStream, IStorageRepository } from '../storage';
|
||||
import { mapPerson, PersonResponseDto, PersonUpdateDto } from './person.dto';
|
||||
@ -44,7 +45,7 @@ export class PersonService {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return this.storageRepository.createReadStream(person.thumbnailPath, 'image/jpeg');
|
||||
return this.storageRepository.createReadStream(person.thumbnailPath, mimeTypes.lookup(person.thumbnailPath));
|
||||
}
|
||||
|
||||
async getAssets(authUser: AuthUserDto, personId: string): Promise<AssetResponseDto[]> {
|
||||
|
@ -56,11 +56,11 @@ describe(StorageTemplateService.name, () => {
|
||||
userMock.getList.mockResolvedValue([userEntityStub.user1]);
|
||||
|
||||
when(storageMock.checkFileExists)
|
||||
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id.ext')
|
||||
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
when(storageMock.checkFileExists)
|
||||
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id+1.ext')
|
||||
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id+1.jpg')
|
||||
.mockResolvedValue(false);
|
||||
|
||||
await sut.handleMigration();
|
||||
@ -69,7 +69,7 @@ describe(StorageTemplateService.name, () => {
|
||||
expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: assetEntityStub.image.id,
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
|
||||
});
|
||||
expect(userMock.getList).toHaveBeenCalled();
|
||||
});
|
||||
@ -79,7 +79,7 @@ describe(StorageTemplateService.name, () => {
|
||||
items: [
|
||||
{
|
||||
...assetEntityStub.image,
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext',
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
|
||||
},
|
||||
],
|
||||
hasNextPage: false,
|
||||
@ -99,7 +99,7 @@ describe(StorageTemplateService.name, () => {
|
||||
items: [
|
||||
{
|
||||
...assetEntityStub.image,
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
|
||||
},
|
||||
],
|
||||
hasNextPage: false,
|
||||
@ -126,12 +126,12 @@ describe(StorageTemplateService.name, () => {
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
expect(storageMock.moveFile).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/library/user-id/2023/2023-02-23/asset-id.ext',
|
||||
'/original/path.jpg',
|
||||
'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
|
||||
);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: assetEntityStub.image.id,
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext',
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
@ -147,12 +147,12 @@ describe(StorageTemplateService.name, () => {
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
expect(storageMock.moveFile).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/library/label-1/2023/2023-02-23/asset-id.ext',
|
||||
'/original/path.jpg',
|
||||
'upload/library/label-1/2023/2023-02-23/asset-id.jpg',
|
||||
);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: assetEntityStub.image.id,
|
||||
originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.ext',
|
||||
originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
@ -168,8 +168,8 @@ describe(StorageTemplateService.name, () => {
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
expect(storageMock.moveFile).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/library/user-id/2023/2023-02-23/asset-id.ext',
|
||||
'/original/path.jpg',
|
||||
'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
|
||||
);
|
||||
expect(assetMock.save).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -187,11 +187,11 @@ describe(StorageTemplateService.name, () => {
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: assetEntityStub.image.id,
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext',
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
|
||||
});
|
||||
expect(storageMock.moveFile.mock.calls).toEqual([
|
||||
['/original/path.ext', 'upload/library/user-id/2023/2023-02-23/asset-id.ext'],
|
||||
['upload/library/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'],
|
||||
['/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg'],
|
||||
['upload/library/user-id/2023/2023-02-23/asset-id.jpg', '/original/path.jpg'],
|
||||
]);
|
||||
});
|
||||
|
||||
@ -200,7 +200,7 @@ describe(StorageTemplateService.name, () => {
|
||||
items: [
|
||||
{
|
||||
...assetEntityStub.image,
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
|
||||
isReadOnly: true,
|
||||
},
|
||||
],
|
||||
|
@ -17,7 +17,6 @@ export class AssetCore {
|
||||
const asset = await this.repository.create({
|
||||
owner: { id: authUser.id } as UserEntity,
|
||||
|
||||
mimeType: file.mimeType,
|
||||
checksum: file.checksum,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
|
@ -1,12 +1,9 @@
|
||||
import {
|
||||
ASSET_MIME_TYPES,
|
||||
ICryptoRepository,
|
||||
IJobRepository,
|
||||
IStorageRepository,
|
||||
JobName,
|
||||
LIVE_PHOTO_MIME_TYPES,
|
||||
PROFILE_MIME_TYPES,
|
||||
SIDECAR_MIME_TYPES,
|
||||
mimeTypes,
|
||||
UploadFieldName,
|
||||
} from '@app/domain';
|
||||
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
||||
@ -60,7 +57,6 @@ const _getAsset_1 = () => {
|
||||
asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.isFavorite = false;
|
||||
asset_1.isArchived = false;
|
||||
asset_1.mimeType = 'image/jpeg';
|
||||
asset_1.webpPath = '';
|
||||
asset_1.encodedVideoPath = '';
|
||||
asset_1.duration = '0:00:00.000000';
|
||||
@ -85,7 +81,6 @@ const _getAsset_2 = () => {
|
||||
asset_2.updatedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_2.isFavorite = false;
|
||||
asset_2.isArchived = false;
|
||||
asset_2.mimeType = 'image/jpeg';
|
||||
asset_2.webpPath = '';
|
||||
asset_2.encodedVideoPath = '';
|
||||
asset_2.duration = '0:00:00.000000';
|
||||
@ -132,24 +127,11 @@ const uploadFile = {
|
||||
authUser: null,
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
file: {
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: 'upload/admin/image.jpeg',
|
||||
originalName: 'image.jpeg',
|
||||
},
|
||||
},
|
||||
mimeType: (fieldName: UploadFieldName, mimeType: string) => {
|
||||
return {
|
||||
authUser: authStub.admin,
|
||||
fieldName,
|
||||
file: {
|
||||
mimeType,
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: 'upload/admin/image.jpeg',
|
||||
originalName: 'image.jpeg',
|
||||
},
|
||||
};
|
||||
},
|
||||
filename: (fieldName: UploadFieldName, filename: string) => {
|
||||
return {
|
||||
authUser: authStub.admin,
|
||||
@ -164,6 +146,33 @@ const uploadFile = {
|
||||
},
|
||||
};
|
||||
|
||||
const uploadTests = [
|
||||
{
|
||||
label: 'asset',
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
filetypes: Object.keys({ ...mimeTypes.image, ...mimeTypes.video }),
|
||||
invalid: ['.xml', '.html'],
|
||||
},
|
||||
{
|
||||
label: 'live photo',
|
||||
fieldName: UploadFieldName.LIVE_PHOTO_DATA,
|
||||
filetypes: Object.keys(mimeTypes.video),
|
||||
invalid: ['.xml', '.html', '.jpg', '.jpeg'],
|
||||
},
|
||||
{
|
||||
label: 'sidecar',
|
||||
fieldName: UploadFieldName.SIDECAR_DATA,
|
||||
filetypes: Object.keys(mimeTypes.sidecar),
|
||||
invalid: ['.xml', '.html', '.jpg', '.jpeg', '.mov', '.mp4'],
|
||||
},
|
||||
{
|
||||
label: 'profile',
|
||||
fieldName: UploadFieldName.PROFILE_DATA,
|
||||
filetypes: Object.keys(mimeTypes.profile),
|
||||
invalid: ['.xml', '.html', '.cr2', '.arf', '.mov', '.mp4'],
|
||||
},
|
||||
];
|
||||
|
||||
describe('AssetService', () => {
|
||||
let sut: AssetService;
|
||||
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
|
||||
@ -195,8 +204,6 @@ describe('AssetService', () => {
|
||||
getByOriginalPath: jest.fn(),
|
||||
};
|
||||
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
|
||||
accessMock = newAccessRepositoryMock();
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
@ -212,60 +219,106 @@ describe('AssetService', () => {
|
||||
.mockResolvedValue(assetEntityStub.livePhotoMotionAsset);
|
||||
});
|
||||
|
||||
const tests = [
|
||||
{ label: 'asset', fieldName: UploadFieldName.ASSET_DATA, mimeTypes: ASSET_MIME_TYPES },
|
||||
{ label: 'live photo', fieldName: UploadFieldName.LIVE_PHOTO_DATA, mimeTypes: LIVE_PHOTO_MIME_TYPES },
|
||||
{ label: 'sidecar', fieldName: UploadFieldName.SIDECAR_DATA, mimeTypes: SIDECAR_MIME_TYPES },
|
||||
{ label: 'profile', fieldName: UploadFieldName.PROFILE_DATA, mimeTypes: PROFILE_MIME_TYPES },
|
||||
];
|
||||
|
||||
for (const { label, fieldName, mimeTypes } of tests) {
|
||||
describe(`${label} mime types linting`, () => {
|
||||
it('should be a sorted list', () => {
|
||||
expect(mimeTypes).toEqual(mimeTypes.sort());
|
||||
});
|
||||
|
||||
it('should contain only unique values', () => {
|
||||
expect(mimeTypes).toEqual([...new Set(mimeTypes)]);
|
||||
});
|
||||
|
||||
if (fieldName !== UploadFieldName.SIDECAR_DATA) {
|
||||
it('should contain only image or video mime types', () => {
|
||||
expect(mimeTypes).toEqual(
|
||||
mimeTypes.filter((mimeType) => mimeType.startsWith('image/') || mimeType.startsWith('video/')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe('mime types linting', () => {
|
||||
describe('profile', () => {
|
||||
it('should contain only lowercase mime types', () => {
|
||||
expect(mimeTypes).toEqual(mimeTypes.map((mimeType) => mimeType.toLowerCase()));
|
||||
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('canUpload', () => {
|
||||
it('should require an authenticated user', () => {
|
||||
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should accept all accepted mime types', () => {
|
||||
for (const { fieldName, mimeTypes } of tests) {
|
||||
for (const mimeType of mimeTypes) {
|
||||
expect(sut.canUploadFile(uploadFile.mimeType(fieldName, mimeType))).toEqual(true);
|
||||
for (const { fieldName, filetypes, invalid } of uploadTests) {
|
||||
describe(`${fieldName}`, () => {
|
||||
for (const filetype of filetypes) {
|
||||
it(`should accept ${filetype}`, () => {
|
||||
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject other mime types', () => {
|
||||
for (const { fieldName, mimeType } of [
|
||||
{ fieldName: UploadFieldName.ASSET_DATA, mimeType: 'application/html' },
|
||||
{ fieldName: UploadFieldName.LIVE_PHOTO_DATA, mimeType: 'application/html' },
|
||||
{ fieldName: UploadFieldName.PROFILE_DATA, mimeType: 'application/html' },
|
||||
{ fieldName: UploadFieldName.SIDECAR_DATA, mimeType: 'image/jpeg' },
|
||||
]) {
|
||||
expect(() => sut.canUploadFile(uploadFile.mimeType(fieldName, mimeType))).toThrowError(BadRequestException);
|
||||
}
|
||||
});
|
||||
for (const filetype of invalid) {
|
||||
it(`should reject ${filetype}`, () => {
|
||||
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('getUploadFilename', () => {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import {
|
||||
AccessCore,
|
||||
AssetResponseDto,
|
||||
ASSET_MIME_TYPES,
|
||||
AuthUserDto,
|
||||
getLivePhotoMotionFilename,
|
||||
IAccessRepository,
|
||||
@ -9,12 +8,10 @@ import {
|
||||
IJobRepository,
|
||||
IStorageRepository,
|
||||
JobName,
|
||||
LIVE_PHOTO_MIME_TYPES,
|
||||
mapAsset,
|
||||
mapAssetWithoutExif,
|
||||
mimeTypes,
|
||||
Permission,
|
||||
PROFILE_MIME_TYPES,
|
||||
SIDECAR_MIME_TYPES,
|
||||
StorageCore,
|
||||
StorageFolder,
|
||||
UploadFieldName,
|
||||
@ -33,7 +30,6 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Response as Res } from 'express';
|
||||
import { constants, createReadStream } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import mime from 'mime-types';
|
||||
import path, { extname } from 'path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { pipeline } from 'stream/promises';
|
||||
@ -71,11 +67,6 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re
|
||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
|
||||
|
||||
interface ServableFile {
|
||||
filepath: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetService {
|
||||
readonly logger = new Logger(AssetService.name);
|
||||
@ -98,35 +89,36 @@ export class AssetService {
|
||||
canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
|
||||
this.access.requireUploadAccess(authUser);
|
||||
|
||||
const filename = file.originalName;
|
||||
|
||||
switch (fieldName) {
|
||||
case UploadFieldName.ASSET_DATA:
|
||||
if (ASSET_MIME_TYPES.includes(file.mimeType)) {
|
||||
if (mimeTypes.isAsset(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
|
||||
case UploadFieldName.LIVE_PHOTO_DATA:
|
||||
if (LIVE_PHOTO_MIME_TYPES.includes(file.mimeType)) {
|
||||
if (mimeTypes.isVideo(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
|
||||
case UploadFieldName.SIDECAR_DATA:
|
||||
if (SIDECAR_MIME_TYPES.includes(file.mimeType)) {
|
||||
if (mimeTypes.isSidecar(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
|
||||
case UploadFieldName.PROFILE_DATA:
|
||||
if (PROFILE_MIME_TYPES.includes(file.mimeType)) {
|
||||
if (mimeTypes.isProfile(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const ext = extname(file.originalName);
|
||||
this.logger.error(`Unsupported file type ${ext} file MIME type ${file.mimeType}`);
|
||||
throw new BadRequestException(`Unsupported file type ${ext}`);
|
||||
this.logger.error(`Unsupported file type ${filename}`);
|
||||
throw new BadRequestException(`Unsupported file type ${filename}`);
|
||||
}
|
||||
|
||||
getUploadFilename({ authUser, fieldName, file }: UploadRequest): string {
|
||||
@ -208,15 +200,12 @@ export class AssetService {
|
||||
sidecarPath: dto.sidecarPath ? path.resolve(dto.sidecarPath) : undefined,
|
||||
};
|
||||
|
||||
const mimeType = mime.lookup(dto.assetPath) as string;
|
||||
if (!ASSET_MIME_TYPES.includes(mimeType)) {
|
||||
throw new BadRequestException(`Unsupported file type ${mimeType}`);
|
||||
if (!mimeTypes.isAsset(dto.assetPath)) {
|
||||
throw new BadRequestException(`Unsupported file type ${dto.assetPath}`);
|
||||
}
|
||||
|
||||
if (dto.sidecarPath) {
|
||||
if (path.extname(dto.sidecarPath).toLowerCase() !== '.xmp') {
|
||||
throw new BadRequestException(`Unsupported sidecar file type`);
|
||||
}
|
||||
if (dto.sidecarPath && !mimeTypes.isSidecar(dto.sidecarPath)) {
|
||||
throw new BadRequestException(`Unsupported sidecar file type`);
|
||||
}
|
||||
|
||||
for (const filepath of [dto.assetPath, dto.sidecarPath]) {
|
||||
@ -236,7 +225,6 @@ export class AssetService {
|
||||
|
||||
const assetFile: UploadFile = {
|
||||
checksum: await this.cryptoRepository.hashFile(dto.assetPath),
|
||||
mimeType,
|
||||
originalPath: dto.assetPath,
|
||||
originalName: path.parse(dto.assetPath).name,
|
||||
};
|
||||
@ -328,8 +316,7 @@ export class AssetService {
|
||||
}
|
||||
|
||||
try {
|
||||
const [thumbnailPath, contentType] = this.getThumbnailPath(asset, query.format);
|
||||
return this.streamFile(thumbnailPath, res, headers, contentType);
|
||||
return this.streamFile(this.getThumbnailPath(asset, query.format), res, headers);
|
||||
} catch (e) {
|
||||
res.header('Cache-Control', 'none');
|
||||
this.logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail');
|
||||
@ -360,8 +347,7 @@ export class AssetService {
|
||||
// Handle Sending Images
|
||||
if (asset.type == AssetType.IMAGE) {
|
||||
try {
|
||||
const { filepath, contentType } = this.getServePath(asset, query, allowOriginalFile);
|
||||
return this.streamFile(filepath, res, headers, contentType);
|
||||
return this.streamFile(this.getServePath(asset, query, allowOriginalFile), res, headers);
|
||||
} catch (e) {
|
||||
this.logger.error(`Cannot create read stream for asset ${asset.id} ${JSON.stringify(e)}`, 'serveFile[IMAGE]');
|
||||
throw new InternalServerErrorException(
|
||||
@ -371,10 +357,7 @@ export class AssetService {
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const videoPath = asset.encodedVideoPath ? asset.encodedVideoPath : asset.originalPath;
|
||||
const mimeType = asset.encodedVideoPath ? 'video/mp4' : asset.mimeType;
|
||||
|
||||
return this.streamFile(videoPath, res, headers, mimeType);
|
||||
return this.streamFile(asset.encodedVideoPath || asset.originalPath, res, headers);
|
||||
} catch (e: Error | any) {
|
||||
this.logger.error(`Error serving VIDEO asset=${asset.id}`, e?.stack);
|
||||
throw new InternalServerErrorException(`Failed to serve video asset ${e}`, 'ServeFile');
|
||||
@ -595,7 +578,7 @@ export class AssetService {
|
||||
switch (format) {
|
||||
case GetAssetThumbnailFormatEnum.WEBP:
|
||||
if (asset.webpPath) {
|
||||
return [asset.webpPath, 'image/webp'];
|
||||
return asset.webpPath;
|
||||
}
|
||||
this.logger.warn(`WebP thumbnail requested but not found for asset ${asset.id}, falling back to JPEG`);
|
||||
|
||||
@ -604,48 +587,48 @@ export class AssetService {
|
||||
if (!asset.resizePath) {
|
||||
throw new NotFoundException(`No thumbnail found for asset ${asset.id}`);
|
||||
}
|
||||
return [asset.resizePath, 'image/jpeg'];
|
||||
return asset.resizePath;
|
||||
}
|
||||
}
|
||||
|
||||
private getServePath(asset: AssetEntity, query: ServeFileDto, allowOriginalFile: boolean): ServableFile {
|
||||
private getServePath(asset: AssetEntity, query: ServeFileDto, allowOriginalFile: boolean): string {
|
||||
const mimeType = mimeTypes.lookup(asset.originalPath);
|
||||
|
||||
/**
|
||||
* Serve file viewer on the web
|
||||
*/
|
||||
if (query.isWeb && asset.mimeType != 'image/gif') {
|
||||
if (query.isWeb && mimeType != 'image/gif') {
|
||||
if (!asset.resizePath) {
|
||||
this.logger.error('Error serving IMAGE asset for web');
|
||||
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
|
||||
}
|
||||
|
||||
return { filepath: asset.resizePath, contentType: 'image/jpeg' };
|
||||
return asset.resizePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve thumbnail image for both web and mobile app
|
||||
*/
|
||||
if ((!query.isThumb && allowOriginalFile) || (query.isWeb && asset.mimeType === 'image/gif')) {
|
||||
return { filepath: asset.originalPath, contentType: asset.mimeType as string };
|
||||
if ((!query.isThumb && allowOriginalFile) || (query.isWeb && mimeType === 'image/gif')) {
|
||||
return asset.originalPath;
|
||||
}
|
||||
|
||||
if (asset.webpPath && asset.webpPath.length > 0) {
|
||||
return { filepath: asset.webpPath, contentType: 'image/webp' };
|
||||
return asset.webpPath;
|
||||
}
|
||||
|
||||
if (!asset.resizePath) {
|
||||
throw new Error('resizePath not set');
|
||||
}
|
||||
|
||||
return { filepath: asset.resizePath, contentType: 'image/jpeg' };
|
||||
return asset.resizePath;
|
||||
}
|
||||
|
||||
private async streamFile(filepath: string, res: Res, headers: Record<string, string>, contentType?: string | null) {
|
||||
private async streamFile(filepath: string, res: Res, headers: Record<string, string>) {
|
||||
await fs.access(filepath, constants.R_OK);
|
||||
const { size, mtimeNs } = await fs.stat(filepath, { bigint: true });
|
||||
|
||||
if (contentType) {
|
||||
res.header('Content-Type', contentType);
|
||||
}
|
||||
res.header('Content-Type', mimeTypes.lookup(filepath));
|
||||
|
||||
const range = this.setResRange(res, headers, Number(size));
|
||||
|
||||
|
@ -23,7 +23,6 @@ export interface ImmichFile extends Express.Multer.File {
|
||||
export function mapToUploadFile(file: ImmichFile): UploadFile {
|
||||
return {
|
||||
checksum: file.checksum,
|
||||
mimeType: file.mimetype,
|
||||
originalPath: file.path,
|
||||
originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'),
|
||||
};
|
||||
|
@ -78,9 +78,6 @@ export class AssetEntity {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isReadOnly!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
mimeType!: string | null;
|
||||
|
||||
@Column({ type: 'bytea' })
|
||||
@Index()
|
||||
checksum!: Buffer; // sha1 checksum
|
||||
|
@ -0,0 +1,14 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class DropMimeTypeColumn1689001889950 implements MigrationInterface {
|
||||
name = 'DropMimeTypeColumn1689001889950'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "mimeType"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "assets" ADD "mimeType" character varying`);
|
||||
}
|
||||
|
||||
}
|
@ -190,13 +190,11 @@ export const userEntityStub = {
|
||||
export const fileStub = {
|
||||
livePhotoStill: Object.freeze({
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
}),
|
||||
livePhotoMotion: Object.freeze({
|
||||
originalPath: 'fake_path/asset_1.mp4',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('live photo file hash', 'utf8'),
|
||||
originalName: 'asset_1.mp4',
|
||||
}),
|
||||
@ -221,7 +219,6 @@ export const assetEntityStub = {
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
mimeType: null,
|
||||
isFavorite: true,
|
||||
isArchived: false,
|
||||
duration: null,
|
||||
@ -251,7 +248,6 @@ export const assetEntityStub = {
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
mimeType: null,
|
||||
isFavorite: true,
|
||||
isArchived: false,
|
||||
duration: null,
|
||||
@ -285,7 +281,6 @@ export const assetEntityStub = {
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
mimeType: null,
|
||||
isFavorite: true,
|
||||
isArchived: false,
|
||||
isReadOnly: false,
|
||||
@ -307,8 +302,8 @@ export const assetEntityStub = {
|
||||
owner: userEntityStub.user1,
|
||||
ownerId: 'user-id',
|
||||
deviceId: 'device-id',
|
||||
originalPath: '/original/path.ext',
|
||||
resizePath: '/uploads/user-id/thumbs/path.ext',
|
||||
originalPath: '/original/path.jpg',
|
||||
resizePath: '/uploads/user-id/thumbs/path.jpg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
type: AssetType.IMAGE,
|
||||
webpPath: '/uploads/user-id/webp/path.ext',
|
||||
@ -316,7 +311,6 @@ export const assetEntityStub = {
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
mimeType: null,
|
||||
isFavorite: true,
|
||||
isArchived: false,
|
||||
isReadOnly: false,
|
||||
@ -326,7 +320,7 @@ export const assetEntityStub = {
|
||||
livePhotoVideoId: null,
|
||||
tags: [],
|
||||
sharedLinks: [],
|
||||
originalFileName: 'asset-id.ext',
|
||||
originalFileName: 'asset-id.jpg',
|
||||
faces: [],
|
||||
sidecarPath: null,
|
||||
exifInfo: {
|
||||
@ -351,7 +345,6 @@ export const assetEntityStub = {
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
mimeType: null,
|
||||
isFavorite: true,
|
||||
isArchived: false,
|
||||
isReadOnly: false,
|
||||
@ -412,7 +405,6 @@ export const assetEntityStub = {
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
mimeType: null,
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
isReadOnly: false,
|
||||
@ -447,7 +439,6 @@ export const assetEntityStub = {
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
mimeType: null,
|
||||
isFavorite: true,
|
||||
isArchived: false,
|
||||
isReadOnly: false,
|
||||
@ -621,7 +612,6 @@ const assetResponse: AssetResponseDto = {
|
||||
updatedAt: today,
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
mimeType: 'image/jpeg',
|
||||
smartInfo: {
|
||||
tags: [],
|
||||
objects: ['a', 'b', 'c'],
|
||||
@ -909,7 +899,6 @@ export const sharedLinkStub = {
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
isReadOnly: false,
|
||||
mimeType: 'image/jpeg',
|
||||
smartInfo: {
|
||||
assetId: 'id_1',
|
||||
tags: [],
|
||||
@ -1136,7 +1125,7 @@ export const personStub = {
|
||||
ownerId: userEntityStub.admin.id,
|
||||
owner: userEntityStub.admin,
|
||||
name: '',
|
||||
thumbnailPath: '/path/to/thumbnail',
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
faces: [],
|
||||
}),
|
||||
withName: Object.freeze<PersonEntity>({
|
||||
@ -1146,7 +1135,7 @@ export const personStub = {
|
||||
ownerId: userEntityStub.admin.id,
|
||||
owner: userEntityStub.admin,
|
||||
name: 'Person 1',
|
||||
thumbnailPath: '/path/to/thumbnail',
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
faces: [],
|
||||
}),
|
||||
noThumbnail: Object.freeze<PersonEntity>({
|
||||
@ -1166,7 +1155,7 @@ export const personStub = {
|
||||
ownerId: userEntityStub.admin.id,
|
||||
owner: userEntityStub.admin,
|
||||
name: '',
|
||||
thumbnailPath: '/new/path/to/thumbnail',
|
||||
thumbnailPath: '/new/path/to/thumbnail.jpg',
|
||||
faces: [],
|
||||
}),
|
||||
};
|
||||
|
6
web/src/api/open-api/api.ts
generated
6
web/src/api/open-api/api.ts
generated
@ -679,12 +679,6 @@ export interface AssetResponseDto {
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'isArchived': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'mimeType': string | null;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
|
Loading…
Reference in New Issue
Block a user