forked from Cutlery/immich
		
	refactor(server): upload config (#3252)
This commit is contained in:
		
							parent
							
								
									382341f550
								
							
						
					
					
						commit
						1064128fde
					
				@ -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', () => {
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -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();
 | 
			
		||||
 | 
			
		||||
@ -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,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,
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user