diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 986e7d0b38..fa0cccb209 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -1,18 +1,21 @@ import { AssetType } from '@app/infra/entities'; -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { assetEntityStub, authStub, IAccessRepositoryMock, newAccessRepositoryMock, newAssetRepositoryMock, + newCryptoRepositoryMock, newStorageRepositoryMock, } from '@test'; import { when } from 'jest-when'; import { Readable } from 'stream'; +import { ICryptoRepository } from '../crypto'; +import { mimeTypes } from '../domain.constant'; import { IStorageRepository } from '../storage'; import { AssetStats, IAssetRepository } from './asset.repository'; -import { AssetService } from './asset.service'; +import { AssetService, UploadFieldName } from './asset.service'; import { AssetStatsResponseDto, DownloadResponseDto } from './dto'; import { mapAsset } from './response-dto'; @@ -39,10 +42,62 @@ const statResponse: AssetStatsResponseDto = { total: 33, }; +const uploadFile = { + nullAuth: { + authUser: null, + fieldName: UploadFieldName.ASSET_DATA, + file: { + checksum: Buffer.from('checksum', 'utf8'), + originalPath: 'upload/admin/image.jpeg', + originalName: 'image.jpeg', + }, + }, + filename: (fieldName: UploadFieldName, filename: string) => { + return { + authUser: authStub.admin, + fieldName, + file: { + mimeType: 'image/jpeg', + checksum: Buffer.from('checksum', 'utf8'), + originalPath: `upload/admin/${filename}`, + originalName: filename, + }, + }; + }, +}; + +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.name, () => { let sut: AssetService; let accessMock: IAccessRepositoryMock; let assetMock: jest.Mocked; + let cryptoMock: jest.Mocked; let storageMock: jest.Mocked; it('should work', () => { @@ -52,8 +107,83 @@ describe(AssetService.name, () => { beforeEach(async () => { accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); + cryptoMock = newCryptoRepositoryMock(); storageMock = newStorageRepositoryMock(); - sut = new AssetService(accessMock, assetMock, storageMock); + sut = new AssetService(accessMock, assetMock, cryptoMock, storageMock); + }); + + describe('canUpload', () => { + it('should require an authenticated user', () => { + expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException); + }); + + 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); + }); + } + + for (const filetype of invalid) { + it(`should reject ${filetype}`, () => { + expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError( + BadRequestException, + ); + }); + } + }); + } + }); + + describe('getUploadFilename', () => { + it('should require authentication', () => { + expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException); + }); + + it('should be the original extension for asset upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( + 'random-uuid.jpg', + ); + }); + + it('should be the mov extension for live photo upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual( + 'random-uuid.mov', + ); + }); + + it('should be the xmp extension for sidecar upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual( + 'random-uuid.xmp', + ); + }); + + it('should be the original extension for profile upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( + 'random-uuid.jpg', + ); + }); + }); + + describe('getUploadFolder', () => { + it('should require authentication', () => { + expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException); + }); + + it('should return profile for profile uploads', () => { + expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( + 'upload/profile/admin_id', + ); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); + }); + + it('should return upload for everything else', () => { + expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( + 'upload/upload/admin_id', + ); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id'); + }); }); describe('getMapMarkers', () => { diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 90595de69b..cb2701ea12 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -1,12 +1,14 @@ import { AssetEntity } from '@app/infra/entities'; -import { BadRequestException, Inject } from '@nestjs/common'; +import { BadRequestException, Inject, Logger } from '@nestjs/common'; import { DateTime } from 'luxon'; import { extname } from 'path'; +import sanitize from 'sanitize-filename'; import { AccessCore, IAccessRepository, Permission } from '../access'; import { AuthUserDto } from '../auth'; +import { ICryptoRepository } from '../crypto'; import { mimeTypes } from '../domain.constant'; import { HumanReadableSize, usePagination } from '../domain.util'; -import { ImmichReadStream, IStorageRepository } from '../storage'; +import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage'; import { IAssetRepository } from './asset.repository'; import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto'; import { AssetStatsDto, mapStats } from './dto/asset-statistics.dto'; @@ -21,6 +23,12 @@ export enum UploadFieldName { PROFILE_DATA = 'file', } +export interface UploadRequest { + authUser: AuthUserDto | null; + fieldName: UploadFieldName; + file: UploadFile; +} + export interface UploadFile { checksum: Buffer; originalPath: string; @@ -28,16 +36,82 @@ export interface UploadFile { } export class AssetService { + private logger = new Logger(AssetService.name); private access: AccessCore; + private storageCore = new StorageCore(); constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { this.access = new AccessCore(accessRepository); } + canUploadFile({ authUser, fieldName, file }: UploadRequest): true { + this.access.requireUploadAccess(authUser); + + const filename = file.originalName; + + switch (fieldName) { + case UploadFieldName.ASSET_DATA: + if (mimeTypes.isAsset(filename)) { + return true; + } + break; + + case UploadFieldName.LIVE_PHOTO_DATA: + if (mimeTypes.isVideo(filename)) { + return true; + } + break; + + case UploadFieldName.SIDECAR_DATA: + if (mimeTypes.isSidecar(filename)) { + return true; + } + break; + + case UploadFieldName.PROFILE_DATA: + if (mimeTypes.isProfile(filename)) { + return true; + } + break; + } + + this.logger.error(`Unsupported file type ${filename}`); + throw new BadRequestException(`Unsupported file type ${filename}`); + } + + getUploadFilename({ authUser, fieldName, file }: UploadRequest): string { + this.access.requireUploadAccess(authUser); + + const originalExt = extname(file.originalName); + + const lookup = { + [UploadFieldName.ASSET_DATA]: originalExt, + [UploadFieldName.LIVE_PHOTO_DATA]: '.mov', + [UploadFieldName.SIDECAR_DATA]: '.xmp', + [UploadFieldName.PROFILE_DATA]: originalExt, + }; + + return sanitize(`${this.cryptoRepository.randomUUID()}${lookup[fieldName]}`); + } + + getUploadFolder({ authUser, fieldName }: UploadRequest): string { + authUser = this.access.requireUploadAccess(authUser); + + let folder = this.storageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id); + if (fieldName === UploadFieldName.PROFILE_DATA) { + folder = this.storageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id); + } + + this.storageRepository.mkdirSync(folder); + + return folder; + } + getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise { return this.assetRepository.getMapMarkers(authUser.id, options); } diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 110d63f50c..5528f7bcc6 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -1,13 +1,6 @@ -import { - ICryptoRepository, - IJobRepository, - IStorageRepository, - JobName, - mimeTypes, - UploadFieldName, -} from '@app/domain'; +import { ICryptoRepository, IJobRepository, IStorageRepository, JobName, mimeTypes } from '@app/domain'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; -import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; import { assetEntityStub, authStub, @@ -102,57 +95,6 @@ const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => { return [result1, result2]; }; -const uploadFile = { - nullAuth: { - authUser: null, - fieldName: UploadFieldName.ASSET_DATA, - file: { - checksum: Buffer.from('checksum', 'utf8'), - originalPath: 'upload/admin/image.jpeg', - originalName: 'image.jpeg', - }, - }, - filename: (fieldName: UploadFieldName, filename: string) => { - return { - authUser: authStub.admin, - fieldName, - file: { - mimeType: 'image/jpeg', - checksum: Buffer.from('checksum', 'utf8'), - originalPath: `upload/admin/${filename}`, - originalName: filename, - }, - }; - }, -}; - -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; // TO BE DELETED AFTER FINISHED REFACTORING @@ -275,80 +217,6 @@ describe('AssetService', () => { }); }); - describe('canUpload', () => { - it('should require an authenticated user', () => { - expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException); - }); - - 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); - }); - } - - for (const filetype of invalid) { - it(`should reject ${filetype}`, () => { - expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError( - BadRequestException, - ); - }); - } - }); - } - }); - - describe('getUploadFilename', () => { - it('should require authentication', () => { - expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException); - }); - - it('should be the original extension for asset upload', () => { - expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( - 'random-uuid.jpg', - ); - }); - - it('should be the mov extension for live photo upload', () => { - expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual( - 'random-uuid.mov', - ); - }); - - it('should be the xmp extension for sidecar upload', () => { - expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual( - 'random-uuid.xmp', - ); - }); - - it('should be the original extension for profile upload', () => { - expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( - 'random-uuid.jpg', - ); - }); - }); - - describe('getUploadFolder', () => { - it('should require authentication', () => { - expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException); - }); - - it('should return profile for profile uploads', () => { - expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( - 'upload/profile/admin_id', - ); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); - }); - - it('should return upload for everything else', () => { - expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( - 'upload/upload/admin_id', - ); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id'); - }); - }); - describe('uploadFile', () => { it('should handle a file upload', async () => { const assetEntity = _getAsset_1(); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 795a7148a1..61520a9b06 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -12,9 +12,6 @@ import { mapAssetWithoutExif, mimeTypes, Permission, - StorageCore, - StorageFolder, - UploadFieldName, UploadFile, } from '@app/domain'; import { AssetEntity, AssetType } from '@app/infra/entities'; @@ -30,10 +27,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Response as Res } from 'express'; import { constants } from 'fs'; import fs from 'fs/promises'; -import path, { extname } from 'path'; -import sanitize from 'sanitize-filename'; +import path from 'path'; import { QueryFailedError, Repository } from 'typeorm'; -import { UploadRequest } from '../../app.interceptor'; import { IAssetRepository } from './asset-repository'; import { AssetCore } from './asset.core'; import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; @@ -70,7 +65,6 @@ export class AssetService { readonly logger = new Logger(AssetService.name); private assetCore: AssetCore; private access: AccessCore; - private storageCore = new StorageCore(); constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @@ -84,69 +78,6 @@ export class AssetService { this.access = new AccessCore(accessRepository); } - canUploadFile({ authUser, fieldName, file }: UploadRequest): true { - this.access.requireUploadAccess(authUser); - - const filename = file.originalName; - - switch (fieldName) { - case UploadFieldName.ASSET_DATA: - if (mimeTypes.isAsset(filename)) { - return true; - } - break; - - case UploadFieldName.LIVE_PHOTO_DATA: - if (mimeTypes.isVideo(filename)) { - return true; - } - break; - - case UploadFieldName.SIDECAR_DATA: - if (mimeTypes.isSidecar(filename)) { - return true; - } - break; - - case UploadFieldName.PROFILE_DATA: - if (mimeTypes.isProfile(filename)) { - return true; - } - break; - } - - this.logger.error(`Unsupported file type ${filename}`); - throw new BadRequestException(`Unsupported file type ${filename}`); - } - - getUploadFilename({ authUser, fieldName, file }: UploadRequest): string { - this.access.requireUploadAccess(authUser); - - const originalExt = extname(file.originalName); - - const lookup = { - [UploadFieldName.ASSET_DATA]: originalExt, - [UploadFieldName.LIVE_PHOTO_DATA]: '.mov', - [UploadFieldName.SIDECAR_DATA]: '.xmp', - [UploadFieldName.PROFILE_DATA]: originalExt, - }; - - return sanitize(`${this.cryptoRepository.randomUUID()}${lookup[fieldName]}`); - } - - getUploadFolder({ authUser, fieldName }: UploadRequest): string { - authUser = this.access.requireUploadAccess(authUser); - - let folder = this.storageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id); - if (fieldName === UploadFieldName.PROFILE_DATA) { - folder = this.storageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id); - } - - this.storageRepository.mkdirSync(folder); - - return folder; - } - public async uploadFile( authUser: AuthUserDto, dto: CreateAssetDto, diff --git a/server/src/immich/app.interceptor.ts b/server/src/immich/app.interceptor.ts index d6c4a3a7ec..28c0019976 100644 --- a/server/src/immich/app.interceptor.ts +++ b/server/src/immich/app.interceptor.ts @@ -1,4 +1,4 @@ -import { AuthUserDto, UploadFieldName, UploadFile } from '@app/domain'; +import { AssetService, UploadFieldName, UploadFile } from '@app/domain'; import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common'; import { PATH_METADATA } from '@nestjs/common/constants'; import { Reflector } from '@nestjs/core'; @@ -7,7 +7,6 @@ import { createHash } from 'crypto'; import { NextFunction, RequestHandler } from 'express'; import multer, { diskStorage, StorageEngine } from 'multer'; import { Observable } from 'rxjs'; -import { AssetService } from './api-v1/asset/asset.service'; import { AuthRequest } from './app.guard'; export enum Route { @@ -43,12 +42,6 @@ const callbackify = async (fn: (...args: any[]) => T, callback: Callback) } }; -export interface UploadRequest { - authUser: AuthUserDto | null; - fieldName: UploadFieldName; - file: UploadFile; -} - const asRequest = (req: AuthRequest, file: Express.Multer.File) => { return { authUser: req.user || null,