瀏覽代碼

refactor(server): upload config (#3252)

Jason Rasmussen 2 年之前
父節點
當前提交
1064128fde

+ 133 - 3
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<IAssetRepository>;
+  let cryptoMock: jest.Mocked<ICryptoRepository>;
   let storageMock: jest.Mocked<IStorageRepository>;
 
   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', () => {

+ 76 - 2
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<MapMarkerResponseDto[]> {
     return this.assetRepository.getMapMarkers(authUser.id, options);
   }

+ 2 - 134
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<AssetEntity>; // 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();

+ 1 - 70
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,

+ 1 - 8
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 <T>(fn: (...args: any[]) => T, callback: Callback<T>)
   }
 };
 
-export interface UploadRequest {
-  authUser: AuthUserDto | null;
-  fieldName: UploadFieldName;
-  file: UploadFile;
-}
-
 const asRequest = (req: AuthRequest, file: Express.Multer.File) => {
   return {
     authUser: req.user || null,